Node.js + NATS JetStream in 2026: Lightweight Messaging at Scale
Kafka has dominated event-streaming conversations for nearly a decade, but in 2026 a quieter contender is winning real production deployments: NATS JetStream. Teams are tired of running 6-node Kafka clusters with ZooKeeper-style ops overhead just to move a few hundred thousand messages a day. JetStream offers durable streams, at-least-once delivery, a built-in key-value store, and a 15 MB single-binary broker that runs anywhere from a Raspberry Pi at the edge to a multi-region cluster on Kubernetes.
This guide walks through running NATS JetStream in production with Node.js — the nats.js client, stream and consumer configuration, exactly-once semantics, KV state, performance characteristics, and the honest cases where you should still reach for Kafka instead. By the end you'll have the patterns and benchmarks needed to make a confident architectural call for your team.
Why NATS JetStream is having a 2026 moment
NATS started life as a fire-and-forget pub/sub system — fast, but with no persistence. JetStream, added in 2020 and now stable across every major release, glues durable streams on top of the same wire protocol. The result is a single broker that can do both ephemeral pub/sub and persistent streaming, with consumer groups, message replay, dedupe windows, and stream replication baked in.
Three shifts pushed JetStream into the mainstream this year. First, the edge: most Kafka deployments don't fit on an edge gateway, but a NATS server happily runs in 30 MB of RAM. Second, multi-tenancy: NATS accounts give you logical isolation without spinning up extra brokers. Third, the developer experience — a one-line `nats stream add` and consumers that you can describe with a JSON object beat the `kafka-topics.sh` ecosystem for anyone shipping fewer than a billion events per day.
NATS Core vs JetStream — what JetStream actually adds
If you've used NATS Core, you know the basics: publish to a subject like `orders.created`, and any subscriber listening to `orders.>` receives the message. There is no buffering, no replay, and no acknowledgement of delivery. If a subscriber is offline, the message is gone.
JetStream sits on top of Core and watches selected subjects. When a subject matches a stream's filter, JetStream persists the message to disk with optional replication across three nodes. Consumers — which can be push or pull — track their own position in the stream, ack messages explicitly, and retry on failure. Crucially, the publish API doesn't change: your producers still publish to `orders.created`, and JetStream catches the message transparently.
The two new primitives: Streams and Consumers
A stream is a persistent log bound to one or more subjects, with retention policy (time-based, size-based, or work-queue), max-message count, and replica count. A consumer is a named position into a stream, with ack policy, max-redeliveries, and either push (server delivers to a subject) or pull (client requests batches).

