After moving my blog to EmDash CMS, getting an article live finally felt frictionless. I finish a draft, push it over MCP from my terminal, and it is live at the edge in a second.
And then comes the part I hate: distribution.
If you write technical posts and don't share them, nobody knows they exist. So right after finishing an article, instead of moving on to my next project, I was stuck doing 20 minutes of mind-numbing tab-switching—opening X, Bluesky, Mastodon, Threads, and a Nostr client, trimming the excerpt to fit five different character limits, and manually chaining self-replies.
I looked at existing SaaS social schedulers, and none of them made sense for a developer blog. Paying $15 to $30 a month just to poll an RSS feed is absurd. Worse, most of them either don't support Nostr and Bluesky properly, or they dump your article link straight into the main post on X and Threads—which guarantees the algorithm buries it.
Since my entire stack already runs on Cloudflare Workers, I decided to build my own zero-dollar distribution engine: Syndicator.
What started as "I'll just write a quick cron script that parses RSS and calls five APIs" quickly turned into a tour through algorithmic link penalties, UTF-8 byte offsets, RFC 5849 OAuth quirks, and raw Worker WebSockets. Here is how it came together.
On paper, an RSS-to-social bot is thirty lines of code: wake up on a cron schedule, fetch /rss.xml, loop through the items, and post them.
Then you stop and think about what actually happens the first time you deploy that script against an existing blog with 35 published articles: it immediately blasts five years of your archive across five social networks at the same time.
And what happens four hours later when you haven't published anything new? It downloads and parses the entire XML feed again for nothing. Or worse: what happens if Bluesky and Mastodon succeed, X throws a 503 Service Unavailable, and the cron retries four hours later? Without granular state tracking, you end up double-posting to your followers on every retry.
To keep the service completely free ($0/month), I wanted zero relational databases and zero external queues—just a Cloudflare Worker running on a 4-hour cron (0 */4 * * *) backed by Cloudflare KV.
Solving those failure modes shaped the RSS sync engine:
- 5ms Exit on Unchanged Feeds: Syndicator stores the feed's
rss:etagandrss:last_modifiedheaders in KV. Every 4 hours, it sendsIf-None-MatchandIf-Modified-Since. When there's no new post, the blog returns304 Not Modifiedand the Worker exits in ~5 milliseconds without parsing a single byte of XML. - Silent Baseline Seeding: On a fresh deployment (
rss:latest_urlis empty), Syndicator records the top article in the feed as its high-water mark and publishes nothing. Only articles published after deployment get syndicated, processed oldest-to-newest so timelines stay in order. - Per-Platform Checkpointing: Every article gets a state record at
post:<canonical_url>tracking the remote post ID for each network. If platform 3 out of 5 fails mid-run, the batch halts immediately so later posts don't jump the queue. On the next run, Syndicator sees which platforms already succeeded for that URL, skips them, and only retries the failed ones.
Once the RSS state machine was solid, I had to deal with how modern social networks actually work.
If you broadcast the exact same post format everywhere, your reach gets crushed on half of them:
- The Open Web (Bluesky, Mastodon, Nostr): Feeds are chronological. Readers want the excerpt and the clickable article link right there in the main post, complete with a rich preview card.
- The Algorithmic Feeds (X and Meta Threads): Recommender algorithms actively penalize posts that contain external links because they want to keep users scrolling inside the app. On X and Threads, the only way to get normal reach is to post a clean, link-free text hook as Post 1, and drop the actual article link into Post 2 (the first self-reply).
So I built the thread planner to route differently depending on the target platform: chronological networks get the link inline in the root post, while X and Threads automatically get a two-post minimum where the outbound URL is tucked into the first reply.
While testing how links looked across platforms, I noticed another annoyance: my article slugs are long (/articles/why-astros-native-csp-breaks-on-shiki-and-why-switching-to-prism-is-a-bad-trade/). In plain-text Nostr clients, a 90-character URL looks terrible.
Since the Worker was already running on Hono and connected to KV, I added a built-in 3-character Base62 URL shortener ([0-9a-zA-Z], giving 238,328 unique links). It maps short:url:<canonical_url> <-> short:code:a9X and serves GET /:code with a 301 Moved Permanently and a 24-hour Cache-Control header—meaning Cloudflare's edge cache handles repeat clicks without even waking up the Worker.
I wanted Syndicator to have near-zero dependencies and run on pure Web Standards inside V8 isolates. Wiring up all five platforms side by side felt like a museum tour of how web APIs have evolved over the last fifteen years.
1. The Unicode Math Bug (Intl.Segmenter vs. Bluesky's UTF-8 Facets)
Before even sending a request, splitting an excerpt into 280- or 300-character chunks hit a classic JavaScript landmine: string.length counts UTF-16 code units, not visible characters. A single family emoji (👨👩👧👦) counts as 11 in .length, and a rainbow flag (🏳️🌈) counts as 6.
To split threads cleanly on word boundaries without cutting words or miscounting emojis, Syndicator uses the standard Intl.Segmenter(undefined, { granularity: 'grapheme' }) API while dynamically reserving space for (1/3) thread counters.
Then came Bluesky (AT Protocol). Bluesky doesn't auto-detect URLs in post text; you have to pass a facets array specifying where the link starts and ends—measured in UTF-8 byte offsets, not UTF-16 character indices. If your post has a single curly apostrophe or em-dash before the URL, string.indexOf(url) will be off by several bytes and Bluesky will turn the wrong word into a hyperlink. Encoding slices through new TextEncoder() solved it cleanly, paired with an app.bsky.embed.external card for the link preview.
2. X (Twitter) API v2 and Refusing the OAuth 2.0 Refresh Treadmill
When wiring up X (POST https://api.twitter.com/2/tweets), I had two auth choices: OAuth 2.0 with PKCE, or classic OAuth 1.0a User Context.
OAuth 2.0 access tokens expire after two hours, which means maintaining a stateful refresh-token rotation loop in KV—and if a refresh ever fails mid-flight, your bot dies until you manually re-authenticate. OAuth 1.0a user tokens never expire.
The catch is that OAuth 1.0a requires signing every request with RFC 5849 HMAC-SHA1, and most people pull in ancient Node.js crypto libraries to do it. Instead, I implemented the RFC 5849 signature generator directly using the native Web Crypto API (crypto.subtle.sign("HMAC", ...)). The one spec gotcha that caught me: under RFC 5849 §3.4.1.3, when sending a application/json payload to v2 endpoints, the POST body must be excluded from the signature base string—only the sorted oauth_* parameters and URL are signed.
3. Mastodon's Built-In Idempotency & Meta Threads' Two-Step Containers
Mastodon (POST /api/v1/statuses) was by far the cleanest REST API of the bunch. Better yet, ActivityPub instances support a native Idempotency-Key header. By hashing mastodon:<url>:<segmentIndex> into a deterministic 32-character SHA-256 hex string via Web Crypto, Mastodon guarantees it won't create duplicate toots even if a network glitch triggers a retry.
Meta Threads (graph.threads.net/v1.0), on the other hand, requires a multi-step choreography: first querying GET /v1.0/me to resolve your numeric user ID from your 60-day token, then calling POST /{user_id}/threads to stage an unpublished media container, and finally calling POST /{user_id}/threads_publish with the container ID to actually put the post on your timeline—repeating the dance with reply_to_id for the follow-up link post.
4. Nostr: Signing Schnorr Events Over Worker WebSockets
Nostr doesn't use REST at all. It is a decentralized network of relays speaking raw WebSockets where every post is a NIP-01 Kind 1 JSON object signed with a secp256k1 BIP-340 Schnorr signature.
Using nostr-tools to sign the event with an nsec1... (bech32) or hex private key was straightforward, but broadcasting it from a Cloudflare Worker required using Cloudflare's native fetch WebSocket upgrade API instead of Node's ws library:
const resp = await fetch(relayUrl, {
headers: { Upgrade: 'websocket' },
});
const ws = resp.webSocket;
if (!ws) throw new Error(`Relay ${relayUrl} rejected WebSocket upgrade`);
ws.accept();
ws.send(JSON.stringify(['EVENT', signedEvent]));Syndicator fans the signed note out concurrently across multiple relays (wss://relay.damus.io, wss://nos.lol, wss://relay.nostr.band), attaches an ["r", articleUrl] reference tag, and waits for each relay to reply with a NIP-01 ["OK", event_id, true] confirmation frame before closing the socket.
Now my publishing loop is genuinely finished. I write an article, push it to my site, and close my editor.
On the next 4-hour tick, Syndicator wakes up at the edge, spots the new RSS entry, mints a 3-character shortlink, formats the threads for each network's rules, and distributes it across all five platforms for $0/month.
If you want to run the same setup for your own blog, the code is open source on GitHub:
- Repository: github.com/Evgenii-Zinner/syndicator
Point RSS_FEED_URL at your feed, add the secrets for whichever platforms you use, and bun run deploy it to your Cloudflare account.