Core NATS is excellent for live fan-out, but it cannot guarantee delivery if a subscriber is offline. JetStream adds durable streams (message stores) and consumers (delivery state machines) so producers and consumers can be temporally decoupled: messages are captured, replayed on demand, and redelivered until acknowledged.
This article teaches engineers how streams and consumers work in practice, with honest durability caveats for production decisions.
Prerequisite: Core NATS (Part 1). Researched against docs.nats.io on July 6, 2026.
TL;DR
- JetStream adds at-least-once delivery (exactly-once is opt-in) on top of Core NATS transport.
- A stream is a durable message store bound to one or more subjects; messages published on matching subjects are captured.
- A consumer is a stateful view of a stream — it tracks which messages were delivered and acknowledged.
- Durable pull consumers are the recommended default for new projects: explicit ack, flow control, scalable batch fetching.
MaxDeliveris not a dead-letter queue — after max attempts, NATS stops redelivering but the message remains in the stream.- Plan for idempotent consumers regardless of ack policy.
What You Will Learn Here
- What JetStream adds over Core NATS
- How streams work: subject binding, retention policies, limits
- How consumers work: durable vs ephemeral, push vs pull, filters, ack tuning
- A consumer configuration cheat sheet (
AckWait,MaxDeliver,MaxAckPending) - An end-to-end outbox → JetStream → projector example
- How KV buckets relate to streams (
KV_app-objects) - Operational durability caveats for production
Core NATS vs JetStream
| Dimension | Core NATS | JetStream |
|---|---|---|
| Delivery | At-most-once, best-effort | At-least-once by default; exactly-once opt-in |
| Storage | In-memory, no disk | File or memory, configurable retention |
| Offline subscribers | Message lost | Replay from stream on reconnect |
| Temporal coupling | Publisher and subscriber must overlap | Decoupled by design |
| Replay | Not supported | Deliver from sequence, time, or last position |
Core NATS JetStream
───────── ─────────
Publisher ──► Subscriber Publisher ──► Stream ──► Consumer ──► Worker
(must be online) (persisted) (tracks cursor)
JetStream is built into nats-server. Enable it in server config and use JetStream publish APIs — the server acknowledges successful storage.
Streams: The Durable Ledger
A stream is a message store that binds to one or more subjects (wildcards supported). Messages published on matching subjects are captured according to retention and limit policies.
Creating a stream
nats stream add ORDERS \
--subjects "orders.>" \
--retention limits \
--storage file \
--replicas 3 \
--max-age 7d \
--discard old
| Setting | What it controls |
|---|---|
subjects | Which published messages are captured |
retention | limits (default, replay), workqueue (delete on ack), interest (retain while consumers have interest) |
storage | file (default) or memory |
replicas | 1–5 for clustering and HA |
max_age / max_msgs / max_bytes | Retention limits |
discard | old (default) or new when limits hit |
Prefer JetStream publish APIs over plain Core NATS publish — the server returns an acknowledgment that the message was stored.
KV buckets are streams
JetStream KV buckets are implemented as streams named KV_<bucket>. A bucket app-objects materializes as stream KV_app-objects:
nats kv add app-objects --history 5 --ttl 1h
nats stream info KV_app-objects # subjects: $KV.app-objects.>
This matters because KV inherits stream limits, replication, and storage behavior. Part 3 explains the KV abstraction; here, remember that a bucket is a stream with a latest-value view.
Consumers: Delivery State Machines
A consumer is a stateful view of a stream. It tracks which messages were delivered and acknowledged. Unacknowledged messages are redelivered after AckWait expires.
Durable vs ephemeral
| Type | Behavior | Use when |
|---|---|---|
| Durable | State persisted; survives restarts; shared cursor by name | Production workers, projectors |
| Ephemeral | No persisted state; auto-cleaned after inactivity | Debug, one-off replay, temporary dashboards |
Always set a durable_name for production consumers.
Push vs pull
| Type | Behavior | Recommendation |
|---|---|---|
| Pull | Client requests batches on demand | Default for new projects — better flow control, scalability, error handling |
| Push | Server delivers to a DeliverSubject | Legacy integrations; MaxAckPending is the only flow-control mechanism |
FilterSubjects
Consumers can filter which stream messages they receive:
{
"durable_name": "search-projector",
"filter_subject": "orders.created",
"ack_policy": "explicit",
"ack_wait": 30000000000,
"max_deliver": 5,
"max_ack_pending": 100,
"deliver_policy": "all"
}
Stream captures orders.> but this consumer only sees orders.created. One consumer per downstream concern keeps projectors independent.
Ack tuning cheat sheet
| Knob | What it controls | Tuning hint |
|---|---|---|
AckWait | Redelivery timeout after delivery | Raise for slow external APIs (e.g. OpenSearch bulk) |
MaxDeliver | Max redelivery attempts per message | Set finite + alert; plan manual replay for poison messages |
MaxAckPending | In-flight unacked cap | Lower for high-latency workers; primary push flow control |
DeliverPolicy | Where replay starts | DeliverNew for live; DeliverAll for backfill |
BackOff | Escalating redelivery delays | Overrides AckWait when configured |
Operational gotcha: After MaxDeliver attempts, NATS stops redelivering but the message remains in the stream. There is no automatic dead-letter queue. Your ops process or application must move, delete, or alert on these messages.
PUBLISH PATH
------------
Producer App
|
| JetStream publish (orders.created)
v
+------------------+ +---------------------------+
| NATS Server | -------------------------> | Stream: ORDERS |
| (JetStream) | captures orders.> | retention: limits |
+------------------+ | storage: file, R=3 |
^ | [msg1][msg2][msg3]... |
| publish ack +---------------------------+
| |
| | N consumer views
| +---------------------+---------------------+
| | | |
| +---------v---------+ +---------v---------+
| | Consumer (durable) | | Consumer (durable) |
| | pull, filter: | | pull, filter: |
| | orders.created | | orders.shipped |
| | MaxAckPending: 100 | | MaxDeliver: 5 |
| +---------+---------+ +---------+---------+
| | |
| v v
| Search Projector Shipping Worker
|
+--- if no ack within AckWait --> redeliver (up to MaxDeliver)
Concrete Implementation: Outbox → JetStream → Projector
This extends the read-model pipeline from The Pieces of Modern, Effective Software Design with JetStream-specific publish and consume semantics.
Write path — store business change and outbox row in one transaction:
BEGIN;
INSERT INTO todos (id, user_id, title, completed)
VALUES (:id, :user_id, :title, false);
INSERT INTO outbox_events (id, topic, payload, created_at)
VALUES (:event_id, 'todo.created', :payload, now());
COMMIT;
Publisher — read outbox, publish to JetStream, mark published:
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const sc = StringCodec();
async function publishOutboxBatch(events: OutboxEvent[]) {
for (const event of events) {
const ack = await js.publish(event.topic, sc.encode(JSON.stringify({
id: event.id,
occurredAt: new Date().toISOString(),
data: event.payload,
})));
// Server confirmed storage in stream
console.log(`stored seq=${ack.seq} stream=${ack.stream}`);
await markPublished(event.id);
}
}
Projector — durable pull consumer with explicit ack:
const sub = await js.pullSubscribe("todo.created", {
config: { durable_name: "search-projector" },
stream: "TODOS",
});
for (;;) {
const msgs = await sub.fetch(10, { expires: 5000 });
for (const msg of msgs) {
const event = JSON.parse(sc.decode(msg.data));
if (await alreadyProcessed(event.id)) {
msg.ack();
continue;
}
try {
await openSearch.index({
index: "todos",
id: event.data.todo_id,
document: {
title: event.data.title,
owner_id: event.data.user_id,
completed: false,
},
});
await markProcessed(event.id);
msg.ack();
} catch (err) {
msg.nak(); // redeliver after AckWait
}
}
}
The shape is stable across stacks:
- Write once to the source of truth
- Publish changes reliably through JetStream
- Update read models in the background
- Make projectors idempotent (dedupe by
event.id) - Measure lag between write and read visibility
Durability Caveats (Read This Before Production)
JetStream is durable, not magic. Frame durability claims for ops reviews and incident postmortems:
| Caveat | Detail |
|---|---|
| Default delivery | At-least-once — design idempotent consumers |
| Exactly-once | Opt-in via publisher message IDs and deduplication — still plan for rare duplicates |
| Disk fsync | Default sync_interval is 2 minutes — ack does not always mean immediately on disk in all failure modes |
MaxDeliver exhausted | Message stays in stream; no automatic DLQ routing |
| Stale acks | Acking a message after it was redelivered may be ignored — handle errors before ack |
Choosing Core NATS vs JetStream
| Need | Core NATS | JetStream stream | Work-queue stream |
|---|---|---|---|
| Live fan-out, all subscribers online | ✓ | optional | — |
| Subscriber was offline | ✗ | ✓ replay | ✓ |
| Guaranteed processing with retries | ✗ | ✓ ack + redeliver | ✓ |
| Each message processed exactly once | ✗ | idempotent consumers | ✓ (with caveats) |
| Shared job queue | queue groups | — | ✓ workqueue retention |
Common Mistakes
- Treating ack as “job done on disk.” Understand replication and
sync_intervalfor your RPO requirements. - Assuming
MaxDeliver= DLQ. Plan explicit poison-message handling. - Using push consumers by default. Pull consumers give better flow control for new services.
- One consumer for everything. Use
FilterSubjectto give each projector its own cursor and retry policy. - Skipping idempotency. At-least-once means duplicates are normal, not exceptional.
Next in series: JetStream KV Is a Change Stream, Not a Table
Sources
- JetStream — persistence, replay, exactly-once semantics
- Core NATS — at-most-once baseline
- Streams — retention, limits, storage
- Consumers — durable/ephemeral, push/pull, ack configuration
- Key/Value Store — KV buckets as streams
- The Pieces of Modern, Effective Software Design — outbox → projector pipeline