Architecture: NATS

Codelooru nats

A microservice publishes an order event. Three other services need to know about it: inventory has to decrement stock, billing has to charge the card, and shipping has to queue a label. In a lot of systems, that fan-out means standing up a broker, provisioning disks, tuning partitions, and running a coordination service alongside it. Then you check the memory footprint and realize the messaging layer is heavier than the services it connects.

NATS takes a different starting position. The core server is a single small binary with no external dependencies, built around a text protocol with only a handful of verbs. It routes messages between publishers and subscribers based on subject interest, and does so fast enough that the messaging layer stops being the thing you worry about.

That simplicity is a deliberate design stance, not a missing feature set. This post walks through how NATS is built: the core routing engine, the clustering model, the JetStream persistence layer, and how a message actually moves from publisher to durable consumer. Kafka shows up occasionally as a point of contrast, but the goal here is to understand NATS on its own terms.


The Big Picture

NATS is really two complementary systems sharing one binary. Core NATS is a pure publish-subscribe engine with at-most-once delivery: blazingly fast, holds almost no state, and forgets a message the instant it has been routed. JetStream is a persistence layer built on top, adding durable streams, replay, and stronger delivery guarantees.

The important part is that JetStream is not a separate product. It is built into the NATS server binary and enabled on a per-server basis, so both can coexist in the same cluster. You decide which nodes spend CPU, RAM, and disk on persistence, and which stay lean and handle only fire-and-forget traffic.

That layering is the mental model to hold onto for the rest of this post: a minimal routing core, with an optional consensus-backed storage system layered above it.

NATS: Core routing with an optional persistence layer CLIENTS Publishers Subscribers NATS CLUSTER (FULL MESH) Server 1 Core + JetStream Server 2 Core + JetStream Server 3 Core only PERSISTENCE JetStream Streams + Consumers Raft-replicated publish deliver Core NATS handles routing; JetStream-enabled servers add durable, replicated storage. A client connects to any one server and sees the whole cluster as a single entity.

Subjects and the Routing Core

Everything in NATS is addressed by a subject: a dot-delimited string like orders.us.created. Publishers send messages to a subject. Subscribers register interest in a subject, and the server delivers matching messages. There are no queues to declare up front and no topology to provision; a subject exists the moment someone publishes to it.

Subjects support wildcards, which is where the routing model gets expressive. A single token wildcard * matches one level, so orders.*.created catches orders.us.created and orders.eu.created. The full wildcard > matches one or more trailing tokens, so orders.> catches everything under orders. This lets consumers subscribe to broad or narrow slices of the traffic without any change on the publisher side.

The routing engine is deliberately stateless about message history. Core NATS supports at-most-once delivery, which means there is no need for consensus among servers in a cluster, keeping the design simple and the code fast. If a subscriber is not connected when a message is published, that message is gone. For real-time telemetry or presence updates, where a missed frame is irrelevant, this is exactly the tradeoff you want.

This is the sharpest contrast with a log-based broker like Kafka, where every message lands in a partitioned, replicated log by default. In Core NATS, the default is the opposite: nothing is stored, and interest drives delivery. Messages are only routed to servers that have clients interested in the subject, so traffic is not propagated across the network unnecessarily.

Subject-based routing with wildcards Publisher orders.us.created NATS Server subject matching Sub A orders.us.created Sub B orders.*.created Sub C orders.> One published message matches three different subscriptions. All three subscribers receive it.

Queue Groups

Plain subscriptions fan out: every interested subscriber gets every message. That is the wrong shape for work distribution, where you want each message handled once by one of several identical workers. NATS solves this with queue groups.

When multiple subscribers join the same queue group on a subject, the server delivers each message to exactly one member of the group, chosen at random. Add more workers and throughput scales; remove one and the rest absorb its share. There is no separate concept to configure: a queue group is just a name passed at subscribe time.

The elegant part is that fan-out and load balancing compose. A single subject can have several independent queue groups plus plain subscribers, and each queue group sees the full message flow while distributing internally. One event stream can simultaneously feed a load-balanced worker pool and a separate audit logger without any of them knowing about each other.


Clustering and the Full Mesh

A single server is a single point of failure, so production NATS runs as a cluster. Every server connects to every other server in a full mesh, with a one-hop routing maximum that guarantees messages never loop through the cluster. Servers speak a server-to-server protocol over TCP that propagates topology and subscription interest in real time.

Two ports define the topology. Each node exposes a client port, 4222 by default, for application connections, and a cluster port, typically 6222, for server-to-server communication. Clients only ever touch the client port; the cluster port is internal plumbing.

From an application's point of view, the cluster is one thing. An officially supported client only needs the address of a single server to connect, and it then receives the full cluster topology automatically. Because of this discovery mechanism, servers can be added or removed at runtime with zero downtime, and it is wise to give a client at least two seed server addresses so it can always find a way in.

