← All posts
·16 min read

Claude Code with NATS: JetStream Without the Foot-Guns

Claude CodeNATSJetStreamMessaging
Claude Code with NATS: JetStream without the foot-guns

Why NATS without CLAUDE.md ships code that confuses core NATS with JetStream

NATS has two delivery models that look almost identical in the client API and behave very differently in production. Core NATS is fire-and-forget pub/sub: messages are delivered to subscribers that happen to be connected at the moment of publish, and that is the entire delivery guarantee. JetStream is the persistent layer on top of core NATS: messages are stored on disk, consumers are durable, redelivery is automatic, and the system gives at-least-once semantics with optional exactly-once via deduplication windows.

Claude Code, asked to "wire up NATS," does not reliably pick the right one. The default examples in the docs and tutorials lean on core NATS, which is fine for a chat demo and a disaster for an order-processing pipeline. The first time the consumer process restarts during a publish burst, every message in flight is gone. The first time a network blip drops the consumer connection, every message published during the blip is lost forever. None of these are NATS bugs. They are the consequences of Claude generating core NATS code where JetStream was the right choice.

This guide covers the CLAUDE.md configuration that locks Claude Code into the right NATS patterns: use core NATS for ephemeral pub/sub and request/reply only, use JetStream for any work that must survive a restart, declare durable named consumers, set ack policies and ack wait timeouts explicitly, cap max deliveries with a dead letter strategy, and design the subject hierarchy as a versioned contract. For the alternative streaming model where ordered partitions per key are the right shape, Claude Code with Kafka covers Kafka. For the alternative message-broker model with rich routing, Claude Code with RabbitMQ covers AMQP.

The NATS CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a NATS integration it needs to declare: the client library, the connection topology, the rule for choosing core NATS versus JetStream, the stream and consumer definitions, the ack policy, the subject naming convention, the deduplication strategy, and the hard rules that block the message-loss patterns Claude generates by default.

# NATS rules

## Stack
- nats.js ^2.x (Node.js client, supports both core NATS and JetStream)
- nats-server 2.10+ with JetStream enabled
- NATS_URL env var, comma-separated for cluster URLs
- One credential file per service via JWT/nkey (no plaintext user/pass in production)

## Project structure
- src/nats/connection.ts        , singleton NATS connection (shared across producers/consumers)
- src/nats/jetstream.ts         , singleton JetStream context + manager
- src/nats/streams.ts           , stream + consumer definitions, idempotent setup function
- src/nats/subjects.ts          , subject hierarchy constants, versioned
- src/publishers/               , one file per publisher (core pub or JetStream publish)
- src/consumers/                , one file per consumer (push or pull based)
- src/handlers/                 , pure functions called by consumers (testable)
- tests/                        , unit + integration tests with a real nats-server

## Connection pattern (MANDATORY)
- ONE NATS connection per process, shared across all producers/consumers
- ONE JetStream context derived from that connection
- maxReconnectAttempts: -1 (retry forever)
- reconnectTimeWait: 1000 (1s between retries)
- name: clientName set to '<service>-<env>-<pid>' for server-side debugging

## Choosing core NATS versus JetStream (MANDATORY decision)
- Core NATS: ephemeral pub/sub, request/reply, live metrics, broadcast messages where loss is acceptable
- JetStream: any work that MUST be processed even if the consumer was offline at publish time
- Default: JetStream. Use core NATS only after explicit justification in the CLAUDE.md change.

## Subject hierarchy (MANDATORY)
- Subjects are versioned: `<domain>.<entity>.<event>.v<N>` (e.g., `orders.order.placed.v1`)
- Wildcards used only in consumer subscriptions, never in publishes
- Reserved leading tokens: `_sys.` (server), `$JS.` (JetStream API)
- NEVER use single-word subjects in production (`event`, `update`)

## Stream definitions (MANDATORY for every JetStream stream)
- Defined in src/nats/streams.ts, applied via JSM (JetStream Manager) on boot
- Storage: file (default) or memory (only for genuinely ephemeral streams)
- Retention: limits (size/age) or interest or workqueue, documented per stream
- Max age: explicit (e.g., 7 days), NEVER unbounded in production
- Max bytes: explicit budget, NEVER unbounded
- Replicas: 3 in production (single-node loses data on disk failure)
- Discard policy: old (default) or new (for back-pressure)

