Guaranteed message delivery: cost and when to use it

"Will I lose messages if a worker dies?" is the right question — and the answer is a trade-off, not a yes/no. Here's how guaranteed delivery works, what it costs, and how to choose per workload.

The first serious question anyone asks about a message-based system is: "if a worker grabs a message and then crashes, is that message lost?" It's the right question. The answer isn't a simple yes or no — it's a trade-off you should make deliberately, per workload, with your eyes open about the cost.

The three delivery semantics, and the one that doesn't exist

Every broker's documentation uses the same three names, so it's worth being precise about what each one promises before choosing.

  • At-most-once. The message is delivered zero or one times. Send it, don't track it, accept that a crash loses it. Cheapest, and honest for telemetry or cache warmups.
  • At-least-once. The message is delivered one or more times. Delivery is tracked until the consumer confirms, and an unconfirmed message is redelivered — which means duplicates are not an edge case, they are the guarantee. This is what "guaranteed delivery" means, here and in RabbitMQ, SQS, NATS JetStream and Kafka alike.
  • Exactly-once. The message is processed once, no more and no less. As a delivery guarantee this is not achievable, and no broker provides it however the marketing is worded.

The reason is worth stating plainly, because it decides how you write consumers. Delivery involves two separate actions — doing the work, and recording that the work was done — on two different machines, and a crash can land between them. Whichever order you pick, you get at-most-once (record first) or at-least-once (record last). There is no third order. This is the Two Generals problem, and it does not yield to a better library.

What is achievable is exactly-once processing, and the distinction is the useful part: you get it by making the effect idempotent rather than the delivery unique. Kafka's transactions and SQS FIFO deduplication are both this — deduplication at the consumer, given an at-least-once transport underneath. So the practical rule is: pick at-least-once for anything that matters, then make the handler safe to run twice. The rest of this post is about what that costs.

Two honest modes

@imqueue offers two delivery modes, and the difference is exactly about that crash scenario.

Unreliable (fast) delivery. A consumer takes a message and processes it. If it crashes before finishing, that message is gone. This is the fastest mode — there's no bookkeeping — and it's the right default for work that is frequent, idempotent-on-retry-elsewhere, or simply not costly to miss (think best-effort notifications, cache warmups, telemetry).

Guaranteed (safe) delivery. As a consumer takes a message, it's atomically moved into that consumer's own "processing" holding area, so a process that dies between taking a message and starting on it leaves the message behind to be rescheduled to another instance instead of swallowing it. The protection covers that hand-off rather than the whole handler: the entry is released once the message reaches your code, so a consumer killed part-way through the work loses that attempt like any other. This is still what you want for work that must not vanish — placing an order, charging a card, kicking off a payout — paired with a drain on shutdown for the planned case.

What guaranteed delivery costs

Safety isn't free, and it's useful to know the shape of the bill. In a recent @imqueue/core benchmark run — a 24-core Intel Core Ultra 9 275HX, Node.js 24, ~1 KB messages — unreliable delivery reached ~200,000 round-trip msg/sec and guaranteed delivery ~120,000 msg/sec. So safe mode costs roughly 40% of throughput (about 1.7× slower), keeping ~60%. Your numbers will differ with hardware and message size — see the benchmark post for the full run and how to reproduce it.

That's a very reasonable price for "never lose this message," and the key insight is that you don't pay it globally. You choose per queue. Latency-critical, loss-tolerant paths stay in the fast mode; critical paths run safe. You're not forced into one guarantee for the whole system.

The knob, and what it actually governs

Guaranteed mode stamps each hand-off with a time-to-live (safeDeliveryTtl, default 5 seconds; safeLockTtl through @imqueue/job). A sweep running on that same interval reclaims holding-area entries whose TTL has passed and puts them back on the queue. That's the mechanism behind rescheduling — and it is easy to read more into it than it does:

A slow-but-healthy task is not re-queued for being slow, and raising safeDeliveryTtl does not extend any protection over a long-running handler. The entry is released when the message reaches your code, so the TTL is a recovery deadline for an abandoned hand-off, not a processing deadline.

So tune it for recovery latency rather than against your p99: shorter brings an abandoned message back sooner, longer sweeps less often. What survives a restart that lands mid-handler is a drain on shutdown, not this TTL.

A quick decision guide

Ask two questions about each kind of message:

  1. If this is lost, does it matter? If no → unreliable is fine, and faster. If yes → guaranteed.
  2. Is the work idempotent? Guaranteed delivery is at-least-once: a message can be processed more than once (a hand-off whose release didn't land can be swept back onto the queue while the first consumer is still working, and @imqueue/job re-sends a job whose handler throws). So design critical handlers to be idempotent — safe to run twice — using an idempotency key or a dedupe check. This matters regardless of framework; at-least-once is the honest guarantee, not exactly-once.

Match the mode to the message and you get the best of both: raw speed where loss is acceptable, and durability where it isn't — without paying for durability everywhere. The delivery options are covered in the API reference; Getting Started gets you a service to try them on.

Read this page as plain markdown — no HTML, no navigation. For pasting into an LLM, or for an agent to fetch.


Building on @imqueue? The open-source packages live on GitHub and the docs at imqueue.org. Shipping inside a closed-source product? See commercial licensing & support.