System Design ·

How NATS Works Internally: Subject-Based Messaging, JetStream Persistence, and the Simplicity That Makes It the Fastest Message Broker

A deep dive into NATS internals covering the subject-based pub/sub model, zero-allocation message parser, client-server protocol, JetStream persistence with file and memory storage, Raft-based replication, cluster formation with full-mesh routing, leaf nodes, queue groups, and production considerations with TypeScript examples.

How NATS Works Internally: Subject-Based Messaging, JetStream Persistence, and the Simplicity That Makes It the Fastest Message Broker

Most message broker complexity comes from trying to solve every problem at once: persistence, ordering, routing, backpressure, replay, fan-out. NATS takes a different approach. The core broker is a subject-based routing engine that fits in a single binary under 20 MB. Persistence, replay, and consumer acknowledgment are opt-in through JetStream, layered on top of the same routing primitives. That separation is not an accident. It is the design.

Understanding NATS means understanding two distinct layers: the core messaging protocol, which is intentionally minimal, and JetStream, which adds everything you need for durable, at-least-once delivery without rewriting the substrate underneath.

The Subject-Based Routing Model

Every message in NATS is addressed to a subject, which is a dot-delimited string: orders.created, payments.us.charge, sensors.temperature.floor3. There are no topics with partitions, no exchanges with bindings. Clients subscribe to a subject and receive any message published to it. The broker routes by matching subjects against registered interest.

Wildcards make this practical at scale. A single-token wildcard * matches one segment: sensors.*.floor3 matches sensors.temperature.floor3 and sensors.humidity.floor3 but not sensors.temperature.building2.floor3. The multi-token wildcard > matches one or more trailing segments: orders.> matches any subject under orders. These two operators let you express complex routing topologies without configuring anything on the broker.

The server maintains an interest graph, a trie-like data structure keyed by subject tokens. When a client subscribes, its interest is registered in the graph. When a message arrives, the server walks the trie, collects all matching subscriptions, and delivers the message to each. Unsubscribing removes the entry. At any point in time, the server only routes messages to subjects with active interest. If no subscriber is listening, the message is dropped. That is a deliberate tradeoff: core NATS is fire-and-forget with no built-in buffering.

The Wire Protocol and Zero-Allocation Parser

NATS uses a text-based protocol over plain TCP. A publish looks like this:

PUB orders.created 42\r\n
{"orderId":"abc","total":99.99}\r\n

A subscription acknowledgment from the server confirming delivery to a subscriber:

MSG orders.created 1 42\r\n
{"orderId":"abc","total":99.99}\r\n

The protocol is intentionally simple. There is no framing, no schema negotiation, no handshake beyond a single CONNECT message. The server does not need to decode the payload to route it. It reads the subject, looks it up in the interest graph, and forwards the raw bytes. This is why NATS benchmarks at millions of messages per second on commodity hardware with single-digit microsecond latency.

The parser inside the NATS server is written to avoid heap allocations on the hot path. Subject strings are compared against the interest graph using byte slices backed by the receive buffer. No intermediate strings are allocated for routing decisions. This matters when you have tens of thousands of subscriptions and millions of messages per second competing for the same CPU cache lines.

Headers were added in NATS 2.2 without breaking this model. A publish with headers uses HPUB:

HPUB orders.created NATS/1.0\r\nX-Request-Id: req-123\r\n\r\n 42\r\n
{"orderId":"abc","total":99.99}\r\n

The header block is length-prefixed so the parser can skip it entirely when routing, preserving zero-allocation delivery to subscribers that do not inspect headers.

Client Code: Pub/Sub and Request-Reply

Using the official nats.js client, basic pub/sub is straightforward:

import { connect, StringCodec } from "nats";

const nc = await connect({ servers: "nats://localhost:4222" });
const sc = StringCodec();

// Publisher
nc.publish("orders.created", sc.encode(JSON.stringify({ orderId: "abc", total: 99.99 })));

// Subscriber
const sub = nc.subscribe("orders.>");
(async () => {
  for await (const msg of sub) {
    const data = JSON.parse(sc.decode(msg.data));
    console.log("received", msg.subject, data);
  }
})();