## Consumer definitions (MANDATORY for every JetStream consumer)
- Durable: named, NOT ephemeral, in production
- Ack policy: explicit (default), NEVER 'none' for at-least-once work
- Ack wait: 30s default, longer for long handlers (worker process timeout safety)
- Max deliver: 5 default, capped (NEVER infinite, prevents poison loops)
- Filter subject: scope the consumer to a subset of the stream
- Replay policy: instant (default) for new consumers, original for time-shifted replay

## Idempotency (MANDATORY)
- JetStream publishes use Nats-Msg-Id header with a stable business key (per-stream dedupe window)
- Consumer handlers are idempotent at the business-key level (DB unique constraint or Redis SET NX)

## Hard rules
- NEVER use core NATS for work that must not be lost on consumer restart
- NEVER create an ephemeral consumer for durable work in production
- NEVER set max deliver to infinite or omit it (poison loop)
- NEVER omit Nats-Msg-Id on a JetStream publish for idempotent producers
- NEVER use a single connection for too many subjects without queue groups for load balancing
- NEVER skip the JetStream stream definition file (streams drift when configured ad-hoc)
- ALWAYS log subject + stream + consumer + sequence on every consumer event

Six rules here prevent the majority of production incidents Claude generates without them.

The core-vs-JetStream rule is the single most important decision in any NATS project. Core NATS is fire-and-forget: a publish without an active subscriber on the subject at the moment of publish is gone forever. There is no buffer, no retry, no persistence. JetStream wraps the same subject-based API with a persistent stream behind it: the publish goes through the stream first, durable consumers replay messages they missed during downtime, redelivery happens automatically when a handler does not ack within the timeout. The right default for any non-ephemeral work is JetStream. Claude defaults to core NATS because that is what most tutorials show.

The durable consumer rule is the consumer-side counterpart. An ephemeral consumer (created without a durable name) only exists while the subscriber is connected. When the subscriber disconnects, the consumer is destroyed, and the next time it reconnects it starts from the current head of the stream, missing every message published during the gap. A durable consumer (durable_name: 'charge-processor') persists across reconnects; the next subscriber resumes from the last acked sequence number.

The max deliver rule prevents the poison-message infinite loop. A consumer that nacks (or fails to ack) a message gets it redelivered after ack_wait. If the same message keeps failing, the consumer keeps getting it back. Without max_deliver, this loops forever. With max_deliver: 5, the message is dead-lettered (or dropped, depending on stream settings) after the fifth attempt, and the partition keeps moving.

The idempotency rule is the at-least-once safety net. JetStream gives at-least-once delivery: a message can be delivered more than once if the ack is lost, if the consumer crashes after handling but before acking, if the stream replays. The handler must be idempotent. The Nats-Msg-Id header on the publisher side gives JetStream a dedupe window (default 2 minutes, configurable per stream): a republish with the same Nats-Msg-Id within the window is rejected as duplicate. The consumer-side idempotency check (a unique constraint or Redis SET NX) catches duplicates that fall outside the window.

The subject hierarchy rule is the operational discipline that keeps NATS deployments manageable. NATS subjects are flat globally per cluster. Without a convention, every service invents its own naming, and after a year nobody knows what update, event, or msg refer to. The versioned <domain>.<entity>.<event>.v<N> convention is the same shape Kafka topic naming uses, and it pays the same dividends: schema evolution via new subject versions, clear ownership, and consumer subscriptions that match a known pattern.

Install and connection setup

Install the NATS client:

pnpm add nats

Set the environment:

# .env.local
NATS_URL=nats://localhost:4222
NATS_NAME=billing-local

Create the singleton connection:

// src/nats/connection.ts
import { connect, NatsConnection } from 'nats';

if (!process.env.NATS_URL) {
  throw new Error('NATS_URL must be set');
}