For multi-region deployments, clusters themselves connect into a super cluster via gateway connections. A super cluster interconnects multiple clusters through gateway nodes and is optimized for spanning regions, with the same interest-driven routing keeping cross-region traffic to only what is actually needed on the other side.


JetStream: Streams

Core NATS gives you speed but no memory. The moment you need a message to survive a subscriber being offline, or you need to replay history, you enable JetStream. It uses a stream-first model: you create streams that capture messages on specific subjects, then create consumers that process them.

A stream is a durable store bound to one or more subjects. Once a stream is configured to capture orders.>, every message on a matching subject is persisted according to the stream's retention policy, regardless of whether any consumer is currently connected. Retention can be limits-based (age, size, or message count), interest-based, or work-queue style.

Storage is pluggable per stream: file-based for durability or memory-based for speed. The durability details matter here. JetStream flushes file writes to the OS synchronously, but under the default configuration it does not immediately fsync to disk; instead it uses a configurable sync_interval that defaults to two minutes. For streams that need the strongest durability, you set sync_interval to always so every write is fsync-ed, at some cost to throughput.

JetStream also supports moving and copying data between streams. Streams can be mirrored or set to source from one or more other streams, and can be placed on specific clusters or even specific servers using placement tags, for example to spread across availability zones. Streams can even be relocated between servers and clusters at runtime without interrupting availability.

Streams capture; consumers are independent cursors Publisher orders.created Stream: ORDERS subjects: orders.> Consumer: billing own ack cursor Consumer: shipping queue group 2 workers share load The stream stores messages once. Each consumer tracks its own position and acknowledgments.

JetStream: Consumers and Delivery Guarantees

If a stream is the stored log, a consumer is a stateful cursor over it. A consumer is a stateful view of a stream that tracks delivery and acknowledgment. Crucially, consumers are decoupled from the stream: this separation between capture and consumption lets multiple consumers process the same messages independently. Two services reading the same stream do not compete; each has its own position and its own acknowledgment state.

Delivery is acknowledgment-driven, which is what upgrades the guarantee. A negative acknowledgment via nak tells JetStream to redeliver immediately, and this is the at-least-once guarantee in action: a message may be delivered more than once if a service crashes mid-processing, but it will not be lost. A consumer that never acks a message will eventually see it redelivered.

Exactly-once is achievable but conditional. JetStream offers exactly-once publish and consume semantics when you combine publish deduplication with confirmed consumer acknowledgments. On the publish side, deduplication works through message IDs. When a duplicate window is configured, the stream tracks the Nats-Msg-Id header; if a message arrives within the window with a previously seen ID, the server returns an acknowledgment marked duplicate and does not store the message again.

Consumers come in two delivery shapes. A push consumer has the server actively send messages to a subject the client listens on. A pull consumer has the client request batches on its own schedule, which gives back-pressure control and is the usual choice for scalable worker pools. Combine a pull consumer with a queue group and you get horizontally scaled, load-balanced, durable processing.


Consistency: Raft Under JetStream

Persistence in a cluster means replication, and replication means agreement about ordering. This is where JetStream departs hardest from the stateless core. Clustered JetStream-enabled servers provide distributed, replicated, shared-nothing persistence using the Raft protocol.

NATS organizes this into layered Raft groups rather than one monolithic consensus group. All JetStream servers join a Meta Group that manages the JetStream API and elects a leader responsible for placement. Then, more granularly, each stream forms its own Raft group that synchronizes its data and state, and each consumer forms a Raft group that synchronizes consumer state. The consequence is direct: the elected stream leader handles acknowledgments, and if there is no leader, that stream will not accept messages.

The consistency guarantee is unusually strong for a messaging system. JetStream uses a NATS-optimized Raft quorum to keep immediate consistency rather than eventual consistency even during failures, and for writes to a stream the formal consistency model is linearizable.

Replication is per stream, set by a replicas factor. The maximum replication factor for a stream is five, and the practical guidance is to keep clusters small and odd. The recommendation is three or five JetStream-enabled servers, which balances scalability against fault tolerance; more than that yields little benefit while costing write performance.

A stream's Raft group: one leader, replicated writes, quorum acks Stream Leader zone A · handles acks Follower zone B Follower zone C replicate replicate quorum = 2 of 3 Losing any one zone still leaves a quorum, so the stream keeps accepting writes.

A Message's Lifecycle, End to End

Trace a single durable order event through the whole system to see the layers cooperate.

A producer calls the JetStream publish API for orders.created, attaching a Nats-Msg-Id header. The client library sends it over Core NATS to whichever server it is connected to. That server routes it to the current leader of the ORDERS stream's Raft group, since only the leader accepts writes for that stream.