Request-reply is a first-class pattern in NATS. The client generates an ephemeral reply subject, subscribes to it, publishes the request, and waits for a response:

import { connect, StringCodec } from "nats";

const nc = await connect({ servers: "nats://localhost:4222" });
const sc = StringCodec();

// Requester
const response = await nc.request(
  "pricing.calculate",
  sc.encode(JSON.stringify({ items: ["sku-1", "sku-2"] })),
  { timeout: 5000 }
);
const result = JSON.parse(sc.decode(response.data));
console.log("price:", result.total);

// Responder
const sub = nc.subscribe("pricing.calculate");
(async () => {
  for await (const msg of sub) {
    const items = JSON.parse(sc.decode(msg.data));
    const total = items.items.length * 9.99;
    msg.respond(sc.encode(JSON.stringify({ total })));
  }
})();

The reply subject is something like _INBOX.abc123. The server routes the response back to the original requester without any broker-side configuration.

Queue Groups for Load-Balanced Consumption

Queue groups let multiple subscribers share load across a subject without each receiving every message. Subscribers join a named queue group; the server delivers each message to exactly one member of the group, chosen round-robin:

import { connect, StringCodec } from "nats";

async function startWorker(id: number) {
  const nc = await connect({ servers: "nats://localhost:4222" });
  const sc = StringCodec();

  const sub = nc.subscribe("jobs.process", { queue: "workers" });
  (async () => {
    for await (const msg of sub) {
      const job = JSON.parse(sc.decode(msg.data));
      console.log(`worker ${id} processing job ${job.id}`);
    }
  })();
}

// Start three competing workers
await Promise.all([startWorker(1), startWorker(2), startWorker(3)]);

This is NATS’s answer to consumer groups. There is no partition assignment, no rebalancing protocol, no coordinator election. The server picks a member of the queue group per message. Scale by adding more subscribers to the same group name.

JetStream: Persistence on Top of Routing

Core NATS drops messages if no subscriber is connected. JetStream solves this by introducing streams: named, durable storage that captures messages matching a subject filter. When you publish to a subject covered by a stream, the server writes the message to storage before acknowledging the publisher. Consumers then pull from the stream independently of when the publisher ran.

A stream is defined by a name, a set of subject filters, a retention policy (limits, interest, or workqueue), and a storage backend (file or memory). The retention policies work differently:

  • limits: Retain messages up to configured size, count, or age limits. Oldest messages are evicted when limits are exceeded.
  • interest: Retain messages only while at least one consumer has not acknowledged them. A stream with no consumers drops all messages immediately.
  • workqueue: Retain each message until exactly one consumer acknowledges it. Once acknowledged, the message is deleted. This turns a stream into a distributed queue with at-least-once delivery and automatic cleanup.
import { connect, AckPolicy, RetentionPolicy, StorageType } from "nats";

const nc = await connect({ servers: "nats://localhost:4222" });
const js = nc.jetstream();
const jsm = await nc.jetstreamManager();

// Create a stream capturing all order events
await jsm.streams.add({
  name: "ORDERS",
  subjects: ["orders.>"],
  storage: StorageType.File,
  retention: RetentionPolicy.Limits,
  max_age: 7 * 24 * 60 * 60 * 1e9, // 7 days in nanoseconds
  max_msgs: 1_000_000,
});

// Publish a persistent message
const pa = await js.publish(
  "orders.created",
  new TextEncoder().encode(JSON.stringify({ orderId: "abc", total: 99.99 }))
);
console.log("sequence:", pa.seq, "stream:", pa.stream);

JetStream Storage Engines

JetStream provides two storage backends. File storage writes messages to disk as binary block files inside the NATS data directory. Each stream gets a directory with sequentially numbered message blocks and an index file. Blocks are fixed-size (default 8 MB) and are memory-mapped for reads. Writes go through a write buffer flushed on a configurable interval or on explicit FlushTimeout. This is not a log-structured merge tree. There is no compaction in the LSM sense. Retention is handled by deleting whole block files once all messages in them have been evicted.

