At-Least-Once Message Processing in NATS with JetStream Durable Consumers
Set up JetStream durable consumers with explicit acknowledgements so message processing survives crashes — and learn why idempotent handlers are non-negotiable.
11 Apr 2026, 01:34 UTC

If you publish to a plain NATS subject and your worker crashes mid-processing, the message is gone. Core NATS is fire-and-forget. JetStream fixes this by persisting messages in a stream and letting a durable consumer track which messages have been acknowledged, so a crashed or restarted worker picks up where it left off. The trade-off: you get at-least-once delivery, not exactly-once, so your handler must tolerate duplicates.
This guide walks through the setup, the acknowledgement loop, and how to verify redelivery actually works before you trust it in production. Examples use the NATS CLI and Go-style pseudocode; client APIs differ across nats.go, nats.py, and nats.js and have changed between releases, so check the docs for your library version.
Prerequisites
- A NATS server with JetStream enabled: start it with
nats-server -jsor ajetstreamblock in the config file. JetStream needs disk for its file store, and the server's storage limits apply. - The
natsCLI for stream management and inspection. - A JetStream-capable client library for your language.
Create the stream before publishing
A stream binds storage to one or more subjects. If you publish to a subject no stream captures, the message stays in core NATS with no persistence — the most common silent failure.
nats stream add ORDERS --subjects "orders.>" \
--storage file --retention work --ackRun this on any host that can reach the server; it needs an account with JetStream admin permissions. --retention work removes messages once all consumers have acked them, which suits task-queue workloads. Confirm with nats stream info ORDERS — you should see the subject filter and zero messages.
Add a durable consumer with explicit acks
nats consumer add ORDERS WORKER \
--filter orders.created --ack explicit \
--wait 60s --max-deliver 5 --deliver allThree settings do the real work:
- AckWait (
--wait): how long the server waits for an ack before redelivering. Set it above your worst-case processing time. If processing takes longer, the message is redelivered while still being handled — duplicate work by design. - MaxDeliver: a bound on redelivery attempts. Without it, a poison message loops forever. Optionally configure a dead-letter subject so exhausted messages land somewhere inspectable instead of vanishing.
- Durable name (
WORKER): stored server-side. A typo creates a new consumer that reprocesses the stream from its start policy — check names carefully.
The processing loop
The handler pattern is: fetch, process, ack only on success.
sub, _ := js.PullSubscribe("orders.created", "WORKER")
for {
msgs, _ := sub.Fetch(10, nats.MaxWait(5*time.Second))
for _, m := range msgs {
if err := process(m); err != nil {
m.Nak() // or just let AckWait expire
continue
}
m.Ack() // only after side effects succeed
}
}If the process dies between fetch and ack, the server redelivers after AckWait. That is the whole guarantee — and why process must be idempotent. Publish with a Nats-Msg-Id header and dedupe on it, or make the side effect itself idempotent (upsert by order ID, not blind insert). Payments and emails need this protection; duplicates will happen under retries, restarts, and network failures.
Verify before you trust it
- Happy path: publish a test message, process it, then run
nats consumer info ORDERS WORKER. Pending should drop to zero and the ack floor should advance. - Crash recovery: publish, kill the consumer before it acks, restart it, and confirm the same message is redelivered after AckWait expires.
- Poison message: with a low MaxDeliver, publish a message that always fails and confirm redeliveries stop at the bound (and the dead-letter subject receives it, if configured).
- Server restart: restart
nats-serverand confirm stream and consumer state survive via the file store.
Limitations and failure modes
At-least-once is not exactly-once; there is no server-side dedupe of your side effects. Hitting the stream's max bytes or message count triggers its discard policy, which may drop old data — monitor storage. And if nats consumer info shows a growing pending count with a stalled ack floor, your consumers are down or failing; that command is your first diagnostic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.