The leader checks the deduplication window. If the message ID was seen recently, it returns a publish acknowledgment flagged as duplicate and stops. Otherwise it appends the message and replicates the write to its followers. Once a quorum has the write, the leader returns a positive publish acknowledgment to the producer. The producer now knows the message is durably stored, not merely accepted.

On the consuming side, the billing consumer pulls a batch. The server delivers the message and starts an acknowledgment timer. Billing charges the card and calls ack, advancing its cursor. Independently, the shipping consumer, a queue group of two workers, pulls the same message; one worker gets it, queues a label, and acks. If that worker had crashed before acking, the redelivery timer would fire and another worker would receive it, honoring at-least-once.

Two consumers, one stored copy, independent cursors, and a linearizable write path underneath. That is the whole system working together.


Failure Modes and Fault Tolerance

The failure behavior differs sharply between the two layers, and that is by design.

In Core NATS, a server crash is nearly a non-event for surviving traffic. Because the routing layer holds no message state, a client only needs one reachable server and receives the full topology, so it can reconnect and resubscribe elsewhere, and servers can leave or join at runtime with zero downtime. The cost is honest: any in-flight message with no connected subscriber at that instant is simply lost, which is the at-most-once contract.

In JetStream, a crash triggers Raft recovery. If a follower dies, the leader keeps serving as long as quorum holds. If the leader dies, the stream's Raft group elects a new one, and during that brief window the stream cannot accept writes, since a leaderless stream rejects messages. When the failed node returns, it automatically resynchronizes with the others in the cluster.

Quorum math drives the sizing. A quorum is half the cluster size plus one, so a three-node cluster needs two servers available to store new messages and a five-node cluster needs three. That is why placement matters: with five servers you would put two in one zone, two in another, and one in a third, so losing any single zone still leaves a quorum and the cluster keeps operating.

One honest caveat, grounded in independent testing. In December 2025, Jepsen published an analysis of JetStream version 2.12.1 and found that it could lose acknowledged writes when data files were truncated or corrupted on a minority of nodes, and that coordinated power failures, or an OS crash on a single node combined with network delays or process pauses, could cause loss of committed writes and persistent split-brain. Two file-corruption findings were stark: single-bit errors or truncation of a stream's block files, even on just one or two nodes out of five, could lose large windows of writes, and in one run corruption caused the loss of 679,153 acknowledged writes out of roughly 1.37 million. Corruption of snapshot files was worse still: a corrupted node could become metadata leader, decide a stream was orphaned, and delete all of its data across the cluster.

Two things keep this in proportion. First, Jepsen did not observe data loss under simple network partitions, process pauses, or plain crashes in that version; the failures required disk corruption or simulated OS-level crashes. Second, the write-loss finding traces substantially to the durability default discussed earlier: JetStream acknowledges a publish immediately but flushes to disk only every two minutes by default, so recently acknowledged writes may not yet be persisted. The vendor, Synadia, acknowledged the corruption and split-brain issues, noted the earlier stream-deletion bug was already fixed in 2.10.23, and has shipped further reliability work, with the filestore corruption fixes still in progress. The takeaway is not to avoid JetStream but to configure durability deliberately: raise the replication factor and set sync_interval to always for streams that genuinely cannot lose a write.


The Complete Picture

NATS end to end CLIENT LAYER Publishers Subscribers Queue-group workers JetStream consumers client port 4222 CORE NATS — FULL-MESH CLUSTER Server 1 subject routing Server 2 subject routing Server 3 subject routing cluster port 6222 JETSTREAM — RAFT-REPLICATED PERSISTENCE Meta Group API + placement Stream Groups data + ordering Consumer Groups cursor state Pluggable storage: file (durable) or memory (fast)

Read top to bottom, the shape of NATS is clear. Clients talk to any server on one port. The full-mesh core routes by subject interest and adds nothing it does not have to. Below that, an optional JetStream layer uses tiered Raft groups over pluggable storage to turn the same system into a durable, linearizable stream store, without changing how clients connect.


Summary

The single idea that makes NATS make sense is layering by cost. The core refuses to pay for anything it does not strictly need: no persistence, no consensus, no per-message state, which is exactly why it is fast and operationally trivial. Durability, ordering, and replay are real costs, so they live in a separate opt-in layer you enable only on the servers and streams that warrant them.

That is the deeper contrast with a log-first broker. Where Kafka makes the durable, partitioned log the default and builds outward, NATS makes lightweight routing the default and lets you buy persistence per stream. Neither is universally better; they encode different assumptions about what most of your messages need. NATS bets that a lot of traffic is ephemeral, and charges you for durability only where you actually ask for it.

One practical footnote follows from that design. Durability in JetStream is a dial, not a default: independent Jepsen testing showed acknowledged writes can be lost under correlated failures with the standard two-minute flush interval, so for streams that truly cannot lose data, raise the replica count and set sync_interval to always rather than trusting the throughput-tuned default.

Related on this blog: Architecture series



×