Setting up JetStream with the nats.js Node.js client
The official nats.js client (v2.30+ at the time of writing) supports both NATS Core and JetStream. Install it from npm, connect to the cluster, then grab a JetStream context to manage streams and consumers.
npm install nats
# optional: nats CLI for inspection
brew install nats-io/nats-tools/natsHere is a complete producer/consumer example. The producer publishes order events; the consumer pulls them in batches with explicit acks. If a message takes longer than the ack-wait window, JetStream redelivers it — at-least-once delivery is the default.
import { connect, StringCodec, AckPolicy } from "nats";
const sc = StringCodec();
async function setupStream(jsm) {
try {
await jsm.streams.info("ORDERS");
} catch {
await jsm.streams.add({
name: "ORDERS",
subjects: ["orders.>"],
retention: "workqueue",
max_age: 7 * 24 * 60 * 60 * 1_000_000_000, // 7 days, nanoseconds
num_replicas: 3,
storage: "file"
});
}
}
async function publishOrder(js, order) {
// unique msgId enables JetStream's dedupe window
const ack = await js.publish(
`orders.created.${order.region}`,
sc.encode(JSON.stringify(order)),
{ msgID: order.id, expect: { lastSequence: undefined } }
);
console.log(`stored seq=${ack.seq} dup=${ack.duplicate}`);
}
async function startWorker(js, jsm) {
await jsm.consumers.add("ORDERS", {
durable_name: "order-worker",
ack_policy: AckPolicy.Explicit,
max_deliver: 5,
ack_wait: 30 * 1_000_000_000, // 30s
filter_subject: "orders.created.>"
});
const consumer = await js.consumers.get("ORDERS", "order-worker");
const messages = await consumer.consume({ max_messages: 100 });
for await (const m of messages) {
try {
const order = JSON.parse(sc.decode(m.data));
await chargeCard(order); // your business logic
m.ack();
} catch (err) {
if (m.info.redeliveryCount >= 4) m.term(); // give up → DLQ
else m.nak(2000); // retry in 2s
}
}
}
const nc = await connect({ servers: "nats://nats:4222" });
const js = nc.jetstream();
const jsm = await nc.jetstreamManager();
await setupStream(jsm);
await Promise.all([
publishOrder(js, { id: "ord_123", region: "eu", amountCents: 4999 }),
startWorker(js, jsm)
]);Streams, subjects, and retention policies
Subjects are the routing layer. They look like dotted segments — `orders.created.eu`, `payments.refunded.us` — and consumers subscribe with wildcards. A `*` matches one token, `>` matches one-or-more. Pick a subject hierarchy that lets you scale horizontally: include region, tenant, or service so consumers can filter without seeing the whole firehose.
Retention controls when JetStream evicts messages from disk. Three modes exist:
Limits retention keeps messages until any limit (age, size, count) is hit — the default for analytics-style replay. Interest retention keeps messages only while a consumer is bound — useful for ephemeral fanout. WorkQueue retention deletes each message after it is acked by any consumer — the right choice for task queues where you don't want re-reading.
Sizing your stream
Set `max_msgs`, `max_bytes`, and `max_age` together. JetStream applies them as an OR — whichever limit is hit first triggers eviction. For a typical SaaS event stream we target 30 days retention, 50 GB cap, and 100 M message cap; the storage stays predictable and the cluster has time to recover from a multi-day outage downstream.
Consumers — push vs pull, durable vs ephemeral
Consumers are where most of JetStream's flexibility lives. A push consumer asks the server to deliver matching messages to a NATS subject the client subscribes to — simple, but you give up flow control during traffic spikes. A pull consumer requests batches on demand, giving you precise control over concurrency and a natural back-pressure signal.
For Node.js workloads we recommend pull consumers in almost every case. Each worker requests `max_messages: 100` at a time, processes them with `Promise.all`, and only requests more once the batch is acked. This pattern keeps memory bounded, makes restarts safe, and aligns perfectly with the Node.js event-loop model.
Durable vs ephemeral
Hire Pre-Vetted Node.js Developers
Skip the months-long search. Our exclusive talent network has senior Node.js experts ready to join your team in 48 hours.
A durable consumer is created on the server and tracks its position across restarts. Use it for any persistent worker. Ephemeral consumers vanish when their last subscription closes — they're handy for dev tools, one-off replays, and CLI inspection but never for production traffic.
Exactly-once delivery and idempotency in JetStream
JetStream gives you exactly-once on the publish side via dedupe windows, and at-least-once on the delivery side. Consumers can crash mid-processing; once their ack times out the message is redelivered. The contract is clear: design your handlers to be idempotent.
The classic pattern is an idempotency key stored in your database or in JetStream's own KV store. Before executing the side-effect (charging a card, sending an email, writing a record), check whether the key already exists. If it does, ack and move on. If not, perform the work inside a transaction that writes both the result and the key. This pattern is so common we've documented it in detail in our companion guide on building reliable Node.js APIs.
For a full walkthrough of building idempotent handlers — including database-backed locks, JetStream KV implementations, and the trade-offs of each — see our guide on idempotency patterns for Node.js APIs. The same techniques carry over to any queue or stream backend you choose.

