Code that announces a freshly published article on social networks rarely crashes. It fails by doing the wrong amount: one post twice, three hundred posts at once, nothing at all for a month. Tests against a stubbed network stay green, because every request was well formed, and the first sign of trouble is a follower asking why the same link appeared twice. This guide lists the quiet failures worth checking in such code, in the order they tend to hurt: a repeated trigger, a first run that sends the whole archive, a trigger placed in the wrong part of the system, limits that differ per network, links that split your statistics, and a channel that dies without anyone hearing.

Failure one: the same article is posted twice

A scheduler can start a job twice, and a retry can repeat a call that did succeed. Neither is exotic. The Cloudflare documentation for Cron Triggers says scheduled Workers run on underutilized machines, and it gives no promise that a run happens exactly once (Cloudflare Cron Triggers). Delivery services say the same in plain words: Cloudflare Queues describes at-least-once delivery by default, with rare repeats, and suggests a unique identifier per message for anything that must not run twice (Cloudflare Queues, delivery guarantees). Amazon's engineers make the general point: a retry is only safe when the outcome of the call can happen once, and their preferred design is a caller-provided request identifier, so that two requests with the same identifier count as one (Amazon Builders' Library).

For announcements, this means the code should claim the article before it posts. Claiming is a conditional update on the article's own row, for example "set the announced time where it is still empty", and the code posts only if exactly one row changed. The second invocation changes zero rows and stops. Doing the claim after the post is the common mistake: two overlapping runs both read "not yet announced", both post, and both then record it.

Some networks add a second layer. Mastodon accepts an Idempotency-Key header on new statuses, and it remembers each key for 1 hour at most (Mastodon API, statuses). Inside that window a repeated request returns the original post. Once that hour is over, the same key can produce a second post. So a job that can replay hours later, such as a nightly sweep or a queue backlog, must check its own record first and treat the header only as a short-range net.

Failure two: a new "announced" column turns the archive into pending work

Adding a column that says whether an article has been announced looks harmless. Every existing row has an empty value, and the dispatcher reads empty as "waiting". The first run after the deploy therefore sees the entire back catalogue as unannounced and starts sending it, newest first or oldest first, to channels that had been quiet.

The repair belongs in the same migration that adds the column: stamp each article that is already public with an old date, and keep the drafts blank so that they get announced once they go live. Doing this afterwards with a separate script leaves a gap in which the scheduler can fire. The question that finds this defect before it happens is short: what happens if this code is switched on and meets every old article in its first minute?

A cap makes the flood smaller even when the migration is right: let a single run announce only a handful of articles, and write to the log when more were waiting.

Failure three: the announcement fires from cache invalidation

Many sites have a single function that runs after any change to an article: it refreshes the cached page, rebuilds the feed and pings the search engines. It is tempting to hang the social post on it. But the same function runs when someone fixes a typo, changes the address, restores an old version or deletes a draft, and each of those would announce the article again.

Put the call in the single spot where an article truly turns from hidden to public, and let that spot claim the row as described above. Cache refreshing may be called as often as the site likes, because it is harmless to repeat.

Failure four: every network has its own limits, and they are not the same unit

Check each channel against its own documentation, not against a rule remembered from another network.

  • Bluesky. The post record limits text to 300 graphemes and, separately, 3000 bytes in UTF-8 (AT Protocol lexicon). A link inside the text is marked by a facet whose start and end are byte positions, which the documentation's own sample code computes after encoding the text as UTF-8 (Bluesky, posts). A title with an accented letter or an emoji shifts those positions if they are counted in characters. Writes are metered in points with an hourly budget of 5,000, and a create costs 3 of them, which caps one account near 1,666 new records an hour; login is limited to 30 per 5 minutes and 300 per day (Bluesky, rate limits). A loop that logs in again for every article spends the login allowance first.
  • Mastodon. The length limit is a setting of each server, exposed in its configuration, and each URL counts as a fixed number of characters reserved by the server (Mastodon API, Instance). By default all endpoints allow 300 calls per 5 minutes, and media uploads 30 per 30 minutes (Mastodon API, rate limits). Posting with pictures at volume meets the upload limit before the general one.
  • A Facebook Page. The Pages API rule is calls per 24 hours equal to 4800 times the number of people engaged with the Page, and the error codes 4, 17, 32 and 613 each report a different kind of limit (Meta Graph API, rate limiting). A small page therefore has a small budget, and the code should read the usage headers rather than assume a number.
  • DEV. An article created through the API is a draft unless published is set, and the canonical_url field exists to say where the piece first appeared (Forem API v1). Posting a full copy without a canonical address competes with your own page.

