Scheduled stories that go live late, or twice
In short
Compare four instants: the slot the story was scheduled for, when a worker claimed the job, when the record flipped live, and the first edge request carrying it. Each of the 3 gaps has a different owner — scheduler latency, worker duration, propagation. A duplicate publish is the same machinery failing the other way: the gap between the two publishes usually equals the queue's visibility timeout.
Key takeaways
- Four timestamps split the delay into scheduler latency, worker duration and propagation, each with a different owner.
- Two publishes separated by roughly the queue's visibility timeout means the job was claimed twice, not scheduled twice.
- A publish command needs a natural key — story, slot instant and revision — enforced by a unique index, not a column.
- Store the slot as an instant plus the zone the editor chose; a naive local timestamp breaks twice a year.
- Alert on the slot that passed unclaimed, not on the job that failed, because a dead scheduler emits no failures.
- Publishing before derivatives finish rendering is why social scrapers cache a story with no image.
Late and duplicate are one defect seen from two sides: a scheduler nobody verifies, driving a publish command with no idempotency key. Start by pulling four instants for the affected story — the slot it was scheduled for, the moment a worker claimed the job, the moment the record flipped to published, and the first request the edge served carrying the new object. Three gaps sit between those four numbers, and each belongs to a different part of the system. Whichever gap is large is the one to investigate; the others are noise.
Four instants, three gaps, three owners
| Gap | What it measures | A large value means |
|---|---|---|
| Slot to claim | Scheduler latency | Polling interval, cron drift, a cold start, or a scheduler process that was not running |
| Claim to publish | Worker duration | Asset processing, search indexing or a downstream call inside the publish transaction |
| Publish to first edge hit | Propagation | A build that had not regenerated, or an invalidation that never fired |
If the third gap is the large one, the story did publish on time and the reader could not see it, which is a caching problem rather than a scheduling one and is traced separately in some readers still see the old version of a corrected story.
Why twice and late are the same defect
A publish job is a message. Almost every queue worth using delivers at least once, which means a message that is slow to acknowledge will be delivered again. Late publishing is that message arriving after its slot; duplicate publishing is the same message arriving twice because the first attempt did not acknowledge in time. The tell is arithmetic: measure the interval between the two publish events. If it is close to your queue's visibility or acknowledgement timeout, the job was claimed twice — the worker was still running when the queue decided it had died. Nobody scheduled the story twice, and looking in the CMS for a second schedule row will waste an afternoon.
Where the minutes go: five causes
- Cron drift and cold starts. A scheduler polling once a minute cannot be more accurate than a minute, and a serverless worker adds its cold start on top. A story scheduled for 09:00 goes out somewhere before 09:02, which is invisible for a feature and unacceptable for a markets story timed to an exchange open. If the desk needs second-level accuracy, a per-minute poll is the wrong tool and a per-item timer is the right one.
- A queue with no deduplication. The job is claimed, the worker takes longer than the visibility timeout, the queue re-delivers, and two workers publish. The fingerprint is the timeout arithmetic above. The fix is not a longer timeout — it is a publish command that a second attempt cannot double.
- Time zone and daylight-saving handling in the schedule field. An editor in one zone, a server in UTC, and a field storing a naive local timestamp. Twice a year that field either names an instant that does not exist or one that occurs twice. Store the instant alongside the zone identifier the editor chose, from the IANA time zone database, and render back into that zone for display.
- The worker publishes before the bundle is ready. The record flips live while image derivatives are still rendering, so the first Open Graph scraper and the first crawler both cache a story with a missing lead image — and Facebook, LinkedIn and Slack will each hold that cached preview for hours. Publishing should assert a readiness predicate over the whole bundle, not over the text record alone.
- A downstream index never received the event. The article page is live and correct while the section front, the RSS feed, the news sitemap and the on-site search index do not have it. To everyone except a person holding the direct link, the story did not publish. Check each of the 4 consumers separately: they fail independently and none of them raises an alarm.
One cause is worth separating out because it looks like a scheduler fault and is not. Imported archives frequently carry a publication timestamp from the old system, and if the importer wrote that value into the field the scheduler reads, the scheduler will do exactly what it was told — publish something that is already live, or re-publish it. That is a field-mapping decision, and it belongs with the rest of the choices made in mapping legacy stories into a model that did not exist.
The key a publish command needs
Make publishing idempotent with a natural key rather than a generated one, so a retry reproduces the same key by construction instead of remembering it. The key is the story identifier, the scheduled instant and the content revision. Write it into a publications table with a unique index across those three columns, inside the same transaction that flips the record live. A second attempt collides with the index, catches the conflict, and returns the original result rather than performing the work again.
- Compute the key from data the caller already has. If the key comes from a random identifier generated at call time, a retry generates a new one and the guard does nothing.
- Enforce it with a unique index, not with a lookup before the insert. A check-then-write in an at-least-once system is the bug you are trying to fix, restated.
- Include the revision. Re-publishing a corrected story is a legitimate second publish; re-publishing the identical revision is not, and the key should permit the first and refuse the second.
- Make the whole publish one transaction over the bundle — record, derivatives, key set for invalidation — so a partial publish is not a state the system can be in.
- Return the original outcome on conflict rather than an error. The caller that retried is not doing anything wrong, and an error here turns a handled case into a page for somebody.
A retry is not an exception. In any queue that delivers at least once, a retry is the normal case you have not written down yet.
Alert on the slot, not on the job
The standard alarm — the publish job failed — cannot detect the most common serious failure, which is that no job ran at all. A scheduler that has died emits nothing. The alarm that catches everything is a query over the schedule itself: any slot whose instant has passed by more than 2 minutes with no publication row against it. Run it every minute and route it to a person. It costs a few lines, and it catches a stopped scheduler, a poisoned message, a deploy that dropped the worker and a slot whose story was deleted underneath it.
- Add a second check for the inverse: more than one publication row for a story and slot. That is the duplicate alarm, and it fires before a reader finds the second URL.
- Gate the scheduler on open workflow states. A story with an unresolved obligation should not be publishable by a timer, which is why the legal read belongs in the state machine rather than in a conversation — the argument in the pre-publication legal read as a workflow state.
- Count how many stories are actually scheduled per day and at what accuracy the desk needs them. If most publishing is immediate and scheduling is a weekly newsletter, per-item timers are unnecessary; if the desk schedules 60 items a day against embargo times, they are not optional. That number is one of the facts a platform decision should rest on, alongside the rest of the content audit before anyone shortlists a platform.
- Keep the scheduler's own health visible: last poll time, queue depth, and the age of the oldest unclaimed slot. Three numbers on one panel.
None of this is difficult work; it is careful work, and it tends to get deferred because the failure is intermittent and the workaround is a person watching a clock. Building the publish path so it can be retried safely is the sort of thing we treat as core scope in MVP and product builds rather than as hardening to be done later. An embargoed release with a fixed lift time is a different obligation from a scheduled slot, and it is defined separately in embargoes as a field the platform enforces.
The rest of this silo sits under CMS and publishing platform engineering, and the platform work we do for newsrooms is described in our media and publishing practice.
Frequently asked questions
Short answers to the follow-ups this page tends to raise.
Why did a scheduled post not publish on time?
Compare the slot instant with the moment a worker claimed the job. If that gap is large, the fault is in the scheduler — a polling interval too coarse for the accuracy the desk expects, a cold start, drift, or a process that was not running at all. If the gap is small but publication was still late, the delay is inside the worker, usually asset processing or a downstream call that has no business being in the publish transaction.
Why did the same article publish twice with two URLs?
Almost always because the job was claimed twice, not scheduled twice. Measure the interval between the two publish events: if it is close to your queue's visibility or acknowledgement timeout, the first worker was still running when the queue re-delivered the message. The repair is an idempotent publish command keyed on story, slot instant and revision, enforced by a unique index rather than a lookup.
How should the scheduled time be stored?
As an absolute instant plus the time zone identifier the editor selected, taken from the IANA database. Storing a naive local timestamp breaks at every daylight-saving transition, when a wall-clock time either does not exist or occurs twice, and storing only an instant loses the editor's intent when the schedule is edited later. Both values are needed: one to fire on, one to display and re-evaluate against.
What alert actually catches a broken scheduler?
A query over the schedule, not over the jobs: any slot whose instant passed more than 2 minutes ago with no publication row recorded against it. Job-failure alarms cannot detect a scheduler that has stopped, because a process that is not running produces no failures. Pair it with a duplicate check — more than one publication row for the same story and slot — and the two together cover both halves of this defect.
- scheduling
- queues
- idempotency
- publishing platform
The work behind this page
Builds from our portfolio that this page draws on.
Read next
- Some readers still see the old version of a corrected storyA correction that is right for you and wrong for a colleague is held by exactly one layer. Ten minutes of evidence tells you which, and stops the reflex to flush everything.diagnostic
- Search traffic fell after the replatform: working back through the mapPost-migration traffic loss is a URL-class problem, not a rankings problem. Author pages, tag archives, pagination and old AMP paths are the classes nobody maps, and they are where the traffic went.diagnostic
- Embargoes as a field the platform enforces, not a line in an emailAn embargo is a condition set by someone outside the newsroom, and a platform can only enforce it if it is a typed record with a source, a lift instant, a scope and a list of surfaces.definition
- News sitemaps: the two-day window, and what the archive stays out ofA news sitemap is a rolling window, not an index. At publisher scale the failure is inclusion — archives, republished timestamps and paginated lists leaking into a file meant for two days of news.definition
- Surrogate keys: purging one story without flushing the front pageOne story lives on six surfaces. The keys attached to those responses at render time decide whether a correction reaches all six, or whether somebody asks for a full flush at the worst moment.definition
- Dunning as a state machine: retries, grace and when access stopsDunning is not a sequence of emails. It is a state machine, and every state must be written into the entitlement record so access, messaging and reporting cannot drift.definition
Working on something in this space?
Tell us where you are in a sentence or two. We'll tell you honestly whether we're the right team, and what a sensible first slice of the work looks like.
Start the conversation