Fire-and-Forget Is a Policy Violation

A note from the operator. I run Netrun Systems. We move work between services constantly — queues, uploads, callbacks, subprocesses, email — and for a long time we lost jobs in ways nobody could reconstruct afterwards. The protocol below is what came out of that. It is not elegant; it is a list of places where we found work disappearing. It also contains one section that is now out of date, which I have left in rather than quietly fixing, because the reason it went stale is instructive. Netrun's CTO agent wrote this up; I reviewed and approved it. — Daniel Garza
The shape of the bug, five times over
A job vanishes. The user sees "queued" forever, or "completed" with nothing to download. No error anywhere. No stack trace. The logs say the handoff happened.
We hit this often enough, in enough different components, to notice that every instance had the same shape:
Something asserted that an operation succeeded without checking that it had.
That is the whole bug class. The variations are only about which operation, and there turned out to be five in our stack. Our protocol document is 187 lines, and it is organized as five patterns because that is how the incidents arrived.
1. The queue push that didn't land
// Wrong
await client.lPush(QUEUE_KEY, payload);
return { queued: true };
await returning without throwing means the command was sent and acknowledged by the client library. It does not mean the item is in the list that the consumer reads. A failover mid-operation, a connection that dropped between send and apply, a misconfigured replica — the push resolves, the item is gone, and the job sits in queued forever because the thing that would have moved it never saw it.
// Right
await client.lPush(QUEUE_KEY, payload);
const position = await client.lPos(QUEUE_KEY, payload);
if (position === null) throw new Error('lPos verification failed');
Read it back. If it isn't there, it didn't happen.
Two details matter more than the read-back itself. First, the function must return { success, reason } rather than a bare boolean, because "it failed" and "it failed because Redis was unreachable" lead to different retries. Second — and this is the one teams skip — the caller must update job status on push failure. A job whose push failed and whose status still says queued is indistinguishable from a job that is about to be processed. It needs its own terminal state, queue_failed, so that it shows up as a problem instead of as patience.
2. The upload that silently didn't
Same shape, different API. Upload the artifact, then fetch its properties to confirm the object exists. Retry three times with backoff on transient errors.
The rule that matters here is the consequence, not the check:
An unverified upload must NEVER produce a download link.
This is the one I would put on a wall. A link handed to a user is a promise that something is there. If you generate the URL from the key you intended to write rather than from a confirmed object, you are promising on the basis of your own intentions. Users click it and get a 404 from a system that told them the job was complete, which is worse than a failure, because a failure is actionable and this is gaslighting.
The corollary is that upload exceptions must propagate. The instinct to wrap an upload in a try/catch and log the error is how a job gets marked complete with nothing in it. Let it throw and let the job fail.
The stale part, left in on purpose
This pattern in our protocol is written against Azure Blob Storage. It names get_blob_properties(), it has a failure-mode row reading "transient Azure issue," and its heading says Processor -> Azure Storage.
We retired Azure. Earlier this month we probed every Azure resource our deployment rules named and got NXDOMAIN on all of them — storage, key vault, database, cache, app services. The estate is on AWS and owned hardware now, and the equivalent check is a head-object call, not get_blob_properties().
The protocol's principle survived the migration untouched: upload, confirm the object exists, never link to an unconfirmed object. The API did not, and nobody updated it, because the principle kept working and so nothing forced the edit. That is how a correct document rots — not by being wrong, but by being right in the vocabulary of a system you no longer run. Any engineer following that section today would go looking for a method that isn't in the SDK they have.
We have since rewritten it, and the structure of the fix is the transferable part. The pattern now states the invariant as the normative thing — upload, read the object's metadata back, and never generate a link from an unconfirmed object — and relegates every API name to a provider table underneath it, one row per backend, marked as illustrative. The retired provider keeps a row, labelled as historical, for a specific reason: there is pre-migration code still using the old verb, and a reader who cannot recognise it as this pattern will mistake it for a missing dependency rather than an instance of a rule they already know.
The checklist item changed in the same way. It used to name one provider's method. It now reads: every object upload reads the object's metadata back before any link is generated. That sentence will survive the next migration, because it describes the obligation rather than the SDK.
The general lesson: a protocol written in the vocabulary of one vendor has an expiry date you did not choose. Put the invariant in the rule and the vendor in an example.
3. The callback that was never acknowledged
A service finishes work and notifies another service. This one has a subtlety the others don't.
ACK gates state:
callback_ok -> mark_completed, NOTmark_completed -> callback.
Order is the entire content of this rule. If you mark the job complete and then send the notification, a failed notification leaves you with a job the system believes is finished and a downstream that never heard. The state is now a lie, and it is a lie you will never detect, because nothing downstream is expecting anything.
If the acknowledgement gates the transition, a failed callback leaves the job in a state that still describes reality: work done, handoff incomplete. Retry three times with explicit backoff — ours is [2s, 4s, 8s] — and when retries are exhausted, mark callback_failed, which is deliberately distinct from failed.
That distinction is operationally important. failed means the work didn't happen and should be redone. callback_failed means the work is done and sitting there, and what's needed is a notification replay, not a recompute. Collapsing them into one status means someone eventually re-runs an expensive job that had already succeeded.
Only a 200 counts as an acknowledgement. Not 2xx, not "didn't throw." A 204 from a load balancer that never reached the application is not a receipt.
4. The subprocess that succeeded at nothing
Check exit code AND validate output quality (not empty).
Retry once on timeout or empty results before failing.
An empty report is worse than a failed job.
Exit zero is a claim about the process, not about the work. A report generator that exits cleanly having produced an empty file has satisfied every check most pipelines perform, and has produced nothing of value while reporting success.
"An empty report is worse than a failed job" is the same reasoning as the download-link rule, which is why both ended up in the document. A failed job triggers a retry, an alert, a human. An empty successful job triggers nothing and propagates downstream as though it were data.
5. Email, which gets its own rule because of how it's usually written
Always
awaitemail sends; never.catch()fire-and-forget.
The pattern this prohibits is the one that looks responsible:
sendEmail(opts).catch(err => logger.error(err)); // wrong
It appears to handle the error. What it actually does is detach the operation from the request, so the caller returns success before the send has resolved, and the failure lands in a log nobody reads with no connection to the user action that caused it. Await it. Log a structured result with the message ID on success, so that "did this user get their email" is answerable later. Log a structured error on failure — and for email specifically, usually don't throw, because a failed receipt shouldn't roll back a completed purchase.
Note that this is the one case where the right answer is not to fail the operation. The rule is not "always fail loudly." It is "always know."
The review checklist
Our protocol ends with the thing that makes it enforceable — a list short enough to actually run during review:
- Every queue push has a corresponding read-back verification
- Every upload confirms the object exists before any link is generated
- Every callback requires a
200and the ACK gates the state change - Every subprocess validates output, not just exit code
- Every retry has explicit backoff and a bounded count
- Every exhausted retry path has its own terminal status, distinct from generic failure
- No async operation is detached from the caller with a bare
.catch()
Seven questions. Most of our violations were found by asking the sixth one, because "what status does this end up in when the retries run out" is a question almost nobody asks while writing the happy path, and the usual answer is "it stays in the previous one," which is how a job gets stuck forever.
What generalizes
Every one of these is the same trade: you pay one extra round-trip to turn an assumption into a fact. A read-back after a push, a head request after an upload, a status code check on a callback. The cost is milliseconds. The thing you buy is that your system's state means what it says.
The reason it is worth writing down as a protocol rather than relying on judgment is that the failures are invisible by construction. A bug that throws gets fixed in an afternoon because it announces itself. A handoff that silently drops one job in a thousand gets reported as "the system is sometimes flaky," goes uninvestigated for months, and erodes trust in every number your service reports.
Fire-and-forget is not a style preference or a performance optimization. It is a decision to not know whether your system worked. Written down that way, nobody chooses it.
Netrun Systems builds and operates agent-orchestrated infrastructure. We publish our protocols including the parts that have gone stale, because a document that is right in the vocabulary of a system you no longer run is a trap, and pretending otherwise helps nobody.