const connectionPromise: Promise<NatsConnection> = connect({
  servers: process.env.NATS_URL.split(','),
  name: `${process.env.NATS_NAME ?? 'unknown'}-${process.pid}`,
  maxReconnectAttempts: -1,
  reconnectTimeWait: 1000,
  pingInterval: 20000,
});

export async function getConnection(): Promise<NatsConnection> {
  return connectionPromise;
}

The connection is established once at module load and reused for every publish and subscribe. maxReconnectAttempts: -1 retries forever, which is the right behaviour for long-lived services. The name shows up in the nats-server connection list and is the first thing to look at when debugging which application is causing trouble.

Create the JetStream context:

// src/nats/jetstream.ts
import { JetStreamClient, JetStreamManager } from 'nats';
import { getConnection } from './connection';

let cachedClient: JetStreamClient | null = null;
let cachedManager: JetStreamManager | null = null;

export async function getJetStream(): Promise<JetStreamClient> {
  if (cachedClient) return cachedClient;
  const nc = await getConnection();
  cachedClient = nc.jetstream();
  return cachedClient;
}

export async function getJetStreamManager(): Promise<JetStreamManager> {
  if (cachedManager) return cachedManager;
  const nc = await getConnection();
  cachedManager = await nc.jetstreamManager();
  return cachedManager;
}

The JetStreamClient is for publishing and consuming. The JetStreamManager is for administrative operations: creating streams, creating consumers, listing, deleting. Both are cached per process so application code does not pay the construction cost on every call.

Defining streams and consumers

Streams and consumers are the persistent JetStream state. Define them in src/nats/streams.ts as a single setup function that is idempotent and run on every service boot.

// src/nats/streams.ts
import { RetentionPolicy, StorageType, DiscardPolicy, AckPolicy, ReplayPolicy } from 'nats';
import { getJetStreamManager } from './jetstream';

export const STREAM_ORDERS = 'ORDERS';
export const SUBJECT_ORDER_PLACED = 'orders.order.placed.v1';

export const CONSUMER_CHARGE_PROCESSOR = 'charge-processor';
export const CONSUMER_INVENTORY_RESERVATION = 'inventory-reservation';

export async function declareStreams() {
  const jsm = await getJetStreamManager();

  const streamConfig = {
    name: STREAM_ORDERS,
    subjects: ['orders.order.*.v1', 'orders.order.*.v2'],
    retention: RetentionPolicy.Limits,
    storage: StorageType.File,
    max_age: 7 * 24 * 60 * 60 * 1_000_000_000,
    max_bytes: 50 * 1024 * 1024 * 1024,
    discard: DiscardPolicy.Old,
    num_replicas: 3,
    duplicate_window: 2 * 60 * 1_000_000_000,
  };

  try {
    await jsm.streams.add(streamConfig);
  } catch (err) {
    if ((err as Error).message.includes('stream name already in use')) {
      await jsm.streams.update(STREAM_ORDERS, streamConfig);
    } else {
      throw err;
    }
  }

  await ensureConsumer(STREAM_ORDERS, {
    durable_name: CONSUMER_CHARGE_PROCESSOR,
    filter_subject: SUBJECT_ORDER_PLACED,
    ack_policy: AckPolicy.Explicit,
    ack_wait: 30 * 1_000_000_000,
    max_deliver: 5,
    replay_policy: ReplayPolicy.Instant,
  });

  await ensureConsumer(STREAM_ORDERS, {
    durable_name: CONSUMER_INVENTORY_RESERVATION,
    filter_subject: SUBJECT_ORDER_PLACED,
    ack_policy: AckPolicy.Explicit,
    ack_wait: 30 * 1_000_000_000,
    max_deliver: 5,
    replay_policy: ReplayPolicy.Instant,
  });
}

async function ensureConsumer(stream: string, config: any) {
  const jsm = await getJetStreamManager();
  try {
    await jsm.consumers.add(stream, config);
  } catch (err) {
    if ((err as Error).message.includes('consumer name already in use')) {
      await jsm.consumers.update(stream, config.durable_name, config);
    } else {
      throw err;
    }
  }
}