Memory storage keeps messages in a Go map keyed by sequence number. It is faster and useful for short-lived streams or caches, but data is lost on server restart. In clustered mode, replication writes to followers even for memory streams, so the data survives individual node failures as long as a quorum of replicas is alive.

JetStream Consumers

A consumer is a cursor into a stream with its own acknowledgment state. You can have multiple consumers on the same stream with independent positions, allowing different services to process the same events at their own pace without interfering with each other.

Push consumers deliver messages to a subscriber as they become available. Pull consumers require explicit fetch calls, which is better for controlled throughput:

import { connect, AckPolicy, DeliverPolicy } from "nats";

const nc = await connect({ servers: "nats://localhost:4222" });
const js = nc.jetstream();
const jsm = await nc.jetstreamManager();

// Create a durable pull consumer
await jsm.consumers.add("ORDERS", {
  durable_name: "order-processor",
  ack_policy: AckPolicy.Explicit,
  deliver_policy: DeliverPolicy.All,
  max_deliver: 5,
  ack_wait: 30 * 1e9, // 30 seconds in nanoseconds
});

const consumer = await js.consumers.get("ORDERS", "order-processor");

// Process messages in batches
while (true) {
  const messages = await consumer.fetch({ max_messages: 10, expires: 5000 });
  for await (const msg of messages) {
    try {
      const order = JSON.parse(new TextDecoder().decode(msg.data));
      await processOrder(order);
      msg.ack();
    } catch (err) {
      // Negative acknowledgment causes redelivery after ack_wait
      msg.nak();
    }
  }
}

async function processOrder(order: { orderId: string; total: number }) {
  // processing logic
}

AckPolicy.Explicit requires each message to be individually acknowledged. max_deliver caps redelivery attempts. After that limit, the message is forwarded to a dead-letter stream if configured, or simply marked as deleted depending on the retention policy.

Raft Consensus for JetStream Replication

JetStream clustering uses the Raft consensus algorithm to replicate stream data and consumer state across server nodes. Each stream has a replication factor (R=1, R=3, or R=5). With R=3, writes require acknowledgment from a majority of stream replicas before the publish acknowledgment is returned to the client.

NATS implements a stripped-down version of Raft called NRG (NATS Raft Groups). Each stream is its own Raft group with its own leader. Writes to the leader are appended to the Raft log, replicated to followers, committed on quorum, and then applied to the stream’s storage layer. The Raft log itself is stored on disk separately from the message blocks. Consumer state (current sequence, pending acks) is also replicated via a separate Raft group, so failover does not lose acknowledgment progress.

Leader election uses randomized election timeouts with a 100-300ms jitter. Under normal conditions, leader stability is high and adds no latency to the write path. Under network partition, writes stall until quorum is restored. This is a CP system for the stream write path.

Cluster Formation and Interest Graph Propagation

A NATS cluster is a full mesh of server connections. Each server connects to every other server directly. There is no coordinator, no central registry. When a new server joins, it connects to any known cluster member, which triggers a gossip exchange that establishes connections to all other members.

Client interest (subscriptions) is propagated across the cluster so that any server can route messages correctly regardless of where the publisher connects. When a client on server A subscribes to orders.created, server A sends a subscription interest notification to servers B and C. When a publisher on server B publishes to orders.created, server B knows to forward the message to server A because it has registered interest there.

This interest-graph propagation keeps the routing table consistent across the mesh. The tradeoff is that full-mesh topology becomes expensive at large node counts. Beyond 5-7 servers, you introduce super-cluster topologies with leaf nodes and gateway connections.

Leaf Nodes and Gateway Connections

Leaf nodes are NATS servers that connect to a hub cluster as clients. They serve edge deployments, IoT gateways, or regional clusters that need to extend the subject namespace without participating in the full mesh. A leaf node forwards outbound traffic to the hub and receives inbound traffic based on import/export subject mappings. The leaf node appears as a single client to the hub cluster.

