This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.
If you have built event-driven systems for any length of time, you have probably internalized the mantra of at-least-once delivery: keep retrying until the work is done, and design everything downstream to be idempotent. Itâs good advice. But thereâs a subtle failure mode that hides right underneath it - one where your system faithfully reports success, commits its progress, and quietly drops work on the floor. No exception, no alert, no dead letter. Just a message that was supposed to do something, and didnât.
I ran into this recently while debugging why a small percentage of events were mysteriously never being processed. Everything looked healthy. The producer got a 2xx. The consumer committed its offset. The dashboards were green. And yet the work never happened. This post is about that gap - the space between âthe system accepted my requestâ and âthe system actually did the workâ - and why itâs one of the more dangerous places for a distributed system to lose data.
Motivation
Most write-ups about message-processing reliability focus on the well-known hazards: duplicate delivery, out-of-order messages, poison pills, consumer lag. Those are real, and thereâs plenty written about them. What I found much less discussed is the case where the acknowledgment itself is a lie - where a component reports success for an operation that has only been accepted, not completed, and a second component treats that acknowledgment as permission to throw the original message away.
This is a design smell that shows up across all sorts of stacks: a queue consumer that calls an async API, a workflow engine that enqueues a job, a service that hands off to a background worker. Any time you have a handoff across an asynchronous boundary , you have the potential for this gap. So I want to walk through the anatomy of the bug from first principles, then talk about how to close it properly.
Background: two kinds of âyesâ
Before we get to the bug, letâs be precise about acknowledgments, because the whole problem lives in some sloppy vocabulary.
When a system replies to your request, it can mean one of two very different things:
- âI have accepted your request.â - Iâve durably recorded your intent, and I promise to try to do the work. Think HTTP 202 Accepted. The work hasnât happened yet.
-
âI have completed your request.â - The work is done, and its effects are durable. Think
200 OKwith a result body.
These are worlds apart, and conflating them is the root of a lot of pain. The trouble is that many APIs return the same status code for âacceptedâ whether or not the work will eventually succeed. A 202 (or a 204 No Content, which is even more ambiguous) tells you the request was received. It tells you nothing about whether the work will run.
Now layer on the consumer side. A huge number of event-driven systems are built on brokers that use offset-based consumer groups - Apache Kafka being the canonical example. If you want a primer, I wrote an introduction to Apache Kafka a while back. The mental model is simple:
- Messages in a partition have monotonically increasing offsets.
- Your consumer reads a message, does some work, and then commits (or âmarksâ) the offset to say âIâm done with everything up to here.â
- If the consumer crashes before committing, the broker redelivers from the last committed offset. Thatâs what gives you at-least-once semantics.
đĄ The offset commit is a promise about the past. When you commit offset N, you are telling the broker âevery message up to and including N has been fully handled, and you never need to give them to me again.â If that statement isnât actually true, you have manufactured data loss with your own hands.
Hold onto those two ideas - the ambiguous âyesâ and the offset-as-promise - because the bug is what happens when they collide.
Anatomy of the pipeline
Let me describe a deliberately generic pipeline. Strip away the specific technologies and almost every async system looks like this:
The consumerâs job is to read an event and translate it into an action by calling some downstream control plane - an async job API, a workflow trigger, a task queue. The control plane accepts the request and, at some later point, a worker actually executes it.
The consumerâs loop, in pseudocode, looked roughly like this:
for {
msg := broker.Read()
err := dispatchJob(msg) // calls the async control plane
if err != nil {
retryWithBackoff(dispatchJob, msg)
}
// Always commit so we never get stuck reprocessing a bad message.
broker.Commit(msg)
}
At a glance this looks reasonable, even defensive. Thereâs a retry with backoff. Thereâs a comment explaining that we always commit to avoid getting wedged on a poison message. Someone clearly thought about failure here.
And that is exactly what makes the bug so insidious.
The bug: the acknowledgment gap
Hereâs the sequence that loses data.
- The consumer reads a message and calls
dispatchJob. - The control plane returns
204 No Content- ârequest accepted, a job has been created.â From the consumerâs point of view, this is success.errisnil. - Milliseconds later, and entirely outside the consumerâs view , the control plane rejects the job before it executes. Maybe a concurrency quota was exceeded. Maybe an admission controller said no. Maybe the queue was full. The job transitions straight to a terminal ârejectedâ state without a single line of business logic ever running.
- Back in the consumer,
dispatchJobreturnednil, so the retry loop never fires - there was nothing to retry, as far as it knows. - The consumer commits the offset.
The offset moves forward. The broker will never redeliver that message. The job never ran. And nobody was told.
This is the acknowledgment gap. The failure happened in the window between âacceptedâ and âexecutedâ , and our success signal was wired to the wrong end of that window. We treated âa job was createdâ as if it meant âa job will run,â and those are not the same statement.
đĄ The most dangerous bugs arenât the ones that throw. Theyâre the ones that return
nil. An exception is a gift - itâs the system telling you something is wrong. Silent, structurally-invisible loss gives you nothing to catch.
What made it worse is that the âalways commitâ decision - added defensively to avoid an infinite reprocessing loop - turned a recoverable failure into an unrecoverable one. The one safety mechanism that could have saved us (letting the broker redeliver) was disabled precisely when we needed it.
Why the usual instincts donât save you
When engineers first see this, they reach for familiar fixes. Most of them donât actually close the gap:
-
âJust check the status code.â We did. It was
204. The status code describes the acceptance, not the outcome. The information we needed didnât exist yet at the moment we got the response. - âAdd a retry.â There was one. It only triggers on a failed dispatch, not a failed execution. You canât retry something you donât know failed.
- âMake it idempotent.â Idempotency is necessary but not sufficient here. Idempotency protects you from doing the work twice; it does nothing to protect you from doing it zero times.
- âUse exactly-once semantics.â Setting aside the long debate about whether exactly-once is even a coherent goal across independent systems - the transactional guarantees of your broker do not extend into a third-party control plane youâre calling over HTTP. The moment you cross that boundary, youâre back to coordinating two independent systems with no shared transaction.
The real issue is architectural: we committed our durable progress based on a signal that didnât actually confirm the work was durable. No amount of tuning the individual pieces fixes that. You have to move the acknowledgment.
Fixing it: verify before you ack
The core principle is a single sentence:
đĄ Never acknowledge a message until you have confirmed the work it represents has actually started (or completed) - not merely been accepted.
Everything else is mechanics. Letâs walk through them, because the mechanics are where the interesting distributed-systems problems hide.
1. Close the loop: confirm execution, donât assume it
Instead of trusting the 204, the consumer now verifies that the dispatched job reached a real running (or terminal-success) state before committing. In practice that means polling the control planeâs read API after dispatch:
started, err := dispatchAndVerify(msg)
if started {
broker.Commit(msg) // safe: the work is genuinely underway
} else {
// do NOT commit - let redelivery give us another shot
alert(msg, err)
}
dispatchAndVerify dispatches, then polls: did a job actually enter a non-rejected state? If it sees the tell-tale ârejected before executingâ terminal state, or it canât find the job at all within a bounded window, it treats that as a failure - which is the thing our original code could never see.
This is really just applying read-after-write thinking to a control plane. Donât trust the write acknowledgment; go read the state back and confirm reality matches your intent.
2. The correlation problem
Hereâs a genuinely tricky sub-problem that this exposes, and itâs a great example of why distributed systems are hard: the dispatch API often doesnât tell you the ID of the thing it just created. You fire a request, you get back 204 No Content - literally no content - and now you need to find âthe job I just createdâ among all the jobs.
Youâre left correlating on secondary signals:
- A creation timestamp window (âa job created after time Tâ), which is racy under concurrency - two near-simultaneous dispatches can be ambiguous.
- A business key embedded into the jobâs metadata at creation time, if the API lets you set something like a name or a label.
The robust fix is to make the work self-identifying : stamp a correlation key you already own (the entity ID, a request UUID) into the job at dispatch time, so that when you read the state back you can match on it exactly rather than guessing by time. If your control plane supports naming or tagging the work, use it. This is the async equivalent of propagating a trace ID, and it pays for itself the first time you have to debug a race.
đĄ Any time you hand work across an async boundary, ask: âWhen this comes back, how will I know itâs mine?â If the answer is âby timestamp,â you have a race waiting to happen.
3. Bounded redelivery, or: donât trade loss for a hot loop
The moment you say âdonât commit on failure so the broker redelivers,â someone will rightly point out the opposite failure mode: what if the work keeps failing? Now youâve built an infinite reprocessing loop, and youâre hammering a control plane thatâs already unhappy. This is the eternal tension:
- Commit too eagerly â you lose messages (the original bug).
- Never commit on failure â you can wedge the consumer forever.
The answer is bounded retries with escalation. Track how many times a given message has been through the wringer - keyed by its stable identity (partition + offset, or a business key) - and:
- Retry with exponential backoff while attempts remain, so a transient quota exhaustion gets a chance to clear.
- Once youâve exhausted the budget, stop, escalate loudly, and then commit so a single doomed message canât block the whole partition behind it.
That final commit is not âgiving up silentlyâ - itâs a deliberate, observable decision to route the message to a human (or a dead-letter queue) instead of blocking the stream. The difference between this and the original bug is everything: the original dropped work with zero signal; this drops it only after N visible, alarmed attempts.
4. Separate transient failures from terminal ones
A subtle but important refinement: when you poll to verify, a failure to read the state is not the same as the work being rejected. If your verification call itself hits a network blip or a 503, and you treat that as âthe job failed,â youâll re-dispatch and potentially create duplicate work - trading a lost-message bug for a double-processing bug.
So the verification loop needs to distinguish:
- âI confirmed the job was rejectedâ â terminal, re-dispatch is warranted.
- âI couldnât reach the control plane to checkâ â transient, just retry the read, donât re-dispatch.
Only definitive answers should drive irreversible decisions. Everything else is a retryable read. This is the same discipline as not making state transitions on ambiguous signals - you wait until you actually know.
5. Make the invisible visible
The reason this bug survived in production is that it was structurally unobservable. So the last piece is observability, and itâs not optional:
- Emit a metric/event whenever a dispatched unit of work fails to start.
- Alert when the retry budget is exhausted and a message is dropped.
- Log the correlation key, the attempt count, and a link to the rejected work.
If your system is going to make a hard decision like âIâm dropping this after five tries,â that decision must be the loudest thing in the room, not a silent commit. A good rule of thumb: every place your code can decide to discard work should be capable of paging a human. You may choose not to page - but the capability being there forces you to consciously design the failure path instead of falling into one.
Stepping back: the general principle
If you zoom out from the specific mechanics, this whole class of bug reduces to a few reusable principles that are worth carrying into any distributed system you build:
- Distinguish âacceptedâ from âcompletedâ - always. Treat them as different events with different names, different metrics, and different handling. Never let one masquerade as the other.
- Anchor your durable acknowledgment to the durable outcome. Commit your offset (or delete your message, or mark your row done) based on confirmation of the effect you care about, not on a transport-level receipt.
-
A
nilerror is a claim, not a fact. Verify claims that cross trust boundaries, especially async ones. Read-after-write is cheap insurance. - Bound every retry, and escalate at the boundary. Unbounded retries and silent drops are two sides of the same coin; the cure for both is a visible, finite budget with a loud exit.
- Only act irreversibly on unambiguous signals. Transient âI donât knowâ should never trigger a decision that assumes âno.â
None of these are novel on their own. Whatâs interesting is how a single missing distinction - accepted vs. executed - cascades into silent data loss when it meets an offset commit. Itâs a good reminder that in distributed systems, the bugs rarely live inside a component. They live in the seams between components , where two reasonable local decisions add up to one unreasonable global one.
Tradeoffs
Nothing here is free, and Iâd be doing you a disservice to pretend otherwise.
What you gain
- No more silent loss - the failure mode thatâs hardest to detect and most damaging to trust.
- A verifiable, observable processing pipeline where âdoneâ actually means done.
What it costs
- Extra latency and load. Verifying execution means additional reads against the control plane per message. Poll intervals and budgets need tuning.
- More moving parts. Correlation keys, attempt tracking, and escalation paths are code you now own and test.
- You must embrace at-least-once for real. Verify-and-redeliver will occasionally produce duplicates (e.g., if a job actually started but your confirmation read failed). Idempotency downstream stops being optional - but that was always true; this just makes it honest.
For low-value, high-volume telemetry you might happily accept silent loss and skip all of this. For work where every single message must result in an action, the cost is obviously worth it. As always, the right answer depends on what the data is worth.
Conclusion
The most memorable bugs are the ones that teach you to distrust a word youâd been using carelessly. For me, that word was âsuccess.â A 2xx is not success. A committed offset is not success. Success is the effect you actually wanted, confirmed to be durable. Everything else is just a system being polite.
If you take one thing away: go look at your event-driven pipelines and ask where you commit progress based on an acknowledgment rather than a confirmation. If those two things are wired together, you probably have an acknowledgment gap hiding in there too - quietly green on every dashboard, right up until someone asks where their data went.
Thanks for reading âď¸


Top comments (0)