Four design decisions visible here. The stream subscribes to a versioned wildcard (orders.order.*.v1) so new event types in the same domain land in the same stream. The retention is Limits with a 7-day age and 50 GB byte cap, so the stream never grows unbounded. The duplicate_window is 2 minutes, which is the window during which JetStream rejects duplicate publishes with the same Nats-Msg-Id. The replicas count of 3 makes the stream survive a single-node failure.

Each consumer has a durable name (matching the service that owns it), an explicit ack policy, an ack wait of 30 seconds, and a max deliver of 5. The ack policy means the consumer must call ack() after handling each message; the ack wait means a message that is not acked within 30 seconds is redelivered; the max deliver caps the redelivery count to prevent infinite loops on poison messages.

The setup function is called on every service boot. The try/catch around streams.add and consumers.add handles the case where the resource already exists, falling through to update to reconcile any drift from the declared config.

Publishing with idempotency

The publisher uses the JetStream client's publish method and sets the Nats-Msg-Id header to a stable business key.

// src/publishers/order-placed.ts
import { headers, JSONCodec } from 'nats';
import { getJetStream } from '../nats/jetstream';
import { SUBJECT_ORDER_PLACED } from '../nats/streams';

const codec = JSONCodec();

export type OrderPlacedEvent = {
  orderId: string;
  userId: string;
  amount: number;
  currency: string;
  placedAt: number;
};

export async function publishOrderPlaced(event: OrderPlacedEvent) {
  const js = await getJetStream();

  const h = headers();
  h.set('Nats-Msg-Id', event.orderId);
  h.set('event-type', 'order.placed.v1');

  const ack = await js.publish(SUBJECT_ORDER_PLACED, codec.encode(event), {
    headers: h,
    timeout: 5000,
  });

  return { stream: ack.stream, sequence: ack.seq };
}

Three CLAUDE.md rules visible here. The JetStream publish (not the core nc.publish) goes through the persistent stream and returns an ack object with the stream name and sequence number. The Nats-Msg-Id header set to event.orderId activates JetStream's dedupe window: a republish of the same order within the window is silently dropped, which is the right semantics for a retry of an upstream operation. The timeout: 5000 bounds the wait for the publish ack; if the stream is unavailable the publish fails fast instead of hanging.

For the producer-side dedupe window to work across longer windows, the dedupe is also enforced application-side by the unique constraint on the resulting database row, or by a Redis-backed idempotency layer. JetStream's dedupe is the cheap first line of defense for retries that happen within seconds; the application-level dedupe is the catch-all.

Consuming with pull-based subscriptions

JetStream has two consumer styles: push (the server delivers messages to the client as they arrive) and pull (the client requests batches of messages). Pull-based is the right default for backend workers because it gives the consumer explicit control over throughput and back-pressure.

// src/consumers/charge-processor.ts
import { JSONCodec, AckPolicy } from 'nats';
import { getJetStream, getJetStreamManager } from '../nats/jetstream';
import { STREAM_ORDERS, CONSUMER_CHARGE_PROCESSOR } from '../nats/streams';
import { processCharge } from '../handlers/process-charge';

const codec = JSONCodec();

export async function startChargeProcessor() {
  const js = await getJetStream();
  const jsm = await getJetStreamManager();

  const consumer = await js.consumers.get(STREAM_ORDERS, CONSUMER_CHARGE_PROCESSOR);

  const messages = await consumer.consume({
    max_messages: 100,
    expires: 30000,
  });

  (async () => {
    for await (const msg of messages) {
      const event = codec.decode(msg.data);

      try {
        await processCharge(event, {
          messageId: msg.headers?.get('Nats-Msg-Id') ?? `${msg.info.stream}:${msg.info.streamSequence}`,
          deliveryCount: msg.info.deliveryCount,
        });
        msg.ack();
      } catch (err) {
        if (isNonRetryable(err) || msg.info.deliveryCount >= 5) {
          await routeToDLQ(msg, err);
          msg.ack();
        } else {
          msg.nak(computeBackoff(msg.info.deliveryCount));
        }
      }
    }
  })();

  return consumer;
}

function computeBackoff(deliveryCount: number): number {
  return Math.min(60_000, 1000 * Math.pow(2, deliveryCount));
}