Gateway connections link separate NATS clusters (called accounts or super-clusters) together. Each cluster maintains its own full mesh, and gateways create a sparse connection between cluster boundaries. Interest is exchanged at the gateway level using an optimistic routing protocol: initially, a gateway forwards a message to a remote cluster and receives a “not interested” response if no subscriber exists there. After enough “not interested” responses for a subject, the gateway suppresses forwarding for that subject until new interest is signaled. This makes gateways efficient for sparse cross-cluster routing.

Production Considerations

Subject namespace design matters more than it seems at the start. Use a hierarchical scheme from day one: <service>.<entity>.<event> or <domain>.<region>.<entity>.<verb>. Wildcards are powerful but flat namespaces make them useless. Changing subjects later means coordinating consumer migrations across streams.

Stream retention policy selection is a frequently misunderstood decision. Use limits for event logs where you want a rolling window of history. Use workqueue for task queues where each message should be processed exactly once and then gone. Use interest only when you can guarantee at least one consumer always exists, or you will silently drop messages on consumer restarts.

Consumer acknowledgment semantics require careful ack_wait tuning. If processing takes 10 seconds but ack_wait is 5 seconds, every message will be redelivered mid-processing. Set ack_wait to your 99th-percentile processing time plus a buffer. For long-running jobs, use msg.inProgress() to reset the timer without acknowledging.

Monitoring is done via the NATS HTTP monitoring endpoint (default port 8222). The /jsz endpoint returns JetStream stream and consumer state including pending message counts, consumer lag, and replication status. The /varz and /connz endpoints expose server resource usage and active connections. Integrate these into your observability stack, since NATS does not push metrics by default.

TLS and authentication are separate concerns in NATS. TLS encrypts the transport. Authentication uses NKeys (Ed25519 keypairs) or JWTs issued by an account server. For multi-tenant clusters, the accounts system isolates subject namespaces between tenants: a publisher in account A cannot reach a subscriber in account B unless explicit exports and imports are configured.

Tradeoffs Comparison

DimensionNATSKafkaRabbitMQRedis StreamsAmazon SQS
Core modelSubject-based pub/subPartitioned logExchange/queue routingAppend-only log per keyManaged queue
PersistenceOpt-in via JetStreamAlways (log on disk)Queue-level (optional)In-memory + AOF/RDBManaged (opaque)
OrderingPer-stream sequencePer-partitionPer-queue (FIFO)Per-stream sequenceBest-effort (FIFO queues optional)
Horizontal write scalingStream sharding (manual)Partition-level parallelismShovel/federationSingle shard per stream keyManaged, transparent
Consumer modelPush or pull, durable cursorsPull, consumer group offsetPush, ack-basedPull, consumer groupsPull, visibility timeout
ReplicationRaft, configurable R factorISR, configurable acksQuorum queues (Raft)Sentinel/ClusterManaged (3 AZ)
LatencySub-millisecond typicalLow single-digit msSub-millisecondSub-millisecondTens of milliseconds
Operational complexityLow (single binary)High (ZooKeeper or KRaft + brokers)Medium (Erlang runtime)Low (Redis dependency)Zero (fully managed)
Multi-tenancyAccounts with namespace isolationACLs on topicsVirtual hostsNone nativeIAM-based isolation
Sweet spotLow-latency pub/sub, IoT, microservice RPC, edge routingHigh-throughput event logs, stream processingComplex routing, legacy AMQP integrationSimple durable queues on existing RedisDecoupled microservices on AWS, minimal ops

Closing

NATS is a case study in the value of a well-defined scope. The core broker does one thing: route messages by subject as fast as possible. JetStream adds persistence and consumer tracking as a deliberate layer on top, not as an afterthought bolted to a routing engine that was not designed for it. The result is a system where you pay only for what you use. Running core NATS for request-reply and fan-out costs almost nothing in latency or memory. Adding JetStream with R=3 replication costs the same as running a small Raft group per stream.

The sharp edge is that this separation requires you to choose the right layer for each use case. Fire-and-forget pub/sub and durable event streaming behave differently and fail differently. Understanding both layers before reaching for JetStream in every situation is the difference between a well-designed deployment and a system that loses messages in ways that are hard to diagnose.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.