KV Store and Object Store — bonus building blocks
JetStream ships with two higher-level abstractions built on top of streams. The KV store is a Redis-style key-value bucket with optional TTL, history, and watcher API. The Object store handles large binary payloads (think file uploads) by chunking them across stream messages.
These are useful well beyond messaging. We've replaced dedicated Redis instances for feature-flag caching, session storage, and distributed locks with JetStream KV — one fewer system to monitor, one fewer set of credentials to rotate. The KV API is intentionally minimal: get, put, watch, delete, with optimistic concurrency via revision numbers.
import { connect } from "nats";
const nc = await connect({ servers: "nats://nats:4222" });
const js = nc.jetstream();
// Create or open a KV bucket — backed by a JetStream stream under the hood
const kv = await js.views.kv("feature-flags", {
history: 5, // keep last 5 revisions per key
ttl: 0, // no expiry
replicas: 3
});
// CAS-style update: only write if the revision matches
const current = await kv.get("checkout-v2-enabled");
const newValue = JSON.stringify({ enabled: true, percentage: 25 });
await kv.update(
"checkout-v2-enabled",
newValue,
current?.revision ?? 0
);
// Watch keys for live updates — great for hot-reload of config
const watcher = await kv.watch({ key: "checkout-*" });
for await (const entry of watcher) {
if (entry.operation === "DEL") continue;
console.log(`flag changed: ${entry.key} = ${entry.string()}`);
}Performance tuning, replication, and observability
Three knobs matter for production JetStream: storage backend, replica count, and consumer batch size. File storage gives durability with reasonable speed; memory storage is faster but loses data on restart. Three replicas is the sweet spot — five gives you tolerance for two simultaneous failures at meaningful latency cost.
For observability, JetStream exposes Prometheus metrics out of the box (`/varz`, `/jsz`, `/connz`). Pair them with the official NATS Grafana dashboards to track stream growth, ack latency, redelivery rates, and consumer lag. The same metrics surface in OpenTelemetry exporters if you've standardised on OTel for your Node.js services.
Connection pooling from Node.js
Unlike most database clients, the nats.js client multiplexes everything over a single TCP connection. You do not need a pool — open one `NatsConnection` per process and share it across handlers. Each subscription is just an in-memory routing entry on the client; you can have thousands without ever opening a second socket.
When NOT to use NATS JetStream
Kafka still wins in a few scenarios. If you need infinite retention with tiered storage for petabyte-scale analytics, Kafka's ecosystem (Kafka Connect, ksqlDB, Confluent Cloud) is unmatched. If your team already runs Kafka and the ops cost is sunk, switching is hard to justify. If you depend on Kafka Streams or Flink for stateful streaming joins, JetStream has no equivalent — you would need to layer Materialize or RisingWave on top.
RabbitMQ is still the better choice when you need complex routing topologies (headers exchanges, advanced TTLs, dead-letter chains) or AMQP protocol compatibility with legacy systems. JetStream's subject-based routing is simpler but less expressive.
If you're weighing message brokers more broadly, our deep-dive on Node.js job queues comparing BullMQ, RabbitMQ, and Kafka covers the trade-offs in depth. For Kafka-specific patterns, see our Node.js Kafka event-streaming guide.
Hire Expert Node.js Developers — Ready in 48 Hours
Building messaging-heavy backends is only half the battle — you need engineers who have actually shipped JetStream, Kafka, and queue-driven systems to production. HireNodeJS.com specialises exclusively in Node.js talent: every developer is pre-vetted on real-world projects, API design, event-driven architecture, and production deployments.
Unlike generalist platforms, our curated pool means you speak only to engineers who live and breathe Node.js. Most clients have their first developer working within 48 hours of getting in touch. Engagements start as short-term contracts and can convert to full-time hires with zero placement fee. If your team is choosing between NATS, Kafka, and RabbitMQ — or scoping a migration — we can match you with someone who has built the exact system before.
Need help right now? Browse backend developers on HireNodeJS or get a full picture of how the hiring process works end-to-end.
Wrapping up — should you adopt JetStream?
If your team needs durable messaging without the operational weight of Kafka, JetStream is the strongest 2026 choice for Node.js backends. It runs on commodity hardware, fits at the edge, and gives you streams, KV, and object storage from a single binary. The Node.js client is mature, the documentation has caught up to the feature surface, and the failure modes are well understood.
Start with a small pilot: one stream, two services, three replicas. Get a feel for the ack-redelivery cycle, set up Prometheus metrics, and stress-test with k6. By the time you've shipped a single use-case to production you'll know whether JetStream fits your architecture — and if it does, the savings in cluster count and ops time pay for themselves within a quarter.
Frequently Asked Questions
What is the difference between NATS Core and NATS JetStream?
NATS Core is fire-and-forget pub/sub: messages are delivered in real time and discarded immediately. JetStream adds durable streams on top of Core, persisting messages to disk with replication, consumer groups, replay, and at-least-once delivery — without changing the producer-side wire protocol.
Is NATS JetStream a real Kafka replacement for Node.js teams?
For most Node.js workloads up to a few hundred thousand events per second, yes. JetStream offers durable streams, dedupe windows, KV state, and a single-binary broker that runs in 30 MB of RAM. Kafka still wins for petabyte-scale analytics, complex stateful streaming joins, and teams already invested in the Kafka ecosystem (Connect, Streams, ksqlDB).
Does NATS JetStream support exactly-once delivery?
JetStream provides exactly-once semantics on the publish side via a configurable dedupe window keyed on msgID, and at-least-once on the delivery side. Combined with idempotent consumer handlers (using a database transaction or the JetStream KV store), you get effectively exactly-once end-to-end.
What is the recommended Node.js client for NATS JetStream?
The official nats.js client (v2.30 or later) is the recommended choice. It supports both NATS Core and JetStream, ships with TypeScript types, and the same API works in Node.js, Deno, and Bun. Install it with npm install nats.
How many replicas should I use for production JetStream streams?
Three replicas across availability zones is the minimum for any production workload. Single-replica streams lose every message on a disk failure. Five replicas adds tolerance for two simultaneous failures but increases write latency — three is the sweet spot for the vast majority of teams.
Can I run NATS JetStream at the edge or on a Raspberry Pi?
Yes — JetStream's edge-friendly footprint is one of its main advantages. A single nats-server binary plus a small file-storage volume runs comfortably on a Raspberry Pi 4, an industrial gateway, or any constrained device. Use leaf-node connections to bridge edge clusters back to a central super-cluster.
Vivek Singh is the founder of Witarist and HireNodeJS.com — a platform connecting companies with pre-vetted Node.js developers. With years of experience scaling engineering teams, Vivek shares insights on hiring, tech talent, and building with Node.js.
Building event-driven systems? Hire a Node.js architect who has shipped JetStream and Kafka.
HireNodeJS connects you with pre-vetted senior Node.js engineers who have run production messaging at scale. NATS, Kafka, RabbitMQ, BullMQ — match the pattern to the right person in under 48 hours, with no recruiter fees and no lengthy screening.