The consumer.consume({ max_messages, expires }) call returns an async iterable that yields messages as they arrive. The handler decodes the payload, calls the pure function, and either acks (success), routes to a dead-letter destination (poison or exhausted retries), or nacks with backoff (transient failure).

The msg.info.deliveryCount is the redelivery counter. Comparing it to the max_deliver value (5 in the consumer config) lets the application route to a DLQ on the final attempt, which is more graceful than letting the message expire silently when max_deliver is reached.

The msg.nak(backoff) form delays the next redelivery by the given number of milliseconds. The exponential backoff on deliveryCount (1s, 2s, 4s, 8s, 16s, capped at 60s) gives upstreams time to recover between retry attempts.

Dead letter strategy

JetStream does not have a built-in dead letter exchange concept. The standard pattern is to handle "exhausted retries" inside the consumer by publishing to a separate dead-letter stream:

// src/consumers/dlq-publisher.ts
import { headers, JSONCodec } from 'nats';
import { getJetStream } from '../nats/jetstream';

const codec = JSONCodec();
const DLQ_SUBJECT = 'orders.order.placed.dlq.v1';

export async function routeToDLQ(msg: any, err: unknown) {
  const js = await getJetStream();

  const h = headers();
  h.set('Nats-Msg-Id', `dlq:${msg.info.streamSequence}`);
  h.set('original-subject', msg.subject);
  h.set('failure-reason', (err as Error).message ?? 'unknown');
  h.set('delivery-count', String(msg.info.deliveryCount));

  await js.publish(DLQ_SUBJECT, msg.data, { headers: h, timeout: 5000 });
}

The DLQ subject lives in a separate stream (or the same stream with a separate consumer) where messages can be inspected, replayed, or archived. The headers carry the failure context for the operator. The original payload is preserved exactly so a manual replay re-encodes the same data.

A dedicated DLQ consumer routes these messages to an alerting channel and a replay UI. The DLQ stream's retention is typically longer (30 days) than the main stream, because failed messages need a longer investigation window.

Graceful shutdown

A consumer that exits without draining loses in-flight work to redelivery. NATS handles the redelivery automatically, but the latency is at least ack_wait and the redelivery counter increments, which can push messages closer to max_deliver.

// src/main.ts
import { getConnection } from './nats/connection';
import { declareStreams } from './nats/streams';
import { startChargeProcessor } from './consumers/charge-processor';

await declareStreams();
const chargeConsumer = await startChargeProcessor();

async function shutdown(signal: string) {
  console.log('shutdown_start', { signal });

  await chargeConsumer.delete();

  const nc = await getConnection();
  await nc.drain();

  console.log('shutdown_complete');
  process.exit(0);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

consumer.delete() on a durable consumer in shutdown looks wrong but is correct only if you genuinely want the consumer to disappear. For a service that will come back online, replace this with a pause or simply rely on the durable name to resume on next boot. nc.drain() flushes any pending publishes and closes the connection cleanly, which prevents in-flight publishes from being lost.

Common Claude Code mistakes with NATS

Six patterns Claude generates incorrectly without CLAUDE.md constraints.

1. Using core NATS for durable work

Claude generates: nc.publish('orders.placed', payload) and nc.subscribe('orders.placed', handler) with no JetStream.

Correct pattern: route the work through JetStream with a defined stream and durable consumer.

2. Ephemeral consumer for durable work

Claude generates: js.subscribe('orders.placed.v1', handler) without a durable_name.

Correct pattern: define a durable consumer in src/nats/streams.ts and consume it by name.

3. No ack_wait or max_deliver

Claude generates: consumer config with default ack_wait (30s) and no max_deliver.

Correct pattern: ack_wait set to the handler timeout, max_deliver: 5 (or appropriate cap) with explicit DLQ routing.

4. No Nats-Msg-Id on publishes

Claude generates: js.publish(subject, payload) with no headers.

Correct pattern: set Nats-Msg-Id to the business key for dedupe-window protection.

5. Single-word or unversioned subjects

Claude generates: nc.publish('event', payload) or js.publish('order_placed', payload).

Correct pattern: js.publish('orders.order.placed.v1', payload) with the documented hierarchy.

6. Stream config drift

Claude generates: streams.add once, never reconciled, configuration drifts between service and broker.

Correct pattern: idempotent setup function called on every boot, with try/update fallback.

Add each pattern to CLAUDE.md with the corrected form.

Permission hooks for NATS CLI

The nats CLI has destructive operations: stream delete, consumer delete, message purge. Permission hooks gate them.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(nats stream ls*)",
      "Bash(nats stream info*)",
      "Bash(nats consumer ls*)",
      "Bash(nats consumer info*)",
      "Bash(nats stream view*)",
      "Bash(nats sub*)"
    ],
    "deny": [
      "Bash(nats stream rm*)",
      "Bash(nats stream purge*)",
      "Bash(nats consumer rm*)",
      "Bash(nats account rm*)",
      "Bash(nats kv del*)"
    ]
  }
}

