---
title: 'NATS JetStream Worker in TypeScript: Retries & DLQ (2026)'
description: 'Build a durable NATS JetStream worker in TypeScript on Node 24: create a stream, a pull consumer with explicit acks, backoff retries, and a dead-letter queue.'
url: https://nerdleveltech.com/nats-jetstream-typescript-durable-worker-retries-dlq-tutorial
language: en
date: '2026-06-02'
category: backend
tags:
  - nats
  - jetstream
  - typescript
  - event-driven
  - message queue
  - node.js
  - backend
  - dead letter queue
markdown_url: https://nerdleveltech.com/nats-jetstream-typescript-durable-worker-retries-dlq-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/nats-jetstream-typescript-durable-worker-retries-dlq-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# NATS JetStream Worker in TypeScript: Retries & DLQ (2026)

A durable NATS JetStream worker in TypeScript needs three things the docs rarely show together: a *durable pull consumer* with explicit acknowledgement, a *retry policy* from `max_deliver`, `ack_wait`, and delayed `nak`s, and a *dead-letter queue* you build yourself because JetStream has none. This guide wires all three on Node 24.

## TL;DR

This hands-on guide builds a production-shaped event worker on NATS JetStream using the current modular `@nats-io/jetstream` 3.4 client[^jetstream-npm] — not the deprecated monolithic `nats` v2 package most tutorials still use. You will run a JetStream server in Docker, scaffold a TypeScript project that runs `.ts` files directly on Node 24 with no build step, create a stream and a durable pull consumer, publish events idempotently with a `msgID`, consume them with explicit `ack`/`nak`/`term`, retry failures with an increasing backoff delay, and route poison messages to a dead-letter queue. It uses Node.js 24 LTS, `@nats-io/jetstream` 3.4.0, `@nats-io/transport-node` 3.4.0, and `nats-server` 2.14.1[^nats-server]. Every file was type-checked against the published packages on 2 June 2026. Budget about 30–40 minutes.

## What you'll learn

- Run a JetStream-enabled NATS server locally with Docker
- Scaffold a Node 24 TypeScript project that runs `.ts` directly with no build step
- Model your domain events with a typed contract shared by producer and worker
- Create a stream and a durable pull consumer with the right retention, ack, and retry settings
- Publish events idempotently so a retried publish does not create a duplicate
- Build a worker that consumes with explicit acknowledgement and an async iterator
- Retry transient failures with delayed `nak()`s on a client-side backoff schedule
- Add a dead-letter queue for poison messages, since JetStream has no native DLQ
- Shut the worker down gracefully so in-flight messages are not lost

## Prerequisites

- **Node.js 24 LTS** or newer. Node 24 is the current Active LTS line, supported through April 2028, and runs TypeScript files natively without `ts-node` or `tsx`[^node-lts][^node-ts]. Check with `node --version`.
- **Docker** (Desktop 4.x or any engine) to run the NATS server. Verify with `docker --version`.
- Working knowledge of **TypeScript** and `async`/`await`.
- A terminal. The optional `nats` CLI[^natscli] is nice for inspection but not required — we verify with code.

