NATS JetStream Worker in TypeScript: Retries & DLQ (2026)
June 2, 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 naks, 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 client1 — 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.12. 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
.tsdirectly 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-nodeortsx34. Check withnode --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
natsCLI5 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 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() API1.
This worker is the runnable cousin of the concepts in event-driven architecture, and a NATS-native alternative to a Postgres-backed job queue with pg-boss.
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 flag6. Start a server with a pinned image tag:
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:
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-checks4.
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:
{
"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 extensions4. 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:
{
"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.
// 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.
// 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 seconds2. The consumer settings are where retry behavior lives7:
| 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, naked, 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 naks, and when set, its first value overrides ack_wait7. Because this tutorial drives retries with explicit naks (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:
node src/setup.ts
Expected output:
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: true8. 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.
// 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:
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 message9.
// 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 barem.nak()redelivers almost immediately; passing a delay in milliseconds is what actually backs the retry off7. We index intoBACKOFF_MSby 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 ofmax_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):
node src/worker.ts
Expected output:
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 queue10. 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:
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 betweenpublishandterm` 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 order11. 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 crash12.
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:
// 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();
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 CLI5 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 respondersor a hungconnect()— the server is not reachable on4222, or you started it without-js. Confirm the Docker container is running (docker ps) and that its log showsStarting 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— yourtsconfig.jsonis missingallowImportingTsExtensions: true. Node needs the.tsin the import specifier, and the type-checker needs this flag to allow it4.ERR_UNKNOWN_FILE_EXTENSIONwhen runningnode src/setup.ts— you are on a Node version older than 22.18 / 24, or"type": "module"is missing frompackage.json. Native TypeScript execution is stable in Node 24; checknode --version4.- Messages never redeliver after a failure — the consumer's
ack_policyis notExplicit, or your handler is callingack()in afinallyblock that runs even on error. Onlyack()on actual success;nak()orterm()on failure. - The same message redelivers forever — you are
naking a permanent failure. A payload that can never succeed should beterm()ed (or sent to the DLQ), notnaked. Check that your DLQ branch compares againstmax_delivercorrectly 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
ORDERSstream. - Add a second durable consumer on the same stream (for example
analytics) — withLimitsretention, 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 and the broker tradeoffs in Kafka vs RabbitMQ vs SQS vs NATS. For an alternative built on your existing database, compare the Postgres-backed pg-boss job queue.
Footnotes
-
@nats-io/jetstreamon npm — 3.4.0 is the currentlatestdist-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 ↩ ↩2 -
nats-serverreleases — v2.14.1 released 2026-05-20. JetStream durations are expressed in nanoseconds. https://github.com/nats-io/nats-server/releases ↩ ↩2 -
Node.js release schedule — Node 24 is the current Active LTS line, supported through April 2028. https://nodejs.org/en/about/previous-releases ↩
-
"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
.tsimport extensions. https://nodejs.org/learn/typescript/run-natively ↩ ↩2 ↩3 ↩4 ↩5 -
natscli(thenatscommand-line tool) — v0.4.0 released 2026-05-01. https://github.com/nats-io/natscli/releases ↩ ↩2 -
"JetStream | NATS Docs" — running the official
natsDocker image with the-jsflag to enable JetStream. https://docs.nats.io/running-a-nats-service/nats_docker/jetstream_docker ↩ -
"Consumers | NATS Docs" —
ack_policy,ack_wait,max_deliver, andbackoffsemantics for pull consumers. https://docs.nats.io/nats-concepts/jetstream/consumers ↩ ↩2 ↩3 -
JetStreamPublishOptions.msgID— a message published with amsgIDalready seen inside the stream'sduplicate_windowis stored once; thePubAckreturnsduplicate: true. https://docs.nats.io/nats-concepts/jetstream/streams ↩ -
@nats-io/jetstreamREADME — the pullconsume()API returns an async iterable ofJsMsg; each message is finalized withack(),nak(),term(), orworking(). https://github.com/nats-io/nats.js/blob/main/jetstream/README.md ↩ -
NATS JetStream has no native dead-letter queue; the documented approach is to detect
max_deliverexhaustion (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 ↩ -
@nats-io/transport-nodeon npm (3.4.0) provides the Nodeconnect()andNatsConnection.drain(). https://www.npmjs.com/package/@nats-io/transport-node ↩ -
@nats-io/jetstreamREADME — 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 ↩