JetStream gives you delivery guarantees as a small set of primitives you compose, not a switch you flip. The happy path proves very little about them; what matters is what happens when a message fails. This post walks the complete lifecycle of a failing message in NATS JetStream, from the publish acknowledgment through redelivery, a dead-letter queue (DLQ), and replay, with working Go.
TL;DR: JetStream’s delivery guarantees compose from a handful of primitives. A publish acknowledgment confirms an event is persisted in a stream (with
Nats-Msg-Iddeduplication inside a configurable window). A durable consumer tracks each subscriber’s progress, and explicit acknowledgment gives the consumer three verbs: ack (done), nak (redeliver immediately, after a delay, or on a backoff schedule), and term (stop trying).MaxDeliverbounds redelivery. When a message exceeds it, the server publishes an advisory on$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<stream>.<consumer>; capture those advisories in a stream of their own, fetch the original message by sequence, and you have a dead-letter queue that is itself a replayable stream. Any authorized consumer can replay history from a sequence number or a timestamp. NATS has no one-line DLQ config; it has something more composable, and this post builds it in about thirty lines of Go.
One answer up front, since it is the question most people arrive with: NATS does not ship a single-config dead-letter queue. It ships the pieces you assemble one from, and what you end up with is a first-class, replayable stream rather than an opaque holding pen. The rest of this post is the assembly.
Every reliability discussion eventually reduces to the same handful of ways delivery goes wrong. Here is each one and the primitive that answers it.
| Failure scenario | NATS / JetStream answer |
|---|---|
| Consumer offline at publish time | Durable streams + durable consumers; delivery resumes from last acknowledged message |
| Consumer cannot process a message | Three consumer verbs: ack (done), nak (redeliver, optionally with delay or a backoff schedule), term (stop trying) |
| Downstream system out for hours | Bounded redelivery (AckWait, MaxDeliver, backoff); consumer resumes from its cursor when the system returns |
| Poison message loops forever | MaxDeliver bound → max-deliveries advisory → DLQ pattern (advisories captured into a replayable stream) |
| Rebuild state from history | Consumer start position by sequence or timestamp; ReplayPolicy instant or original-timing; any authorized consumer can rewind |
| Command needs confirmed delivery | Publish acknowledgment confirms persistence; request/reply confirms execution |
The rest of this post implements that frame. Code is Go, using the jetstream package; the same primitives exist in the other NATS client libraries.
Before the mechanics, here is the map. A message that fails travels a defined path, and every arrow in it is one of the primitives above.
1flowchart LR2 P[Publisher] -->|publish| S[(Stream)]3 S -->|PubAck: persisted| P4 S -->|deliver| C[Consumer]5 C -->|ack| S6 C -->|nak / delay / backoff| S7 C -->|term| S8 S -->|MaxDeliver exceeded| A[[MAX_DELIVERIES advisory]]9 A -->|captured by| AS[(Advisory stream)]10 AS --> H[DLQ handler]11 H -->|direct get by seq| S12 H -->|republish| D[(DLQ stream)]13 D -->|replay / repair| PA message is persisted, delivered, and acknowledged. When the acknowledgment never comes, it is redelivered, then bounded, then routed to a durable place you can inspect and replay. Nothing here is magic; each step is a configuration value or a method call.
There are two acknowledgments in JetStream, and conflating them is the most common source of confusion: the publish acknowledgment protects the write, and the consumer acknowledgment protects the read.
The publish acknowledgment (PubAck) is the server’s confirmation that your event has been persisted to the stream according to that stream’s storage and replication configuration. On a replicated stream, the PubAck means the message is committed across the configured replicas, not merely accepted by one node. If you do not receive a PubAck, you do not know the write landed, so you retry.
Retrying safely is where deduplication comes in. Set the Nats-Msg-Id header on publish, and the stream rejects a duplicate with the same ID inside its deduplication window (configurable per stream, a couple of minutes by default). The PubAck for a duplicate comes back flagged as such, so a retry after a dropped ack is idempotent at the stream boundary.
1ack, err := js.Publish(ctx, "orders.created", data,2 jetstream.WithMsgID("order-8f2c-created"))3if err != nil {4 // No PubAck: safe to retry, dedup window protects against a double-write.5 return err6}7if ack.Duplicate {8 // This exact message ID was already persisted within the window.9}The precise name for this is exactly-once publish within the deduplication window. It does not make end-to-end processing exactly-once; no distributed system can promise that. What you build on top is effectively-once processing: at-least-once delivery combined with idempotent consumers. Hold that thought for the exactly-once question in the FAQ.
The consumer acknowledgment is the read-side counterpart. With an explicit acknowledgment policy (AckExplicitPolicy), the consumer must positively acknowledge each message, and until it does, JetStream considers that message in-flight and owes it redelivery. The consumer’s durable cursor advances only over acknowledged messages, which is exactly why a subscriber that was offline at publish time resumes from its last acknowledged position rather than losing anything.
Two settings govern the in-flight window:
AckWait: how long the server waits for an acknowledgment before it assumes the delivery failed and redelivers.MaxAckPending: the ceiling on unacknowledged messages a consumer may hold at once. This is your backpressure knob; it bounds how much work a slow or wedged consumer can pull without confirming.Explicit acknowledgment gives the consumer three ways to respond to a message. This is the whole vocabulary of read-side reliability.
| Verb | When to use it | What the server does |
|---|---|---|
Ack | Processing succeeded | Marks the message acknowledged; the cursor advances past it |
Nak (or NakWithDelay) | Transient failure; retry is worth attempting | Schedules redelivery, immediately or after the delay you specify |
Term | Permanent failure; retrying will never help | Stops redelivery of this message entirely and advances past it |
The distinction between Nak and Term is judgment, and it belongs in your handler. A timeout talking to a database is a Nak: try again shortly. A message that fails schema validation is a Term, because no number of retries fixes malformed input.
1msg, err := consumer.Next()2// ...3switch result := process(msg); {4case result == nil:5 msg.Ack()6case errors.Is(result, errTransient):7 msg.NakWithDelay(5 * time.Second)8case errors.Is(result, errPermanent):9 msg.Term()10}A fixed AckWait gives you uniform redelivery timing, but transient outages rarely want uniform timing. You want to back off as failures persist. JetStream supports this at the consumer level with BackOff, a slice of durations on the consumer configuration. The first redelivery waits BackOff[0], the second waits BackOff[1], and so on; once the schedule is exhausted, the last interval repeats until the delivery bound is reached.
1cfg := jetstream.ConsumerConfig{2 Durable: "order-processor",3 AckPolicy: jetstream.AckExplicitPolicy,4 AckWait: 30 * time.Second,5 MaxAckPending: 1000,6 MaxDeliver: 5,7 BackOff: []time.Duration{8 1 * time.Second,9 5 * time.Second,10 30 * time.Second,11 2 * time.Minute,12 },13}When you set BackOff, size MaxDeliver deliberately against the length of the schedule. A MaxDeliver of 5 with a four-entry backoff gives four spaced retries after the initial delivery; the fifth attempt is the last before the message is bounded out. This is the answer to “downstream system out for hours”: the consumer keeps its cursor, spaces its retries, and picks up where it left off when the dependency returns, without losing a message and without hammering a recovering service.
Some messages never succeed no matter how patiently you retry. That is the classic poison message, and without a bound it redelivers forever, starving the consumer of forward progress. MaxDeliver is the bound: after that many delivery attempts, JetStream stops redelivering the message on its own.
But stopping is not enough. A message that silently falls off the end of its redelivery budget is a message you have lost track of. That is where advisories come in. When a message exceeds MaxDeliver, the server publishes an advisory:
1$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<stream>.<consumer>Related advisories fire on explicit termination ($JS.EVENT.ADVISORY.CONSUMER.MSG_TERMINATED.<stream>.<consumer>) and on negative acknowledgment ($JS.EVENT.ADVISORY.CONSUMER.MSG_NAKED). One thing to internalize: advisories are transient messages. If nobody is subscribed when one fires, it is gone. So you do the obvious thing and persist them into a stream.
No. NATS has no single-config dead-letter queue. There is no dead_letter: true field on a consumer. What NATS has instead is the set of primitives you assemble one from, and the DLQ you build is an ordinary JetStream stream with all the retention, filtering, and replay that implies, which beats a special-case holding pen.
The pattern is three parts:
First the two streams, a few lines of configuration:
1// Index stream: persists max-deliveries advisories.2js.CreateStream(ctx, jetstream.StreamConfig{3 Name: "DLQ_ADVISORIES",4 Subjects: []string{"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>"},5})6
7// Dead-letter stream: the durable home for failed payloads.8js.CreateStream(ctx, jetstream.StreamConfig{9 Name: "DLQ",10 Subjects: []string{"dlq.>"},11})Now the handler that ties them together:
1type maxDeliverAdvisory struct {2 Stream string `json:"stream"`3 Consumer string `json:"consumer"`4 StreamSeq uint64 `json:"stream_seq"`5}6
7// advisories is a durable consumer on the DLQ_ADVISORIES stream.8func runDLQ(ctx context.Context, js jetstream.JetStream, advisories jetstream.Consumer) error {9 _, err := advisories.Consume(func(m jetstream.Msg) {10 var a maxDeliverAdvisory11 if err := json.Unmarshal(m.Data(), &a); err != nil {12 m.Term() // malformed advisory: never retry13 return14 }15 src, err := js.Stream(ctx, a.Stream)16 if err != nil {17 m.Nak()18 return19 }20 orig, err := src.GetMsg(ctx, a.StreamSeq) // direct get by sequence21 if err != nil {22 m.Nak()23 return24 }25 dead := nats.NewMsg(fmt.Sprintf("dlq.%s.%s", a.Stream, a.Consumer))26 dead.Data = orig.Data27 dead.Header = orig.Header28 dead.Header.Set("Nats-Dlq-Origin-Seq", strconv.FormatUint(a.StreamSeq, 10))29 if _, err := js.PublishMsg(ctx, dead); err != nil {30 m.Nak()31 return32 }33 m.Ack()34 })35 return err36}That is the whole DLQ. The handler is itself a well-behaved JetStream consumer: if it cannot reach the source stream, it Naks and tries the advisory again later; if the advisory is malformed, it Terms. Failed messages land on dlq.<stream>.<consumer>, subject-partitioned so you can inspect or replay a single failing consumer’s backlog without touching the rest.
And because the DLQ is a stream, working with it means reading another consumer, not opening a ticket and grepping logs. Read the dead-letter stream, fix the downstream defect, and replay the affected messages back onto their original subject. NATS traded a one-line config field for a dead-letter mechanism that is first-class infrastructure.
The DLQ pattern touches the source stream by sequence, so how that stream retains messages matters.
MaxDeliver is what triggers the advisory in the first place, treat the capture handler as time-sensitive and keep it running close to the source, rather than assuming the payload lingers indefinitely.The general rule: on any retention policy that removes messages once they are terminally handled, capture the payload while it still exists. Copying the original into the DLQ stream at capture time, as the handler above does, is what makes the dead-lettered data durable regardless of what the source stream does next.
Replay is the same read primitive, aimed backward in time. A consumer’s DeliverPolicy sets where it starts:
ReplayPolicy then controls pacing: instant delivers as fast as the consumer can take it; original reproduces the timing of the original stream, spacing redelivery to match the intervals at which messages first arrived, which is useful for realistic load reproduction.
1js.CreateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{2 DeliverPolicy: jetstream.DeliverByStartTimePolicy,3 OptStartTime: &startOfLastWeek,4 ReplayPolicy: jetstream.ReplayInstantPolicy,5})The concrete payoff: to rebuild a read model or projection, you do not coordinate with the producing systems and you do not ask anyone to re-emit events. You create a fresh consumer, point it at the sequence or timestamp you want, replay the history JetStream already holds, and let it catch up to live. Any authorized consumer can rewind; the producers never know it happened.
How do acknowledgments work in NATS JetStream? There are two. A publish acknowledgment (PubAck) confirms the server persisted your message to the stream per its storage and replication configuration. A consumer acknowledgment confirms a subscriber finished processing a delivered message; under an explicit ack policy the consumer must ack each message, and the durable cursor advances only over acknowledged messages.
How do I retry a failed message with a backoff? Negatively acknowledge it. Nak schedules an immediate redelivery, NakWithDelay waits a fixed interval, and the consumer’s BackOff schedule applies an escalating series of delays across successive redeliveries. Size MaxDeliver against the backoff schedule so the retries you want all get a chance to run.
Does NATS have a dead-letter queue? Not as a single configuration flag. You build one by bounding redelivery with MaxDeliver, capturing the resulting max-deliveries advisories into their own stream, and running a small handler that fetches each failed message by sequence and republishes it to a DLQ subject captured by a dedicated stream. The result is a durable, filterable, replayable dead-letter stream.
How do I stop a poison message from redelivering forever? Set MaxDeliver on the consumer to bound the number of delivery attempts, or call Term in your handler when you detect a permanent failure. Both stop redelivery; MaxDeliver also emits an advisory you can capture for a DLQ.
How do I replay past messages in JetStream? Create a consumer with a DeliverPolicy of all, by start sequence, or by start time, and choose a ReplayPolicy of instant or original timing. The consumer reads the retained history without involving the original producers.
Is JetStream exactly-once? JetStream provides exactly-once publish within the deduplication window via the Nats-Msg-Id header, and at-least-once delivery to consumers. End-to-end exactly-once processing is achieved the same way it is in any honest distributed system: build idempotent consumers on top of at-least-once delivery to get effectively-once results. Avoid treating unqualified “exactly-once” as a delivery guarantee.
Two companion posts in this pack extend the picture: an architecture piece on whether industrial IoT deployments really need separate protocol brokers, and a concept piece arguing that message reliability belongs in the infrastructure rather than in application code. This post is the implementation those two point to.
Delivery guarantees in JetStream come down to a publish acknowledgment, durable cursors, three consumer verbs, bounded redelivery, advisories, and replay. Build the DLQ once from advisories and a direct get, and the absence of a one-line config reads less like a gap and more like a design decision.
Want help from the NATS experts? Meet with our architects to get help tailored to your use case and environment.



News and content from across the community