The stack: `nats-server` 2.14.1 for persistence, the modular [`@nats-io/jetstream`](https://www.npmjs.com/package/@nats-io/jetstream) 3.4.0 client with its `@nats-io/transport-node` 3.4.0 Node transport and `@nats-io/nats-core` 3.4.0 helpers. If you have seen `nats` 2.x imports with `JSONCodec` and a push-based `js.subscribe()` call in older posts, that monolithic package is the previous generation; the v3 client splits transport, core, and JetStream into separate packages and uses a pull-based `consume()` API[^jetstream-npm].

This worker is the runnable cousin of the concepts in [event-driven architecture](https://nerdleveltech.com/event-driven-architecture-building-systems-that-react-in-real-time), and a NATS-native alternative to a [Postgres-backed job queue with pg-boss](https://nerdleveltech.com/pg-boss-postgres-job-queue-node-typescript-production-tutorial).

## Step 1 — Run NATS with JetStream

JetStream is the persistence layer inside `nats-server`. It is not on by default — you enable it with the `-js` flag[^docker]. Start a server with a pinned image tag:

```bash
docker run --name nats-js -p 4222:4222 nats:2.14.1-alpine3.22 -js
```

Port `4222` is the default client port. You should see the server log a line containing `Starting JetStream` near the top of the output. Leave this terminal running; open a new one for the rest of the tutorial.

To persist stream data across container restarts, mount a volume and point JetStream at it:

```bash
docker run --name nats-js -p 4222:4222 \
  -v "$(pwd)/nats-data:/data" \
  nats:2.14.1-alpine3.22 -js -sd /data
```

The `-sd` (store directory) flag tells JetStream where to write file-backed streams. Without it, a `File` storage stream lives only as long as the container.

## Step 2 — Scaffold a Node 24 TypeScript project

Create a project that Node 24 can run without a compile step. The key is `"type": "module"` so Node treats `.ts` files as ES modules, plus a `tsconfig.json` used only for type-checking — Node strips types at runtime and never type-checks[^node-ts].

```bash
mkdir orders-worker && cd orders-worker
npm init -y
npm pkg set type=module
npm install @nats-io/jetstream@3.4.0 @nats-io/transport-node@3.4.0 @nats-io/nats-core@3.4.0
npm install -D typescript@5 @types/node@24
```

Pin `@types/node` to the `24` major so the types match your Node 24 runtime; the `25` line tracks Node 25 (Current), not the LTS you are running. Now add a `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "noEmit": true,
    "lib": ["ES2023"],
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
```

Two settings matter for native execution. `allowImportingTsExtensions` lets you write `import "./events.ts"` with the real extension, which Node's strict ESM resolver *requires* — it does not guess extensions[^node-ts]. `verbatimModuleSyntax` forces you to mark type-only imports with `import type`, which keeps type stripping unambiguous. `noEmit` is correct because TypeScript is your linter here, not your compiler.

Add a type-check script to `package.json`:

```json
{
  "scripts": {
    "typecheck": "tsc -p tsconfig.json"
  }
}
```

Run `npm run typecheck` any time — it should print nothing and exit `0`. You run the actual programs with plain `node src/<file>.ts`.

## Step 3 — Model your domain events

Put the contract producer and worker share in one file so a field rename breaks both sides at type-check time. This is the single most valuable habit in event-driven code: the message *is* the API.

```typescript
// src/events.ts
export interface OrderPlaced {
  orderId: string;
  email: string;
  amountCents: number;
}

export const STREAM = "ORDERS";
export const SUBJECTS = "orders.>";
export const PLACED_SUBJECT = "orders.placed";
export const DURABLE = "email-sender";
export const DLQ_SUBJECT = "orders.dlq";
export const MAX_DELIVER = 5;
export const BACKOFF_MS = [1_000, 5_000, 15_000, 30_000];
```

`SUBJECTS` uses the `>` wildcard so the stream captures every `orders.*` subject (`orders.placed`, `orders.refunded`, and so on), while the consumer in Step 4 filters down to just `orders.placed`. `MAX_DELIVER` is shared between the consumer config and the worker's DLQ check so the two can never drift apart, and `BACKOFF_MS` is the per-retry delay schedule the worker applies in Step 6.

## Step 4 — Create the stream and durable consumer

A **stream** stores messages; a **consumer** is a stateful cursor that tracks which messages a worker has acknowledged. We create both once, up front, with the JetStream manager. Streams and durable consumers are declarative — calling `add` again with the same name is idempotent for the same configuration.

```typescript
// src/setup.ts
import { connect } from "@nats-io/transport-node";
import {
  jetstreamManager,
  AckPolicy,
  DeliverPolicy,
  RetentionPolicy,
  StorageType,
} from "@nats-io/jetstream";
import { nanos } from "@nats-io/nats-core";
import { STREAM, SUBJECTS, DURABLE, PLACED_SUBJECT, MAX_DELIVER } from "./events.ts";

const nc = await connect({ servers: process.env.NATS_URL ?? "localhost:4222" });
const jsm = await jetstreamManager(nc);

const stream = await jsm.streams.add({
  name: STREAM,
  subjects: [SUBJECTS],
  retention: RetentionPolicy.Limits,
  storage: StorageType.File,
  max_age: nanos(7 * 24 * 60 * 60 * 1000),
  num_replicas: 1,
});
console.log(`stream ${stream.config.name} <- [${stream.config.subjects.join(", ")}]`);

const consumer = await jsm.consumers.add(STREAM, {
  durable_name: DURABLE,
  ack_policy: AckPolicy.Explicit,
  deliver_policy: DeliverPolicy.All,
  filter_subject: PLACED_SUBJECT,
  ack_wait: nanos(30_000),
  max_deliver: MAX_DELIVER,
  max_ack_pending: 100,
});
console.log(`consumer ${consumer.name} ready (max_deliver=${consumer.config.max_deliver})`);

await nc.drain();
```

Every duration in JetStream is nanoseconds, so the `nanos()` helper from `@nats-io/nats-core` converts milliseconds for you — `nanos(30_000)` is 30 seconds[^nats-server]. The consumer settings are where retry behavior lives[^consumer-config]:

| Setting | Value here | What it controls |
|---------|-----------|------------------|
| `ack_policy` | `Explicit` | Every message must be individually acknowledged. Without this there is no redelivery. |
| `deliver_policy` | `All` | Start from the first message in the stream. New durable consumers replay history. |
| `filter_subject` | `orders.placed` | Only deliver this subject, even though the stream captures `orders.>`. |
| `ack_wait` | 30s | If a message is delivered but neither acked, `nak`ed, nor marked `working()` within this window, JetStream redelivers it. This is your crash-safety net. |
| `max_deliver` | 5 | Hard cap on total delivery attempts. After the 5th, JetStream stops redelivering on its own. |
| `max_ack_pending` | 100 | How many un-acked messages can be in flight at once — your concurrency ceiling. |

`RetentionPolicy.Limits` keeps messages until the stream's age or size limit is hit, regardless of consumption — good for an event log multiple consumers read. (`Workqueue` retention, by contrast, deletes a message once it is acked.) `StorageType.File` persists to disk so a server restart does not lose the stream.

There is also a consumer-level `backoff` option (an array of nanosecond delays), but note two things from the NATS docs before reaching for it: it applies **only to acknowledgement timeouts, not to explicit `nak`s**, and when set, its first value **overrides `ack_wait`**[^consumer-config]. Because this tutorial drives retries with explicit `nak`s (Step 6), we leave `backoff` off and keep `ack_wait` as a clean 30-second timeout, applying the backoff schedule ourselves on the client.

Run it:

```bash
node src/setup.ts
```

Expected output:

```text
stream ORDERS <- [orders.>]
consumer email-sender ready (max_deliver=5)
```

## Step 5 — Publish events idempotently

Publishing to JetStream returns a `PubAck` that confirms the message was stored and gives you its stream sequence number. The important option is `msgID`: if you publish twice with the same `msgID` inside the stream's duplicate window, JetStream stores it once and the second `PubAck` comes back with `duplicate: true`[^dedup]. That makes a publish safe to retry: if a network blip makes you resend, the event is stored only once instead of creating a duplicate.

```typescript
// src/producer.ts
import { connect } from "@nats-io/transport-node";
import { jetstream } from "@nats-io/jetstream";
import type { OrderPlaced } from "./events.ts";
import { PLACED_SUBJECT } from "./events.ts";

const nc = await connect({ servers: process.env.NATS_URL ?? "localhost:4222" });
const js = jetstream(nc);

const order: OrderPlaced = {
  orderId: crypto.randomUUID(),
  email: "buyer@example.com",
  amountCents: 4200,
};

const ack = await js.publish(PLACED_SUBJECT, JSON.stringify(order), {
  msgID: order.orderId,
});
console.log(`stored seq=${ack.seq} duplicate=${ack.duplicate}`);

await nc.drain();
```

`crypto.randomUUID()` is a Node global, no import needed. Run it once:

```bash
node src/producer.ts
# stored seq=1 duplicate=false
```

Run it a second time and the sequence climbs because each call generates a fresh `orderId`. To *see* deduplication, hard-code `orderId: "fixed-id-123"` and run twice quickly — the second run prints `duplicate=true` and does not advance the stream. This is how you make an event producer safe to retry after a network blip.

## Step 6 — Build the worker that consumes and acks

The worker gets the consumer by name and calls `consume()`, which returns an async iterable of messages. You pull messages by iterating; you tell JetStream the outcome of each by calling `ack()`, `nak()`, or `term()` on the message[^consume].

```typescript
// src/worker.ts
import { connect } from "@nats-io/transport-node";
import { jetstream } from "@nats-io/jetstream";
import type { OrderPlaced } from "./events.ts";
import { STREAM, DURABLE, DLQ_SUBJECT, MAX_DELIVER, BACKOFF_MS } from "./events.ts";

const nc = await connect({ servers: process.env.NATS_URL ?? "localhost:4222" });
const js = jetstream(nc);
const consumer = await js.consumers.get(STREAM, DURABLE);

async function sendReceipt(order: OrderPlaced): Promise<void> {
  if (order.amountCents <= 0) throw new Error(`invalid amount for ${order.orderId}`);
  console.log(`receipt sent to ${order.email} for order ${order.orderId}`);
}

const messages = await consumer.consume({ max_messages: 10 });

process.on("SIGTERM", () => messages.close());
process.on("SIGINT", () => messages.close());

console.log(`worker up; waiting for "${STREAM}" messages`);

for await (const m of messages) {
  let order: OrderPlaced;
  try {
    order = m.json<OrderPlaced>();
  } catch {
    m.term("unparseable payload");
    continue;
  }

  try {
    await sendReceipt(order);
    m.ack();
  } catch (err) {
    if (m.info.deliveryCount >= MAX_DELIVER) {
      await js.publish(DLQ_SUBJECT, m.data, { msgID: `dlq-${m.seq}` });
      m.term(`moved to DLQ after ${m.info.deliveryCount} attempts`);
      console.error(`DLQ: order ${order.orderId} (${String(err)})`);
    } else {
      const delayMs = BACKOFF_MS[Math.min(m.info.deliveryCount - 1, BACKOFF_MS.length - 1)] ?? 30_000;
      console.warn(`retry ${m.info.deliveryCount}/${MAX_DELIVER} for ${order.orderId} in ${delayMs}ms`);
      m.nak(delayMs);
    }
  }
}

await nc.drain();
```

Three message outcomes do all the work:

- **`m.ack()`** — success. JetStream advances the consumer and never redelivers this message.
- **`m.nak(delayMs)`** — negative acknowledgement with an explicit delay. A bare `m.nak()` redelivers almost immediately; passing a delay in milliseconds is what actually backs the retry off[^consumer-config]. We index into `BACKOFF_MS` by the current delivery count, so attempts wait 1s, 5s, 15s, then 30s. Use this for *transient* failures (a downstream timeout, a 503).
- **`m.term(reason)`** — terminate. Stop redelivering this message permanently, regardless of `max_deliver`. Use this for *permanent* failures (a payload that will never parse).

`m.json<OrderPlaced>()` decodes the JSON body into your type; the surrounding `try`/`catch` turns an unparseable message into a `term()` so a single bad payload cannot wedge the worker forever. `m.info.deliveryCount` is the count of times this specific message has been delivered — the value both the backoff index and the DLQ check in Step 7 key off.

Run the worker (in its own terminal, with the producer from Step 5 having published at least once):

```bash
node src/worker.ts
```

Expected output:

```text
worker up; waiting for "ORDERS" messages
receipt sent to buyer@example.com for order 7c3e9b12-5a4f-4c0e-9b1a-2f6d8e4a1c33
```

Leave it running and fire `node src/producer.ts` again from another terminal — the worker prints a new receipt line within a moment.

## Step 7 — Add a dead-letter queue

JetStream has **no built-in dead-letter queue**[^dlq]. When a message hits `max_deliver`, JetStream simply stops redelivering it — without an explicit DLQ step, the failure is silent. The pattern is to detect the final attempt yourself, copy the raw payload to a DLQ subject, and `term()` the original so it is removed from the active set immediately. That is exactly what the `else`/`if` branch in Step 6 does:

```typescript
if (m.info.deliveryCount >= MAX_DELIVER) {
  await js.publish(DLQ_SUBJECT, m.data, { msgID: `dlq-${m.seq}` });
  m.term(`moved to DLQ after ${m.info.deliveryCount} attempts`);
}
```

Two details make this robust. First, republish `m.data` (the raw `Uint8Array`), not a re-serialized object — the DLQ should hold the original bytes for forensic replay. Second, the `msgID: \`dlq-${m.seq}\`` keys the DLQ publish on the original stream sequence, so if the worker crashes between `publish` and `term` and the message is redelivered, the re-run won't write a duplicate DLQ entry.

For the DLQ to be a real queue you can inspect and replay, make sure the `orders.dlq` subject is captured by a stream. The `ORDERS` stream's `orders.>` wildcard already covers it, so DLQ messages land back in the same stream under a different subject. In a larger system you would give the DLQ its own stream with longer retention and an alerting consumer. To trigger the path end to end, publish an order with `amountCents: 0` — `sendReceipt` throws every time, the worker logs `retry 1/5` … `retry 4/5`, and on the fifth delivery it republishes to `orders.dlq` and terminates the original.

## Step 8 — Shut down gracefully

A worker killed mid-message must not lose that message. The two pieces above already handle this. `messages.close()` (wired to `SIGTERM` and `SIGINT`) stops pulling new messages but lets the current iteration finish, so the `for await` loop ends cleanly. After the loop, `nc.drain()` flushes pending acks and closes the connection in order[^transport-npm]. Any message that was delivered but not yet acked when the process exits is redelivered automatically once `ack_wait` (30s) elapses, because JetStream never considers an un-acked message done. That is the safety guarantee of explicit acknowledgement: at-least-once delivery survives a crash[^delivery].

Press `Ctrl+C` in the worker terminal and you will see the loop stop and the process exit without a dangling connection warning.

## Verification

Confirm the whole pipeline from code, no CLI required. Create `src/verify.ts`:

```typescript
// src/verify.ts
import { connect } from "@nats-io/transport-node";
import { jetstreamManager } from "@nats-io/jetstream";
import { STREAM, DURABLE } from "./events.ts";

const nc = await connect({ servers: process.env.NATS_URL ?? "localhost:4222" });
const jsm = await jetstreamManager(nc);

const info = await jsm.streams.info(STREAM);
console.log(`stream messages: ${info.state.messages}`);

const c = await jsm.consumers.info(STREAM, DURABLE);
console.log(`pending (unacked + undelivered): ${c.num_pending}`);
console.log(`redelivered in flight: ${c.num_redelivered}`);

await nc.drain();
```

```bash
node src/verify.ts
```

After publishing a few good orders and letting the worker drain them, `pending` should fall to `0`. If you published a poison (`amountCents: 0`) order, you will see one extra message in the stream (the DLQ copy on `orders.dlq`) and `num_redelivered` reflecting the retry attempts. If you have the `nats` CLI[^natscli] installed, `nats consumer report ORDERS` shows the same numbers in a table.

## Troubleshooting

These are the failures people actually hit, drawn from the NATS issue tracker and the client README.

- **`Error: no responders` or a hung `connect()`** — the server is not reachable on `4222`, or you started it without `-js`. Confirm the Docker container is running (`docker ps`) and that its log shows `Starting JetStream`. JetStream API calls hang or error when JetStream is disabled.
- **`An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled`** — your `tsconfig.json` is missing `allowImportingTsExtensions: true`. Node *needs* the `.ts` in the import specifier, and the type-checker needs this flag to allow it[^node-ts].
- **`ERR_UNKNOWN_FILE_EXTENSION` when running `node src/setup.ts`** — you are on a Node version older than 22.18 / 24, or `"type": "module"` is missing from `package.json`. Native TypeScript execution is stable in Node 24; check `node --version`[^node-ts].
- **Messages never redeliver after a failure** — the consumer's `ack_policy` is not `Explicit`, or your handler is calling `ack()` in a `finally` block that runs even on error. Only `ack()` on actual success; `nak()` or `term()` on failure.
- **The same message redelivers forever** — you are `nak`ing a permanent failure. A payload that can never succeed should be `term()`ed (or sent to the DLQ), not `nak`ed. Check that your DLQ branch compares against `max_deliver` correctly with `>=`, not `>`.

## Next steps & further reading

You now have a durable, retrying, DLQ-backed worker in under 150 lines of TypeScript. From here:

- Give the DLQ its **own stream** with longer retention and a separate alerting consumer, instead of sharing the `ORDERS` stream.
- Add a **second durable consumer** on the same stream (for example `analytics`) — with `Limits` retention, multiple consumers read the same events independently. This is the core advantage over a single-reader queue.
- Scale horizontally by running **multiple copies of the worker** against the same durable consumer; JetStream load-balances messages across them up to `max_ack_pending`.

For the architectural background, see [event-driven architecture](https://nerdleveltech.com/event-driven-architecture-building-systems-that-react-in-real-time) and the broker tradeoffs in [Kafka vs RabbitMQ vs SQS vs NATS](https://nerdleveltech.com/message-queue-showdown-kafka-vs-rabbitmq-vs-sqs-vs-nats). For an alternative built on your existing database, compare the [Postgres-backed pg-boss job queue](https://nerdleveltech.com/pg-boss-postgres-job-queue-node-typescript-production-tutorial).

[^jetstream-npm]: `@nats-io/jetstream` on npm — 3.4.0 is the current `latest` dist-tag (published 2026-05-08). The v3 client is modular: transport, core, and JetStream ship as separate packages. https://www.npmjs.com/package/@nats-io/jetstream
[^transport-npm]: `@nats-io/transport-node` on npm (3.4.0) provides the Node `connect()` and `NatsConnection.drain()`. https://www.npmjs.com/package/@nats-io/transport-node
[^nats-server]: `nats-server` releases — v2.14.1 released 2026-05-20. JetStream durations are expressed in nanoseconds. https://github.com/nats-io/nats-server/releases
[^docker]: "JetStream | NATS Docs" — running the official `nats` Docker image with the `-js` flag to enable JetStream. https://docs.nats.io/running-a-nats-service/nats_docker/jetstream_docker
[^node-ts]: "Running TypeScript Natively | Node.js" and "Modules: TypeScript | Node.js Documentation" — type stripping is enabled by default (since v22.18.0 / v23.6.0, stable in 24), Node does not type-check at runtime, and ESM resolution requires explicit `.ts` import extensions. https://nodejs.org/learn/typescript/run-natively
[^node-lts]: Node.js release schedule — Node 24 is the current Active LTS line, supported through April 2028. https://nodejs.org/en/about/previous-releases
[^delivery]: `@nats-io/jetstream` README — JetStream provides at-least-once delivery; explicit acknowledgement drives redelivery of un-acked messages. https://github.com/nats-io/nats.js/blob/main/jetstream/README.md
[^dedup]: `JetStreamPublishOptions.msgID` — a message published with a `msgID` already seen inside the stream's `duplicate_window` is stored once; the `PubAck` returns `duplicate: true`. https://docs.nats.io/nats-concepts/jetstream/streams
[^consumer-config]: "Consumers | NATS Docs" — `ack_policy`, `ack_wait`, `max_deliver`, and `backoff` semantics for pull consumers. https://docs.nats.io/nats-concepts/jetstream/consumers
[^dlq]: NATS JetStream has no native dead-letter queue; the documented approach is to detect `max_deliver` exhaustion (via delivery-count advisories or the message's delivery count) and republish to a DLQ subject in your application. https://docs.nats.io/nats-concepts/jetstream/consumers
[^consume]: `@nats-io/jetstream` README — the pull `consume()` API returns an async iterable of `JsMsg`; each message is finalized with `ack()`, `nak()`, `term()`, or `working()`. https://github.com/nats-io/nats.js/blob/main/jetstream/README.md
[^natscli]: `natscli` (the `nats` command-line tool) — v0.4.0 released 2026-05-01. https://github.com/nats-io/natscli/releases