The allow list permits inspection. The deny list forces destructive operations into the confirmation path. Combined with the Claude Code hooks system for project-wide policy, the result is a setup where Claude can investigate stream lag but cannot delete the stream.

Observability

Every NATS deployment needs four sets of metrics: stream depth (messages, bytes, first/last sequence), consumer lag (num_pending, num_redelivered, num_ack_pending), connection counts per server, and JetStream resource usage (storage bytes, memory bytes per stream).

Stream depth tracks the producer-consumer balance. A stream whose num_pending per consumer is growing means the consumer is slower than the producer. Alert at num_pending > 10_000 or last_seq - ack_floor > X.

Consumer lag is the most actionable signal. num_redelivered growing means handlers are nacking; num_ack_pending stuck near max_messages means the consumer is hung. Alert on either.

Connection counts catch leaks. A healthy service has a stable connection count (one per process). A growing count means someone is creating connections inside a request handler.

JetStream resource metrics are the cluster-health signal. Storage approaching the configured max_bytes triggers the discard policy; alert before it hits. For the observability stack that surfaces these metrics, Claude Code with Datadog covers the integration.

Choosing NATS versus the alternatives

NATS is the right tool when you want lightweight, low-latency messaging with both ephemeral pub/sub (core NATS) and persistent streams (JetStream) in a single piece of infrastructure. The footprint is small, the operational model is simple, and the subject hierarchy gives flexible routing without the operational complexity of exchanges and bindings.

Use NATS when: you need both fire-and-forget and durable streams from the same broker, you want a single binary that runs at the edge, you have moderate to high throughput, and you want subject-based routing that scales globally.

Use Kafka when: you need strict per-key ordering across partitions, you need very high throughput, and you have a team comfortable with broker operations.

Use RabbitMQ when: you need rich AMQP routing (topic, fanout, headers exchanges), you have moderate throughput, and you want a mature broker with a familiar operational model.

Use BullMQ when: your workload is request/response style background jobs and you already run Redis.

The CLAUDE.md rules for each are different. Picking the right tool first is the rule that prevents the most rework.

Building NATS code that does not lose messages

The NATS CLAUDE.md in this guide produces code where the right delivery model is chosen up front (core for ephemeral, JetStream for durable), every JetStream stream has explicit retention and replicas, every consumer is durable with an explicit ack policy and a finite max deliver, every publish carries Nats-Msg-Id for dedupe-window protection, the subject hierarchy is versioned and documented, and dead-letter routing is the application's responsibility, not the broker's.

The underlying principle is the same as any messaging infrastructure with Claude Code. NATS without a CLAUDE.md produces code that works in a single-node demo and falls apart the first time a consumer crashes mid-handler, the broker restarts, or a publisher retries after a transient error. The CLAUDE.md is what makes the production-safe defaults the default for Claude.

For the streaming alternative with strict per-key ordering, Claude Code with Kafka covers Kafka. For rich AMQP routing, Claude Code with RabbitMQ covers RabbitMQ. For job-queue semantics on Redis, Claude Code with BullMQ covers BullMQ. For workflow orchestration where the messaging layer is too low-level, Claude Code with Temporal covers the workflow engine.

Get Claudify. Ship NATS publishers and consumers that pick the right delivery semantics.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame