Event-Driven Architecture with NATS · Part 2

NATS JetStream: When Events Need to Survive the Moment

JetStream adds durable streams and consumer delivery state so publishers and subscribers can be temporally decoupled — capture, replay, and redeliver until acknowledged.

9 min read

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.
  • MaxDeliver is 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

DimensionCore NATSJetStream
DeliveryAt-most-once, best-effortAt-least-once by default; exactly-once opt-in
StorageIn-memory, no diskFile or memory, configurable retention
Offline subscribersMessage lostReplay from stream on reconnect
Temporal couplingPublisher and subscriber must overlapDecoupled by design
ReplayNot supportedDeliver 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
SettingWhat it controls
subjectsWhich published messages are captured
retentionlimits (default, replay), workqueue (delete on ack), interest (retain while consumers have interest)
storagefile (default) or memory
replicas1–5 for clustering and HA
max_age / max_msgs / max_bytesRetention limits
discardold (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

TypeBehaviorUse when
DurableState persisted; survives restarts; shared cursor by nameProduction workers, projectors
EphemeralNo persisted state; auto-cleaned after inactivityDebug, one-off replay, temporary dashboards

Always set a durable_name for production consumers.

Push vs pull

TypeBehaviorRecommendation
PullClient requests batches on demandDefault for new projects — better flow control, scalability, error handling
PushServer delivers to a DeliverSubjectLegacy 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

KnobWhat it controlsTuning hint
AckWaitRedelivery timeout after deliveryRaise for slow external APIs (e.g. OpenSearch bulk)
MaxDeliverMax redelivery attempts per messageSet finite + alert; plan manual replay for poison messages
MaxAckPendingIn-flight unacked capLower for high-latency workers; primary push flow control
DeliverPolicyWhere replay startsDeliverNew for live; DeliverAll for backfill
BackOffEscalating redelivery delaysOverrides 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:

CaveatDetail
Default deliveryAt-least-once — design idempotent consumers
Exactly-onceOpt-in via publisher message IDs and deduplication — still plan for rare duplicates
Disk fsyncDefault sync_interval is 2 minutes — ack does not always mean immediately on disk in all failure modes
MaxDeliver exhaustedMessage stays in stream; no automatic DLQ routing
Stale acksAcking a message after it was redelivered may be ignored — handle errors before ack

Choosing Core NATS vs JetStream

NeedCore NATSJetStream streamWork-queue stream
Live fan-out, all subscribers onlineoptional
Subscriber was offline✓ replay
Guaranteed processing with retries✓ ack + redeliver
Each message processed exactly onceidempotent consumers✓ (with caveats)
Shared job queuequeue groupsworkqueue retention

Common Mistakes

  1. Treating ack as “job done on disk.” Understand replication and sync_interval for your RPO requirements.
  2. Assuming MaxDeliver = DLQ. Plan explicit poison-message handling.
  3. Using push consumers by default. Pull consumers give better flow control for new services.
  4. One consumer for everything. Use FilterSubject to give each projector its own cursor and retry policy.
  5. Skipping idempotency. At-least-once means duplicates are normal, not exceptional.

Next in series: JetStream KV Is a Change Stream, Not a Table

Sources