The common thread is that "too long" and "too fast" are decided by the network, and the answer changes. Keep the limits in configuration with the date you read them, and cut text with a tool that understands graphemes, never by slicing a string at a number.

Every channel gets the same article address, usually with tracking parameters appended. Google's guidance on duplicate addresses shows a clean product address next to a copy with a tracking parameter, and recommends a canonical link element that points at the preferred version, plus a self-referencing one on the preferred page itself (Google Search Central). So the page must declare its clean address, whatever arrives in the query string.

On the measuring side, Google's campaign guidance asks for source, medium and campaign together, and notes that its analytics treats utm_source=google and utm_source=Google as different values (Google Analytics help). One typo in a channel's tag then makes two rows for the same network. Build the tagged address in one function, with a fixed lowercase value per channel, so that a code change cannot drift.

Failure six: a dead channel nobody hears

An expired token, a changed API or a banned account does not raise an alarm by itself. The dispatcher records an error, moves on, and the other channels keep working. Months later somebody notices that a network has been silent since spring.

Two habits catch it. First, read a missing token as "this channel is off" rather than as a fault: the channel then wakes up on its own once a token is supplied, and nothing raises an alarm before that. Second, watch the silence, not the errors: each channel records the time of its last confirmed post, and an alert fires when an enabled channel has posted nothing for longer than the publishing rhythm plus a margin. A run that was attempted is not a run that got an answer, so log which of the two happened.

Also run channels so that one failure does not hide the others: settle all of them and record each outcome, and give every outbound call a timeout so that one slow network does not hold up the publishing path.

The check that can fail

Make the failure happen on purpose. Fire the announce function twice at the same moment and confirm that one post appears. Apply the schema change to a duplicate of the archive and count the articles the dispatcher then treats as pending; that count must be zero. Edit a published article and confirm that nothing is sent. Take one channel's token away and confirm that its status reads "skipped" with no alert, then give it an expired token and confirm that the silence alert eventually fires. If any of these passes on code that was not changed, the test is not measuring what you think.

For the trap of tests that pass whatever the state of the code, see our guide on flaky tests and how to tell their causes apart, which covers a nearby habit of distrusting a green run.

When this does not apply

A manual workflow, where a person pastes each link into each network, has none of these failures, only others. The advice also assumes one publishing system with a scheduler or a queue; a platform that posts for you through its own integration has its own rules, which you cannot inspect. Limits and field names change, and some figures above were read from documentation on the day this was written; confirm them before relying on a number. The Bluesky text limits came from a search summary of the lexicon file rather than a direct read of it. We did not run live posts against every network for this guide, so the failures are drawn from the documentation and from typical shapes of such code, not from a measured sample.

A tool for reading the code

Our social-dispatch-code-review skill takes pasted announcing code and returns the risks visible in it, each as a line with a fixed code, a location, a cause and a remedy, then one overall verdict. Its checklist covers a response body shortened before parsing, text cut in the wrong unit, link offsets measured in characters, an announce call hanging off cache invalidation, a fresh marker column without a backfill, channels run without isolation or timeouts, an absent token handled as a fault, a staging environment that posts for real, and the signing and header details of Tumblr and DEV. Nothing outside the pasted code is reported, so a helper that is invoked but not included stays out of the findings, and sound code gets the answer No findings.

Read this post as Markdown: /blog/social-auto-posting-duplicates-floods.md · Atom feed.