NATS: Inside a High-Performance Messaging System
An interactive exploration of how NATS connects applications, routes messages, distributes work, persists events, and keeps distributed systems communicating.
1. What is NATS?
NATS is an open-source messaging system. Applications connect to a NATS server and exchange messages by subject — a name such as orders.created — without knowing each other's network addresses . The server and the official clients are Apache 2.0 licensed, the server is written in Go , and NATS is a Cloud Native Computing Foundation incubating project . Synadia, founded by its creators, is a major contributor and maintainer ; in 2025 it agreed to assign the NATS trademarks to the Linux Foundation .
Two layers in one binary
- Core NATS moves messages from publishers to whoever is subscribed right now. It does not persist anything and delivers at most once .
- JetStream is built into the same server and adds streams that store messages, consumers that track delivery, acknowledgements and replay .
Clustering, JetStream, leaf nodes, MQTT and WebSocket are all configuration on one nats-server process . The latest server release when this study was researched was v2.15.1 (October 2026) .
Most of this page is interactive. Every simulation is deterministic and runs in your browser — it never connects to a NATS server. Jump to the playground to build your own topology.
Why it exists
The problem NATS addresses is coupling. If a service calls another by address, both have to know about each other, and adding a third consumer of the same event means changing the caller. With subject-based messaging, the publisher only names what happened; the server decides who receives it, based on interest registered at that moment .
What to expect from each layer
Storage — None; messages exist only in flight — Streams on file or memory; Delivery — At most once — At least once through consumers; Subscriber offline — Message is not delivered to it — Message waits in the stream; Publisher confirmation — None: fire and forget — A publish acknowledgement after storage
"Exactly once" is not a property of NATS alone. JetStream documents it as publish-side de-duplication plus a confirmed ("double") acknowledgement, and even then the application's own side effects need to be idempotent . Chapter 13 shows why.
2. NATS Architecture
The documented core components are the server, which routes messages, plus publishers, subscribers and subjects . Each client holds one long-lived TCP connection over which every publish and every subscription is multiplexed ; the default client port is 4222 .
What the server does
- Keeps an interest graph in memory. Every subscription adds an entry; an unsubscribe or a disconnect removes it .
- Matches subjects. Wildcard subscriptions are matched with a token trie, level by level, rather than by scanning every subscription .
- Delivers. For each published message, every matching subscription gets a copy and each matching queue group gets exactly one .
- Remembers nothing between connections. A reconnecting client is a new connection and re-sends its subscriptions .
Publishers do not need subscriber addresses, and subscribers do not need publisher addresses: both only talk to the server .
Topologies beyond one server
Building block — What it is — Port by convention; Cluster — Servers joined by routes in a full mesh; clients may connect to any member — 6222 (routes); Super-cluster — Clusters joined by gateways that only carry traffic with interest on the far side — 7222 (gateways); Leaf node — A server that connects outward to a remote NATS system and bridges subject interest — 7422 (leaf)
Only the client, monitoring and leaf-node ports are compiled-in defaults; 6222 and 7222 are configuration conventions .
Subjects are free
Subjects are not declared anywhere. A subject exists on the server only while something subscribes to it, so applications can use very many of them .
A worked example
An online shop's Order Service publishes orders.created. Fulfilment, Analytics and Audit each subscribe independently, so each gets a copy; nothing in Order Service changes when a fourth consumer is added . In the simulation above, Billing subscribes to payments.* and therefore never sees order events.
3. Client Connections
The client protocol is plain text, one command per line ending in CR LF . When a connection opens, the server speaks first: it sends INFO describing itself and what the client must do . The client replies with CONNECT, carrying its options and credentials , then sends PING; the server's PONG means the connection is established .
TLS, two ways
- Default. INFO is sent in plain text; if it says tls_required (or the client requires TLS), the client upgrades to TLS and only then sends CONNECT, so credentials never cross the wire unencrypted .
- TLS first (server 2.10.4+). With handshake_first on the server and the matching client option, the TLS handshake happens before INFO . Both sides must opt in, or the connection fails .
Keeping the connection alive
The server sends PING to an idle client every ping_interval (2 minutes by default) and allows ping_max (2) unanswered before closing the connection with -ERR 'Stale Connection' . Any traffic from the client counts as a heartbeat .
The commands
Command — Direction — Purpose; INFO — server → client — Server identity, limits (e.g. max_payload), security requirements; CONNECT — client → server — Options and credentials; PUB / HPUB — client → server — Publish, optionally with headers; SUB / UNSUB — client → server — Register or remove interest; MSG / HMSG — server → client — Deliver to one subscription; PING / PONG — both — Keep-alive; +OK / -ERR — server → client — Acknowledgement in verbose mode; errors
The lab's Protocol panel shows the exact wire form of every step, and its command reference explains each field.
Authentication
Credentials travel in CONNECT: a token, user and password, or for NKeys a signature over the nonce the server sent in INFO . A rejected client receives only "Authorization Violation" and the connection is closed; the reason is logged on the server .
Reconnection
Reconnection is on by default: the client re-dials, repeats the handshake and re-sends every subscription . Defaults differ by client — Go waits 2 s between attempts with up to 60 attempts, JavaScript allows 10 . Publishes made while disconnected go into a bounded in-memory buffer (8 MB in Go and Java) and are flushed after the subscriptions are restored . That buffer is not durable .
``go // Normally, the library will return an error when trying to connect and // there is no server running. The RetryOnFailedConnect option will set // the connection in reconnecting state if it failed to connect right away. nc, err := nats.Connect(nats.DefaultURL, nats.RetryOnFailedConnect(true), nats.MaxReconnects(10), nats.ReconnectWait(time.Second), nats.ReconnectHandler(func(_ *nats.Conn) { // Note that this will be invoked for the first asynchronous connect. })) if err != nil { // Should not return an error even if it can't connect, but you still // need to check in case there are some configuration errors. } `
`javascript const nc = await connect(opts); (async () => { console.info(connected ${nc.getServer()}); for await (const s of nc.status()) { switch (s.type) { case "disconnect": case "reconnect": console.log(s); break; default: // ignored } } })().then(); `
Code from the official nats.go v1.55.0 and nats.js v3.4.0 READMEs . nats.js reports lifecycle events through an async iterator rather than callbacks .
Shutting down cleanly
Flush sends PING and waits for PONG; because the server processes a connection's input in order, a returned flush means the server has every earlier publish — not that anyone received it . Drain` stops and empties subscriptions, then flushes publishes, then closes .
4. Publish–Subscribe
Every subscriber whose subscription matches gets its own copy of a message; the server does the fan-out, and subscribing does not consume anything . Publishing is fire-and-forget: the call returns without saying how many subscribers, if any, received the message .
Try it: disconnect the Order Processor, publish, and reconnect it. The message published while it was away is never delivered to it, because Core NATS keeps no copy for absent subscribers . Then change the subject to one nobody uses: the server silently discards the message — no error, no backlog .
What the engine does on each publish
- The publisher sends PUB with the subject and payload size .
- The server looks the subject up in its interest graph when the message arrives .
- Every matching plain subscription gets one MSG carrying that subscription's id; a connection with two matching subscriptions gets two .
- Nothing is retried, de-duplicated or stored .
Echo
By default a connection also receives its own messages on matching subscriptions; echo: false in CONNECT turns that off for the whole connection .
Message size
max_payload defaults to 1 MB; it can be raised to 64 MB, but more than 8 MB is not recommended . Official clients check it before sending and fail the publish locally, keeping the connection . Headers count against the same limit .
Code
``go import "github.com/nats-io/nats.go"
// Connect to a server nc, _ := nats.Connect(nats.DefaultURL)
// Simple Publisher nc.Publish("foo", []byte("Hello World"))
// Simple Async Subscriber nc.Subscribe("foo", func(m *nats.Msg) { fmt.Printf("Received a message: %s\n", string(m.Data)) }) `
`typescript // import the connect function from a transport import { connect } from "@nats-io/transport-deno"; // to create a connection to a nats-server: const nc = await connect({ servers: "demo.nats.io:4222" }); // create a simple subscriber and iterate over messages // matching the subscription const sub = nc.subscribe("hello"); (async () => { for await (const m of sub) { console.log([${sub.getProcessed()}]: ${m.string()}); } console.log("subscription closed"); })(); nc.publish("hello", "world"); nc.publish("hello", "again"); // we want to ensure that messages that are in flight // get processed, so we are going to drain the // connection. Drain is the same as close, but makes // sure that all messages in flight get seen // by the iterator. After calling drain, // the connection closes. await nc.drain(); `
From the nats.go and nats-core READMEs . In Node, the same connect comes from @nats-io/transport-node` .
A short-lived program that publishes and exits immediately can lose the message, because clients buffer writes in the background. Flush or drain before exiting .
5. Subjects and Wildcards
A subject is a string of tokens separated by .; each token is matched as a unit . Subjects are case-sensitive and may contain any UTF-8 except whitespace; letters, digits, - and _ are recommended .
Subscriptions can use two wildcards:
- ` matches exactly one token, in any position . orders..created matches orders.us.created but not orders.created or orders.us.west.created .
- > matches one or more trailing tokens and must be the last token . orders.> does not match orders — >` never matches zero tokens .
The matcher above is the same code the simulations use, applied token by token — never a substring comparison.
Edge cases the server enforces
- A subscription with > before the end, such as orders.>.created, is rejected with -ERR 'Invalid Subject' .
- Empty tokens and whitespace are rejected (orders..created, orders created) .
- A token like cre is not a wildcard: the server only treats ` and > specially when they are the whole token, so cre is accepted as a literal. The docs call such tokens invalid, so some clients may reject them .
- Wildcards are for subscribers. A publish to orders..created targets that literal subject, which wildcard subscribers still match .
- A space inside a subject breaks PUB parsing: the extra token is read as the reply subject. Most clients reject such subjects .
Reserved names
Subjects starting with $ are reserved — $SYS for the system, $JS for JetStream, $KV and $O for the key-value and object stores — and _INBOX is used for generated reply subjects . Applications should not build on $ subjects .
Designing a hierarchy
The guidance is to go from general to specific, stay under about 16 tokens and 256 characters, and be consistent . A hierarchy pays off three ways:
Use — Example; Selective consumers — Analytics subscribes to orders.>; Billing to orders.*.paid; Queue groups by area — A group subscribed with a wildcard load-balances across every subject it matches; Permissions — Allow orders.created for one service and deny orders.secret for another (chapter 19)
Subscribing to >` alone means interest in every subject the user is permitted to see .
6. Message Routing
Routing in NATS is driven by interest. The server looks up each published subject in its interest graph and sends a copy to every match, at the moment the message arrives . A match returns two sets: plain subscriptions, which each receive the message, and queue subscriptions grouped by queue name, of which one member per group is chosen .
In the simulation, Analytics holds two subscriptions that both match orders.created, so it receives two copies — matching is per subscription . The two billing workers share one queue group, so only one of them receives each message.
Across servers
Inside a cluster, servers exchange interest over routes: a route carries subscription interest one way and the matching messages the other . Interest is announced with RS+ and withdrawn with RS-; queue interest carries a weight, the number of members . Messages then flow only toward servers that advertised matching interest .
A message received over a route is delivered only to that server's own clients and is never forwarded over another route — the one-hop rule . That is why every server must be able to reach every other server directly . Chapter 15 lets you watch this across three servers.
Bookkeeping
- A SUB that reuses an active subscription id does not create a second subscription .
- When a connection closes, all of its subscriptions are removed in one batch .
- There is no limit on subscriptions per client by default .
Ordering
Messages from one publisher reach each subscriber in the order they were published; there is no ordering across publishers. The explicit statement of this exists only in the legacy FAQ, so this study classifies it as Historical . It follows from the server processing each connection's input in order .
7. Queue Groups
Subscribers that give the same queue name when they subscribe form a queue group. The server treats the group as one logical subscriber and delivers each message to exactly one member . No configuration is needed: the group exists as soon as its first member subscribes .
How a member is chosen
Members are chosen at random for each message — uniformly on a single server — not in round-robin, so short runs can look uneven . In the server source, the code picks a random starting point in the member list and delivers to the first member that can take the message . Selection ignores how busy a member is . The simulation uses a seeded random generator so that every replay makes the same choices; publish ten messages and watch the distribution panel.
Plain subscribers on the same subject still receive every message: in the simulation, Audit sees all of them while the workers share them .
The limitation: no redelivery
If the chosen worker receives a message and crashes before finishing it, the message is gone. Core NATS queue groups never redeliver; duplicates can only come from a publisher retrying . Use Failure injection → Worker A will crash mid-message and publish until Worker A is chosen. Durable work belongs in JetStream, where a consumer redelivers anything that was not acknowledged (chapter 13) .
Details that bite
- Groups are keyed by the exact name: a typo creates a second group and the work is done twice .
- Group selection happens after subject matching: a wildcard queue subscription load-balances across every subject it matches .
- A queue group does not keep order across members; work that must stay ordered needs a single subscriber .
In a cluster and beyond
In a cluster, routed queue interest carries a weight equal to the member count on the remote server . For a publish from a local client, the server's choice is roughly weighted-random across local and routed members — this study's reading of the source, not documented behaviour . Across a super-cluster, a message goes to another cluster only if no member is available in the local cluster .
Code
``go // All subscriptions with the same queue name will form a queue group. // Each message will be delivered to only one subscriber per queue group, // using queuing semantics. You can have as many queue groups as you wish. // Normal subscribers will continue to work as expected.
nc.QueueSubscribe("foo", "job_workers", func(_ *Msg) { received += 1 }) `
`typescript const nc = await connect( { servers: "demo.nats.io:4222", name: ${n} }, ); nc.closed() .then((err) => { if (err) { console.error( service ${n} exited because of error: ${err.message}, ); } }); // create a subscription - note the option for a queue, if set // any client with the same queue will be the queue group. const sub = nc.subscribe("echo", { queue: queue }); `
From the official nats.go and nats-core READMEs . In Python, the queue name is the second positional argument of subscribe` .
8. Request–Reply
Request–reply is ordinary publish–subscribe with one extra field. The requester subscribes to a unique reply subject — an inbox — and publishes the request with reply-to set; the responder publishes its answer to that subject . The reply subject travels to subscribers in the MSG line .
Current clients subscribe once to a wildcard inbox such as _INBOX.<connection>.* on the first request, and give each request its own last token; replies are matched back by that token .
When nobody answers
If a request matches no subscriber, the server immediately sends a header-only status message with NATS/1.0 503 to the inbox, so the client fails fast with a "no responders" error instead of waiting . This needs header support and the no_responders flag in CONNECT, which current clients turn on by default . Choose No responders in the simulation to see it, and Timeout (no-responders off) to see the slow alternative.
Exact server behaviour
The 503 is sent only when the message reached nobody, carried a reply subject, the requester set no_responders, and the requester holds a subscription matching the reply subject . If the reply-to points somewhere the requester is not subscribed, no 503 is produced .
Patterns
Pattern — Behaviour; One responder — request() returns the first reply and drops the rest; Queue-group responders — Exactly one instance answers each request — the usual way to scale a service; Scatter–gather — Every responder answers; the requester collects by count, deadline or sentinel
Waiting is not durability
Every request has a timeout; when it expires the call fails and a reply that arrives later is not delivered to the caller . Replies are at-most-once like everything else in Core NATS: a lost reply is not retried . Choose Slow responder in the lab and advance time: the request times out, the answer arrives later and is discarded. A timeout therefore does not tell you whether the work happened — make handlers idempotent before adding retries.
Code
``go nc, _ := nats.Connect(url) defer nc.Drain()
sub, _ := nc.Subscribe("greet.", func(msg nats.Msg) { // Parse out the second token in the subject (everything after greet.) // and use it as part of the response message. name := msg.Subject[6:] msg.Respond([]byte("hello, " + name)) })
rep, _ := nc.Request("greet.joe", nil, time.Second) fmt.Println(string(rep.Data))
// What happens if the service is _unavailable_? We can simulate this by // unsubscribing our handler from above. Now if we make a request, we will // expect an error. sub.Unsubscribe()
_, err := nc.Request("greet.joe", nil, time.Second) fmt.Println(err) `
`typescript import { connect } from "@nats-io/transport-deno"; import { NoRespondersError, RequestError, TimeoutError, } from "@nats-io/transport-deno"; const nc = await connect({ servers: demo.nats.io, }); try { const m = await nc.request("hello.world"); console.log(m.data); } catch (err) { if (err instanceof RequestError) { if (err.cause instanceof TimeoutError) { console.log("someone is listening but didn't respond"); } else if (err.cause instanceof NoRespondersError) { console.log("no one is listening to 'hello.world'"); } else { console.log( failed due to unknown error: ${(err.cause as Error)?.message}, ); } } else { console.log(request failed: ${(err as Error).message}); } } await nc.close(); `
`python import nats from nats.errors import TimeoutError, NoRespondersError
# ... sub = await nc.subscribe("greet.*", cb=greet_handler)
rep = await nc.request("greet.joe", b'', timeout=0.5) print(f"{rep.data}")
# What happens if the service is _unavailable_? We can simulate this by # unsubscribing our handler from above. Now if we make a request, we will # expect an error. await sub.drain()
try: await nc.request("greet.joe", b'', timeout=0.5) except NoRespondersError: print("no responders")
await nc.drain() `
The Go and Python examples come from NATS by Example; Go defines nats.ErrNoResponders and nats.ErrTimeout , and no official Go snippet checks them with errors.Is` . Default request timeouts differ: Go requires one, nats.js defaults to 1 s, nats-py to 0.5 s .
9. Core NATS Delivery Semantics
Core NATS is at most once. A subscriber that is connected and interested receives a message once; one that is absent, slow or disconnected receives it zero times, and there is no second attempt . Core NATS does not retry, does not detect duplicates, and does not guarantee that two subscribers see messages in the same order under load .
Take both consumers offline, publish twice, then bring them back and let the worker fetch. The Core subscriber never sees the two messages; the JetStream worker receives them from the stream .
Why at-most-once is a feature
Not every message deserves storage. For a live price feed, the next tick replaces the previous one, and redelivering a stale value would be worse than dropping it. Core NATS keeps messages only in memory and never writes them to disk ; the current docs describe it as best-effort at-most-once, with JetStream adding at-least-once and exactly-once .
What still is not guaranteed
- The client's reconnect buffer is process memory, not a delivery guarantee .
- Messages published while a subscriber is reconnecting find no interest and are dropped .
- For tightly coupled request–reply, the docs call relying on the messaging system to resend an anti-pattern: use Core NATS and let the caller decide .
Choosing
Need — Use; Live fan-out where only the latest value matters — Core publish–subscribe; A service call with a timely answer — Core request–reply; Work that must be done even if a worker is down — JetStream consumer; Events that must be replayable later — JetStream stream
A plain Core publish to a subject a stream captures is still stored, but the publisher gets no acknowledgement and cannot tell .
10. JetStream Architecture
JetStream is built into nats-server, not a separate process, and is enabled per server . Its model has three parts:
- Subjects. Publishers still address subjects, not streams .
- Streams bind subjects and capture every message published on them into storage .
- Consumers are stateful views of a stream that track what was delivered and acknowledged .
Two acknowledgements, two meanings
- Publish acknowledgement. After the stream stores a message, the server answers the publisher with a JSON acknowledgement carrying the stream name and sequence number . It proves storage — not that anyone received the message .
- Consumer acknowledgement. When a worker finishes processing, it acknowledges to the consumer, which records it and stops tracking the message .
In the simulation these are separate tokens on separate paths: the PubAck returns to the Order API, the +ACK goes from the worker to the consumer.
Management over messaging
The JetStream API is request–reply over Core NATS on $JS.API. subjects, with typed JSON responses . With a JetStream domain the prefix becomes $JS.<domain>.API . JetStream is multi-tenant per account .
Sequences
Stream sequence numbers start at 1, only increase and are never reused . Every write goes through the stream leader, which assigns the sequence . Consumers have their own sequence: every delivery attempt, including a redelivery, gets a new consumer sequence, while the stream sequence of a redelivered message stays the same .
Publishing to a subject no stream captures
A JetStream publish to a subject no stream captures fails at once with "no responders" (a 503 status), and nothing is stored . A publish that times out may still have been stored — the acknowledgement could have been lost .
Code
``go js, _ := jetstream.New(nc)
// Publish message on subject ORDERS.new // Given subject has to belong to a stream ack, err := js.PublishMsg(ctx, &nats.Msg{ Data: []byte("hello"), Subject: "ORDERS.new", }) fmt.Printf("Published msg with sequence number %d on stream %q", ack.Sequence, ack.Stream)
// A helper method accepting subject and data as parameters ack, err = js.Publish(ctx, "ORDERS.new", []byte("hello"))
// ... ack, err = js.Publish(ctx, "ORDERS.new", []byte("hello"), jetstream.WithMsgID("id")) `
`typescript // create the stream const jsm = await jetstreamManager(nc); await jsm.streams.add({ name: "a", subjects: ["a."] }); // create a jetstream client: const js = jetstream(nc); // publish a message received by a stream let pa = await js.publish("a.b"); // jetstream returns an acknowledgement with the // stream that captured the message, it's assigned sequence // and whether the message is a duplicate. const stream = pa.stream; const seq = pa.seq; const duplicate = pa.duplicate; `
In nats.go, the jetstream package is the current API; nc.JetStream() is marked legacy . In nats.js v3, JetStream is a separate module imported as jetstream(nc)` .
11. Streams and Storage
A stream's configuration decides what it keeps and for how long. Change the settings in the lab and publish: limits, discard policy and duplicate detection all act on real sequence numbers.
The settings
Setting — Meaning; Subjects — What the stream captures; defaults to the stream name if none are given; Storage — file (default) or memory; memory data does not survive a restart, and the type cannot be changed later; Limits — max_msgs, max_bytes, max_msgs_per_subject, max_msg_size — unlimited by default; Max age — 0 means unlimited; otherwise at least 100 ms; Discard — old (default) removes the oldest messages; new rejects the new one; Replicas — Up to 5; the docs recommend 3; Duplicate window — 2 minutes by default
Duplicate detection
A publisher can set the Nats-Msg-Id header. Within the duplicate window the stream checks only that ID, not the body . A duplicate is not stored; its publish acknowledgement says "duplicate": true with the original message's sequence . After the window, the same ID is stored again . The docs caution against large windows . Use Retry o-N (same Nats-Msg-Id) in the lab.
When a limit is hit
With discard: new, the publish returns an error: the store reports "maximum messages exceeded" , which reaches the publisher as a 503 error acknowledgement with error code 10077 — this study's reading of the server code . Discard only matters for streams with a limit .
Optimistic concurrency
Headers such as Nats-Expected-Last-Sequence and Nats-Expected-Last-Msg-Id make a publish conditional; on mismatch it is rejected (errors 10071 and 10070) .
Durability on disk
File stores persist to disk but by default fsync only every sync_interval, 2 minutes ; always fsyncs every message before acknowledging it . On a non-replicated stream an operating-system crash can therefore lose messages that were already acknowledged .
Mirrors, sources, key-value and objects
- A mirror copies exactly one stream asynchronously and keeps its sequence numbers; sources aggregate several streams and assign new sequences .
- A KV bucket is a stream named KV_<bucket> on $KV.<bucket>.>, and an Object Store bucket is a stream OBJ_<bucket> holding chunks and metadata .
Code
``go js, _ := jetstream.New(nc)
// create a stream (this is an idempotent operation) s, _ := js.CreateStream(ctx, jetstream.StreamConfig{ Name: "ORDERS", Subjects: []string{"ORDERS."}, })
// update a stream s, _ = js.UpdateStream(ctx, jetstream.StreamConfig{ Name: "ORDERS", Subjects: []string{"ORDERS."}, Description: "updated stream", })
// get stream handle s, _ = js.Stream(ctx, "ORDERS")
// delete a stream js.DeleteStream(ctx, "ORDERS") `
`typescript const jsm = await jetstreamManager(nc); // add a stream - jetstream can capture nats core messages const stream = "mystream"; const subj = mystream.*; await jsm.streams.add({ name: stream, subjects: [subj] }); // retrieve info about the stream by its name const si = await jsm.streams.info(name); // update a stream configuration si.config.subjects?.push("a.b"); await jsm.streams.update(si.config); ``
Creating a stream in nats.go is idempotent .
12. JetStream Consumers
A consumer is what turns a stream into delivered work: it tracks what it has delivered and what has been acknowledged, and it redelivers what was not acknowledged in time .
Pull and push
- A pull consumer delivers only when a client asks — a fetch request on $JS.API.CONSUMER.MSG.NEXT.<stream>.<consumer> with a batch size and expiry . Pull requests give natural one-to-one flow control .
- A push consumer delivers to a deliver_subject as messages arrive; setting that subject is what makes it push . MaxAckPending is then its consumer-level flow control .
The nats.go jetstream package recommends pull consumers for most uses and keeps push mainly for migration .
Durable or ephemeral
A consumer is durable only if Durable is set . Ephemeral consumers have no persisted state and are cleaned up after a period of inactivity, 5 seconds by default .
Where it starts and how fast it replays
- deliver_policy: all (default), last, new, by start sequence, by start time, or last per subject . It only picks the first message; after that the consumer delivers whatever it has not seen or acknowledged .
- replay_policy: instant (default) or original, which reproduces the original publish timing .
Acknowledgement policy
explicit (each message), all (acknowledging N covers everything before it) or none . The docs call explicit the default; at the wire level an omitted policy means none, so the explicit default comes from client libraries .
The state you can inspect
Consumer info reports delivered and ack_floor (each with a consumer and a stream sequence), pending acknowledgements, redeliveries, waiting pull requests and messages not yet consumed . The ack floor is the highest contiguous acknowledged message . The inspector in the lab shows the same fields.
Pull request outcomes
An empty fetch is normal: a no-wait pull with nothing available returns 404 No Messages, and an expired request returns 408 Request Timeout . By default a pull consumer accepts 512 waiting requests .
Code
``go // receive up to 10 messages from the stream msgs, err := c.Fetch(10) if err != nil { // handle error }
for msg := range msgs.Messages() { fmt.Printf("Received a JetStream message: %s\n", string(msg.Data())) }
if msgs.Error() != nil { // handle error }
// FetchNoWait will not wait for new messages if the whole batch is not available at the time of sending request. msgs, err := c.FetchNoWait(10) if err != nil { // handle error } `
`typescript for (let i = 0; i < 3; i++) { let messages = await c.fetch({ max_messages: 4, expires: 2000 }); for await (const m of messages) { m.ack(); } console.log(batch completed: ${messages.getProcessed()} msgs processed); } `
`python # Create pull based consumer on 'foo'. psub = await js.pull_subscribe("foo", "psub")
# Fetch and ack messagess from consumer. for i in range(0, 10): msgs = await psub.fetch(1) for msg in msgs: await msg.ack() print(msg) `
In Go, Fetch waits up to 30 s by default and is slower than Messages() or Consume() for continuous work . nats.js recommends next() or consume({ max_messages: 1 }) to scale out workers . nats-py has no continuous pull iterator; it loops over fetch()` .
13. Acknowledgements and Redelivery
Every message a consumer delivers carries an acknowledgement subject that encodes the stream, the consumer, the delivery count, the stream and consumer sequences, a timestamp and the number still pending: $JS.ACK.<stream>.<consumer>.<delivered>.<stream seq>.<consumer seq>.<timestamp>.<pending> . Acknowledging means publishing to that subject.
What the worker can answer
Body — Effect; +ACK (or empty) — Processed; stop tracking it; -NAK — Redeliver now (or after a delay, -NAK 10s); BackOff does not apply; +WPI — Still working: restart the ack timer from now; +TERM — Never redeliver; treated like an ack for tracking, with an optional reason; +NXT — Acknowledge and ask for more (pull consumers)
The timers
- AckWait — 30 s by default — is how long the consumer waits for an acknowledgement before redelivering .
- BackOff overrides AckWait with a list of delays: the n-th delivery waits BackOff[min(n−1, last)], and the last entry is reused .
- MaxDeliver caps attempts; unlimited by default .
- MaxAckPending caps unacknowledged messages, 1000 by default; at the cap no new messages are delivered, while redeliveries still go out first .
The seven situations in the lab
- Acknowledged — the normal path.
- Crash before acknowledging — the message stays pending; advance time and it comes back with delivery count 2.
- No acknowledgement — identical from the consumer's point of view.
- Redelivered to another worker — with a pull consumer you choose who fetches the redelivery.
- Ack lost: duplicate delivery — Worker 1 did the work but its acknowledgement never arrived, so Worker 2 does it again; the shared record shows it applied twice. A plain ack is fire-and-forget, and a lost ack means redelivery .
- Idempotent worker — the same failure, but Worker 2 checks the shared record and skips the side effect.
- MaxDeliver reached — after the last attempt the consumer stops, publishes one advisory and moves its ack floor past the message .
There is no dead-letter queue
JetStream has no built-in dead-letter queue . When MaxDeliver is reached, an advisory is published on $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<stream>.<consumer> with the stream sequence and delivery count ; the message stays in the stream, and on a WorkQueue stream it must be deleted manually . Parking failed messages is an application design: watch the advisory, fetch the message by sequence, store it where your team can inspect it.
Late acknowledgements and slow work
An acknowledgement that arrives after AckWait — even after the message was redelivered to someone else — is still honoured . That is exactly the case where work gets done twice: processing took longer than AckWait without +WPI . Order is also affected: a redelivery can arrive after later messages, and strict ordering needs MaxAckPending = 1 .
Code
``go // DoubleAck acknowledges a message and waits for ack reply from the server. // While it impacts performance, it is useful for scenarios where // message loss is not acceptable. DoubleAck(context.Context) error // Nak negatively acknowledges a message. This tells the server to // redeliver the message. // // Nak does not adhere to AckWait or Backoff configured on the consumer // and triggers instant redelivery. For a delayed redelivery, use // NakWithDelay. Nak() error // NakWithDelay negatively acknowledges a message. This tells the server // to redeliver the message after the given delay. NakWithDelay(delay time.Duration) error // InProgress tells the server that this message is being worked on. It // resets the redelivery timer on the server. InProgress() error // Term tells the server to not redeliver this message, regardless of // the value of MaxDeliver. Term() error `
`typescript ack(): void; nak(millis?: number): void; working(): void; term(reason?: string): void; ackAck(opts?: Partial<{ timeout: number }>): Promise<boolean>; `
These are the interface definitions from nats.go and nats.js; the official repositories have no usage examples for every variant . Python offers ack(), ack_sync(), nak(delay), in_progress() and term()` .
A "double ack" waits for the server to confirm the acknowledgement. It rules out redelivery caused by a lost ack — but a crash between doing the work and acknowledging it still redelivers, so idempotent processing remains necessary .
14. Retention and Replay
Retention decides when a stream deletes a message. It is related to consumer acknowledgements but is not the same thing.
Policy — A message is removed when…; Limits (default) — a limit (count, size, age) removes it; reads and acks change nothing; Interest — every consumer whose filter covers it has acknowledged it; WorkQueue — it is acknowledged once — by its single consumer
Limits and discard apply under every policy, so a limit can remove a message no consumer has received .
Interest retention, precisely
Interest counts only the consumers that exist at the time . A message published when no consumer's filter matches its subject is not kept: the server uses up the sequence number but stores nothing . So consumers must exist before you publish to an Interest stream . A stalled consumer holds up cleanup and can grow the stream to its limits .
WorkQueue rules
WorkQueue forbids overlapping consumers: a second unfiltered consumer, or overlapping filters, is rejected (error 10100) . Its consumers must deliver from the start and use explicit acknowledgements . Retention can be changed only between Limits and Interest .
Terminated and given-up messages
Under Limits retention a terminated message stays; on WorkQueue and Interest streams a +TERM removes it like an acknowledgement . A message that reached MaxDeliver still counts as needing that consumer's acknowledgement, so Interest and WorkQueue streams keep it .
Replay
Consumers choose where to start with deliver_policy (chapter 12). With replay_policy: original, a consumer delivers at the original publish rate — useful for replaying a recorded session .
Age limits
max_age removes messages older than the limit whether or not they were consumed; in the lab, set MaxAge to 2 minutes and advance time .
15. Clustering and Routing
A cluster is a set of servers joined by routes in a full mesh; clients may connect to any member . Servers discover each other by gossip: one seed route per server is enough, and the rest of the mesh completes itself .
Watch the sequence
- The subscriber on Server C subscribes, and Server C shares the interest with A and B over the routes .
- The publisher on Server A publishes.
- Server A looks up its interest table, finds that only Server C needs the message, and sends one copy over the A–C route; Server B gets nothing .
- Server C delivers to its local subscriber. It never forwards a route-received message to another route .
Publish a subject nobody wants and nothing crosses any route.
Route connections
Since v2.10, each pair of servers opens a pool of 3 route connections, each carrying a fixed subset of accounts, plus a dedicated route for the system account . If one pooled connection breaks, its accounts' traffic stops until it reconnects; it is not moved to the others . All servers must share the cluster name .
How clients learn the cluster
Servers tell clients about other members through connect_urls in INFO, and push an updated INFO when the cluster changes . Clients add those servers to their pool, so after a failure they can reconnect to a server they were never configured with . Use Stop Server B in the lab to watch the workers on B move.
Queue groups in a cluster
A queue message that arrives over a route goes to the receiving server's local members if it has any . For a message from a local client, the server starts at a random index over local members plus remote members repeated by their weight, which this study reads as roughly weighted-random across the cluster . Members inside the cluster are preferred over members attached through leaf nodes .
Partitions
If the route between A and B fails while both still reach C, A's publications do not reach B's subscribers through C — the one-hop rule means C only delivers to its own clients .
16. Raft and Replication
JetStream clustering uses a NATS-optimised Raft . Four separate things are easy to confuse:
Concept — What it is; NATS server clustering — Routes and interest; carries Core messages, needs no consensus; The meta group — A cluster-wide Raft group of the JetStream servers; its leader places streams and consumers; Stream replication — Each replicated stream has its own Raft group; Consumer state — Each replicated consumer also has its own Raft group, on the stream's servers
Only the leader accepts new entries and replicates them . A write commits once a majority of the stream's replicas hold it — for R3, the leader plus one follower — and the publish acknowledgement is sent only after that commit .
Quorum and elections
Quorum is computed from the configured replicas, not the live ones: N/2+1 . Without a majority no leader can be elected and the stream does not accept messages . When a leader dies, the survivors elect a new one if they still form a quorum . A peer votes only for a candidate whose log is at least as up to date as its own . Leaders send heartbeats about once a second, and election timers are randomised between 4 and 9 seconds — so writes are refused for seconds during an election and clients should retry . A leader that cannot hear from a majority for 10 seconds steps down .
The simulation draws leadership, replication and commit at the level the documentation describes. It does not draw individual Raft messages, and elections complete in one step rather than seconds.
Sizing
Replicas — Tolerates; R1 — No server loss; R2 — No more than R1: losing one copy blocks writes; R3 — One loss; R5 — Two losses (maximum is 5)
Use odd cluster sizes — four servers tolerate the same single failure as three — and spread them across zones . unique_tag forces replicas onto servers with different tag values such as availability zones .
Commit is not fsync
Quorum commit does not imply the data is on disk: fsync happens every sync_interval (2 minutes) unless set to always . Several faults inside that window — operating-system crashes plus a partition — can still make replicas diverge .
Consistency
Writes are linearizable; read-your-writes is not guaranteed, because direct reads may be served by followers or mirrors .
17. Gateways and Superclusters
A super-cluster joins independent clusters with gateways, which carry only traffic that has interest on the far side . Each server opens one outbound gateway connection to each other cluster , and the gateway name must equal the cluster name .
Publish orders.created in east: it crosses one gateway to west because Reporting subscribes there, and west-1 routes it on to west-2 . Publish a render job: the east worker takes it, because a queue group is served in the local cluster first; disconnect the east worker and the job crosses to the west .
Interest-only mode
The protocol reference still describes an "optimistic" starting mode, but since server 2.9.0 every account runs in interest-only mode: the receiving cluster sends all of its subscription interest, and a message is sent only when interest is known .
Choosing a remote cluster
When several remote clusters have members of a queue group, exactly one is chosen and the message crosses one gateway ; gateways are kept ordered by lowest round-trip time, which is the order used for that choice . A gateway send starts from where a message entered the cluster, so a message crosses at most one gateway plus at most one route .
Replies across clusters
Reply subjects are prefixed with _GR_.<cluster hash>.<server hash>. so the reply can go straight back to the origin cluster . The exact conditions for adding the prefix were not analysed .
JetStream in a super-cluster
A stream and all of its replicas live in a single cluster — by default the one the client is connected to . Every server in a super-cluster shares the same JetStream domain . Clusters must have distinct names .
18. Leaf Nodes
A leaf node is a server that connects outward to a remote NATS system and bridges subject interest; the hub never dials the leaf, so only outbound connectivity is needed . A leaf bridges only subjects with interest on the other side, in both directions .
The factory dashboard subscribes locally, so temperature readings reach it without leaving the factory. Cloud analytics subscribes to factory.*.temp, so temperature crosses the uplink but vibration does not. Cut the uplink: the factory keeps working locally, but readings published meanwhile never reach the cloud .
Authentication and accounts
Clients on the leaf are authenticated by the leaf's own policy . The hub binds the leaf connection to an account determined by the credentials the leaf presents, and the leaf user's permissions decide what flows each way . The hub sees one leaf connection, not the leaf's clients .
Not a replication mechanism
A leaf connection does not store anything. Core messages published while the link is down are not queued for later — this study's inference from Core NATS being at-most-once . For data that must survive outages:
- mirror a hub stream into the leaf's domain to push data to the edge, or source edge streams into the hub to aggregate them ;
- mirroring is store-and-forward and resumes from the last stored sequence after an outage .
A leaf that runs its own JetStream needs a separate domain, and each domain has its own meta group .
Topology rules
Leaf links can form trees — a leaf can also be a hub — and loops are detected and closed . Queue semantics hold across leaf connections, with local members served first .
19. Security
NATS security has three layers: who may connect, which account they belong to, and which subjects they may use.
Permissions
Writing an allow list implicitly denies everything not on it; when a subject matches both allow and deny, deny wins . A denied publish is dropped and the server replies with an asynchronous error while the connection stays open :
``text -ERR 'Permissions Violation for Publish to "payments.completed"' ``
A denied subscription gets the matching "Permissions Violation for Subscription to" error .
Accounts
Accounts are isolated subject namespaces: the same subject string in two accounts names two different subjects . The boundary is absolute unless crossed by an explicit export and import . In the lab, Team B's publication never reaches the default account, and Client A's never reaches Team B.
Transport security
TLS is configured per connection type: client TLS does not secure routes, gateways or leaf nodes . Mutual TLS (verify) rejects clients whose certificate does not chain to the configured CA, and verify_and_map maps a certificate identity to a user . Chapter 3 covers TLS-first.
Authentication methods
Token, user and password (bcrypt allowed), TLS certificate, NKey challenge, decentralized JWT and auth callout are all supported .
- NKeys (Ed25519): the client signs a fresh, per-connection nonce from INFO, so no secret crosses the wire and replays fail .
- Decentralized JWT: an operator signs accounts and accounts sign users; on connect the server checks the user JWT, fetches the issuing account JWT and verifies the operator signature .
- Auth callout (2.10+) delegates authentication to your own service on $SYS.REQ.USER.AUTH, which answers with a signed user JWT .
Sharing across accounts
Sharing needs both halves: an import with no matching export is rejected . Stream exports publish one way; service exports let other accounts send requests that the exporting account answers .
Subtleties
- A wildcard subscription that overlaps a deny is accepted, and the denied subjects are silently filtered out at delivery .
- allow_responses lets a responder publish to a request's reply subject even outside its publish allow list .
- The monitoring port has no authentication by default .
- Authentication failures publish a system event on $SYS.SERVER.<id>.CLIENT.AUTH.ERR .
The lab shows permissions as configured on users. Credentials are never shown, and the account boundary is drawn conceptually; the error lines are the exact wire text.
20. Flow Control and Backpressure
A subscriber that cannot keep up is a slow consumer. Core NATS and JetStream handle it differently, and Core NATS has two different failure points.
Where — What happens; Client library — Each subscription has a pending limit; on overflow the library drops the new message and raises one slow-consumer error, while the subscription and connection stay up; Server — If a connection's unsent bytes would exceed max_pending (64 MB by default) the server closes it; the client then reconnects; JetStream consumer — Delivery stops at MaxAckPending; undelivered messages wait in the stream
The lab's Core mode shows the client-library case with a deliberately small limit; real defaults are far larger — 500,000 messages or 64 MB in Go .
Inside the server
- Write deadline. A single write that blocks longer than write_deadline (10 s by default) also closes the connection as a slow consumer .
- Fast-producer stall. Once a consumer passes 75% of max_pending, producers sending to it are slowed by a few milliseconds per message; no_fast_producer_stall drops messages to the slow consumer instead .
- Counting. /varz counts slow consumers, and closed connections record the reason, such as "Slow Consumer (Pending Bytes)" .
The protocol reference still says the slow-consumer threshold is 10 MB and that the server sends -ERR 'Slow Consumer'. The v2.15.1 code uses 64 MB and closes the connection without that error string .
The fix is usually more consumers
A larger buffer only delays the problem; more members in a queue group spread the work .
JetStream flow control
JetStream decouples producers from consumers: flow control is between each client and the server, not end to end . Publishers get implicit flow control by waiting for publish acknowledgements; asynchronous publishes whose acknowledgements are never checked are effectively lost writes . Push consumers can add a byte-window flow control, using 100 FlowControl Request status messages that the client must answer, and idle heartbeats so clients can tell the server is alive . The docs advise a high MaxAckPending for throughput and a lower one, with AckWait adjusted, for slow processing .
21. Failure Scenarios
Pick a failure. The lab builds the configuration stated under Initial state, triggers the failure and plays out what the documentation says happens next. Each scenario lists the message status, the delivery guarantee, the risk and the mitigation.
Outcomes depend on configuration: replica counts, retention, discard policy, client options. Each scenario states its assumptions; a different deployment can behave differently.
Server failures
When a cluster server goes down, its clients reconnect to another member and re-establish their subscriptions . For planned maintenance, lame-duck mode closes the listener, transfers Raft leadership, tells clients with INFO ldm:true and then closes connections gradually — a 10-second grace period and a 2-minute duration by default . During rolling upgrades, wait for /healthz to return 200 on each node before moving on .
Editorial: when NATS servers run as Pods on Kubernetes, noticing a dead machine and starting a replacement server is Kubernetes' job, with its own detection delays. The Kubernetes deep dive walks through them.
JetStream under failure
- A publish in flight when the leader crashes, not yet on a majority, is lost and never acknowledged; the client must retry .
- Leader elections can briefly cause "no responders" for JetStream publishes; the Go client retries twice by default .
- On the minority side of a partition no leader can be elected, and a stranded leader steps down after about 10 seconds .
- What a publisher sees when the leader dies after appending but before commit is not documented .
Storage
When stream limits are reached, Discard old removes the oldest and Discard new rejects the new message . When server storage runs out, publishes fail with resource errors such as "insufficient resources" , and a server out of disk emits an advisory .
Losing the meta group
If the JetStream meta group loses its quorum, no leader exists and stream edits and removals fail . Server 2.15 adds nats server cluster rescue, which temporarily lowers the meta quorum; the docs warn it risks split-brain and is only for servers that will never return .
22. Performance and Observability
NATS servers expose read-only JSON over HTTP when monitoring is enabled — http_port: 8222 by convention; it is off by default . Each request returns a snapshot; nothing is pushed .
Endpoint — Shows; /varz — Server state: connections, totals, slow consumers; /connz — Per-connection detail including pending bytes and subscriptions; /subsz — The subscription routing structure, including queue groups; /routez, /gatewayz, /leafz — Routes, gateways, leaf connections; /jsz — JetStream; read it from the stream leader for accuracy; /raftz — Raft term, leader and peers; /healthz — 200 when healthy, 503 when not
Beyond HTTP
- The same data is available as system services: $SYS.REQ.SERVER.PING.<endpoint> collects answers from every server .
- System events — connects, disconnects, statistics, authentication errors — are published on the system account .
- JetStream advisories under $JS.EVENT.ADVISORY.> include an event whenever a stream or consumer leader changes .
- nats server report jetstream shows the meta group and its leader; there are also reports for routes, gateways and leaf nodes .
- prometheus-nats-exporter turns one node's monitoring JSON into Prometheus metrics; nats-surveyor polls every server through the system account .
About performance numbers
This study quotes no throughput or latency figures. The official benchmark page labels its numbers as examples from one laptop, and no official, general performance figures were found . Measure your own workload with nats bench on your own hardware.
The monitoring port has no authentication by default; do not expose it publicly .
23. Real-World Architecture Patterns
Editorial. These scenarios apply the documented behaviour from earlier chapters to common designs. They are illustrations, not descriptions of any particular company's system.
A — E-commerce order events
Order Service publishes orders.created. Notification and Analytics subscribe directly: if one is briefly down, a missed event is tolerable, so Core publish–subscribe fits (chapter 4). Inventory and Fulfilment must handle every order, so they consume from a stream ORDERS with durable consumers and explicit acknowledgements (chapters 11–13).
B — Trading market data
A market-data publisher sends market.nse.reliance.ticks to a price dashboard, a strategy engine and monitoring. Live prices are a natural fit for Core NATS fan-out: a missed tick is superseded by the next, and redelivering a stale price is worse than skipping it (chapter 9). Throughput claims are deliberately absent — measure your own.
C — Trading order processing
Order instructions are different: losing one is unacceptable and executing one twice is worse. Publish with a stable Nats-Msg-Id and retry until acknowledged; consume with explicit acks, MaxDeliver and BackOff; make the handler idempotent (chapter 13).
More on scenario C
An acknowledgement does not mean a financial transaction completed. It only tells the consumer to stop redelivering. Duplicates can still reach the handler after a lost ack or a crash before the ack .
Patterns that close the gap (editorial):
- Idempotency key. Record each order ID in the same database transaction that applies its effect; skip when it is already present.
- Outbox. Write the business change and the event to publish in one database transaction, then publish from the outbox with a stable Nats-Msg-Id, so a crash between the two cannot lose the event .
- Reconciliation. Periodically compare the trading ledger with the stream and the exchange's records; messaging does not replace this.
D — IoT telemetry
Each site runs a leaf node; sensors publish locally and the cloud subscribes to what it needs (chapter 18). Readings that must reach the cloud even through outages go into a local stream that the hub sources or mirrors .
E — Microservice RPC
An API gateway calls backend services with request–reply. Each service runs as a queue group, so every request goes to one instance ; no-responders makes a missing service fail fast ; timeouts are sized to the service and retries require idempotent handlers .
Build your own
The playground above is a full simulation: add publishers, subscribers and queue members, switch JetStream on, choose a retention policy and worker behaviour, publish with headers, and disconnect anything. It never connects to a real NATS server.
24. NATS vs Other Messaging Systems
Editorial comparison of architecture and operating model. Facts about each system come from its own documentation; no benchmark figures are used. Core NATS and JetStream are listed separately because they behave differently.
Model — Subject-based pub/sub, request–reply, queue groups — Streams capture subjects; consumers track delivery — Partitioned topics; events kept after consumption — Exchanges route to queues via bindings — Topics with exclusive, shared, failover or key_shared subscriptions — An append-only log data type with consumer groups; Persistence — None — File or memory — Filesystem and OS page cache — Durable queues plus persistent messages; streams — BookKeeper ledgers — Memory with AOF/RDB persistence; Delivery — At most once — At least once; exactly-once via dedupe plus double ack — At most / at least / exactly once — At least once with acks — Exactly once via idempotent producer or transactions — At least once; Competing consumers — Queue groups, random member — Shared pull consumer — One consumer per partition in a group ; share groups — Round-robin on a queue — Shared subscription, round-robin — Consumer groups with a pending entries list; Replication — — — Raft per stream, quorum commit — In-sync replicas — Quorum queues on Raft — BookKeeper, broker geo-replication — Asynchronous replication
Things the comparison table cannot say
- Exactly-once has narrow scopes everywhere. Kafka's covers read–process–write within Kafka ; RabbitMQ calls its stream mode "effectively-once" and avoids the term exactly-once ; Pulsar's needs an idempotent producer on one partition or transactions ; NATS's needs a stable message ID, a duplicate window and confirmed acks — and an idempotent handler .
- Ordering differs. Kafka orders within a partition ; Pulsar per key or per producer ; a NATS queue group does not preserve order across members .
- Redis Streams durability depends on configuration. Replication is asynchronous and a failover can lose writes; the Redis docs ask for AOF with a strong fsync policy when persistence matters .
The vendor comparison page
NATS publishes its own comparison table . It is vendor-authored and partly out of date: it still describes Kafka as coordinated through ZooKeeper, which Kafka 4.0 removed , and says RabbitMQ has no replay, although RabbitMQ Streams support it .
Choosing (editorial)
- Many services exchanging small messages, request–reply and fan-out, with optional persistence in the same system: NATS.
- A high-volume, long-retention event log with partition-ordered consumers and a large ecosystem of connectors: Kafka.
- Rich routing rules and classic work queues: RabbitMQ.
- Multi-tenant, tiered storage and geo-replication built in: Pulsar.
- Lightweight streams inside an existing Redis deployment: Redis Streams.
Bit Culled has a Redis profile; the other systems do not yet have catalogue entries.
25. Practical Implementation
Current client versions when this study was researched: nats.go v1.55.0, nats.js v3.4.0 (@nats-io/transport-node, @nats-io/jetstream) and nats-py 2.16.0, against nats-server v2.15.1 . The old nats npm package is deprecated . The examples below are copied from each project's official README or examples; they have not been altered.
Connect, publish, subscribe, request, drain
``go import "github.com/nats-io/nats.go"
// Connect to a server nc, _ := nats.Connect(nats.DefaultURL)
// Simple Publisher nc.Publish("foo", []byte("Hello World"))
// Simple Async Subscriber nc.Subscribe("foo", func(m nats.Msg) { fmt.Printf("Received a message: %s\n", string(m.Data)) })
// Responding to a request message nc.Subscribe("request", func(m nats.Msg) { m.Respond([]byte("answer is 42")) })
// Requests msg, err := nc.Request("help", []byte("help me"), 10*time.Millisecond)
// Drain connection (Preferred for responders) // Close() not needed if this is called. nc.Drain() `
`python # Use queue named 'workers' for distributing requests # among subscribers. sub = await nc.subscribe("help", "workers", help_request)
# Send a request and expect a single response # and trigger timeout if not faster than 500 ms. try: response = await nc.request("help", b'help me', timeout=0.5) print("Received response: {message}".format( message=response.data.decode())) except TimeoutError: print("Request timed out")
# Remove interest in subscription. await sub.unsubscribe()
# Terminate connection to NATS. await nc.drain() ``
JetStream end to end (Go)
``go // In the jetstream package, almost all API calls rely on context.Context for timeout/cancellation handling ctx, cancel := context.WithTimeout(context.Background(), 30time.Second) defer cancel() nc, _ := nats.Connect(nats.DefaultURL)
// Create a JetStream management interface js, _ := jetstream.New(nc)
// Create a stream s, _ := js.CreateStream(ctx, jetstream.StreamConfig{ Name: "ORDERS", Subjects: []string{"ORDERS."}, })
// Publish some messages for i := 0; i < 100; i++ { js.Publish(ctx, "ORDERS.new", []byte("hello message "+strconv.Itoa(i))) fmt.Printf("Published hello message %d\n", i) }
// Create durable consumer c, _ := s.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{ Durable: "CONS", AckPolicy: jetstream.AckExplicitPolicy, })
// Get 10 messages from the consumer messageCounter := 0
// Receive messages continuously in a callback cons, _ := c.Consume(func(msg jetstream.Msg) { msg.Ack() fmt.Printf("Received a JetStream message via callback: %s\n", string(msg.Data())) messageCounter++ }) defer cons.Stop() `
`typescript const messages = await c.consume({ max_messages: 10 }); // this rate limiter is just example code, do not use in production const rl = new SimpleMutex(5); async function schedule(m: JsMsg): Promise<void> { // pretend to do work await delay(1000); m.ack(); console.log(${m.seq}); } for await (const m of messages) { // block reading messages until we have capacity await rl.lock(); schedule(m) .catch((err) => { console.log(failed processing: ${err.message}); m.nak(); }) .finally(() => { // unblock, allowing a lock to resolve rl.unlock(); }); } `
Every API call in the Go jetstream package takes a context . Consume() and Messages() pre-buffer pull requests, and Stop() must always be called . For slow work-queue processing, fetch one message at a time with Messages(jetstream.PullMaxMessages(1)) to avoid redeliveries .
Graceful shutdown (Go)
`go cc, err := cons.Consume(func(msg jetstream.Msg) { fmt.Println(string(msg.Data())) msg.Ack() }, jetstream.ConsumeErrHandler(func(consumeCtx jetstream.ConsumeContext, err error) { fmt.Println(err) })) if err != nil { log.Fatal(err) } defer cc.Stop()
sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig `
Consume and Messages report an error after two missed idle heartbeats, visible through ConsumeErrHandler . Connection Drain drains subscriptions, then publishers, then closes; it is the preferred shutdown for responders .
What happens inside, step by step
Code — Protocol and server effect; nats.Connect — TCP, INFO, CONNECT, PING/PONG (chapter 3); nc.Subscribe — SUB with a client-chosen sid; the server adds interest; nc.Publish — PUB; the server matches subjects and delivers MSG to each subscription; nc.Request — First call subscribes to a wildcard inbox; publish with reply-to; js.Publish — A publish whose reply carries the PubAck after storage; msg.Ack() — A publish to the message's $JS.ACK… subject
Checklist (editorial)
- Name every connection; nats.js recommends a unique client name .
- Handle disconnect, reconnect and async errors (slow consumers, permission violations) explicitly.
- Use JetStream for anything that must not be lost; check every PubAck.
- Prefer pull consumers; set AckWait to your real processing time, use +WPI for long work, and set MaxDeliver.
- Make handlers idempotent. Add a stable Nats-Msg-Id` to retried publishes.
- Run three or five servers for JetStream; use R3 for important streams.
- Enable monitoring on a private network and alert on slow consumers and leader changes.
Technologies
- nats-server: The single binary that provides Core NATS, JetStream, clustering, gateways, leaf nodes, MQTT and WebSocket.
- JetStream: Persistence built into nats-server: streams, consumers, acknowledgements, replication; also the base of the key-value and object stores.
- nats.go: Official Go client; its jetstream package is the current JetStream API.
- nats.js: Official JavaScript/TypeScript client (v3: @nats-io/transport-node, @nats-io/jetstream and other modules).
- nats-py: Official Python asyncio client.
- Redis Streams: Compared in chapter 24: an append-only log data type in Redis with consumer groups.
Sources
- Client (protocol reference) — NATS docs (nats.docs.v2), undated (current docs)
- Subjects & wildcards — NATS docs, undated (current docs)
- Request-reply (Core NATS deep dive) — NATS docs, undated (current docs)
- Queue groups (Core NATS deep dive) — NATS docs, undated (current docs)
- Scatter-gather — NATS docs, undated (current docs)
- Message headers — NATS docs, undated (current docs)
- Connection lifecycle — NATS docs, undated (current docs)
- Resilient clients → Connecting — NATS docs, undated (current docs)
- Reconnection — NATS docs, undated (current docs)
- Slow Consumers — NATS docs, undated (current docs)
- TLS & Auth — NATS docs, undated (current docs)
- Client Protocol (legacy page, superseded repo) — NATS docs (legacy), undated (superseded)
- Drain & Shutdown — NATS docs, undated (current docs)
- Server configuration reference (v2.15, incl. per-key pages max_payload, max_pending, write_deadline, write_timeout, ping_interval, ping_max, no_fast_producer_stall, http_port, authorization/timeout) — NATS docs, undated (current docs, version 2.15)
- handshake_first — NATS docs, undated (current docs, version 2.15)
- Server Monitoring (reference) — NATS docs, undated (current docs)
- Monitoring endpoints — NATS docs, undated (current docs)
- TLS (legacy page; "TLS-first Handshake" section) — NATS docs (legacy), undated (superseded)
- FAQ (legacy; old URL now redirects away) — NATS docs (legacy), undated (superseded)
- What is NATS (legacy) — NATS docs (legacy), undated (superseded)
- Queue Groups (legacy) — NATS docs (legacy), undated (superseded)
- What is NATS? — NATS docs, undated (current docs)
- ADR-4 NATS Message Headers — NATS maintainers, 2021-05-12 (rev. 2025-05-15)
- ADR-40 NATS Connection — NATS maintainers, 2023-10-12 (rev. 2025-11-05)
- ADR-47 Request Many — NATS maintainers, 2024-09-26
- nats-server client.go — nats-io, v2.15.1 (2026-10-09)
- nats-server const.go — nats-io, v2.15.1 (2026-10-09)
- nats-server opts.go — nats-io, v2.15.1 (2026-10-09)
- nats-server server.go — nats-io, v2.15.1 (2026-10-09)
- nats-server sublist.go — nats-io, v2.15.1 (2026-10-09)
- Publish-Subscribe — NATS docs, undated (current docs)
- nats-server repository (README, GitHub API metadata: license, language, latest release) — nats-io, v2.15.1 released 2026-10-09
- About NATS — NATS Authors, undated (current site)
- CNCF and Synadia Align on Securing the Future of the NATS.io Project — CNCF, 2025-05-01
- Request-Reply Resilience — NATS docs, undated (current docs)
- doc-versions.json (reference version mapping) — nats-io, undated
- nats-server monitor.go — nats-io, v2.15.1 (2026-10-09)
- Subjects — NATS docs, undated (current docs)
- Queue Groups — NATS docs, undated (current docs)
- Request-Reply — NATS docs, undated (current docs)
- Connecting (Core NATS deep dive) — NATS docs, undated (current docs)
- Publish-subscribe (Core NATS deep dive) — NATS docs, undated (current docs)
- Configuring JetStream (legacy) — NATS / Synadia, n.d. (repo HEAD 2026-08-24)
- JetStream (Core Concepts, live site) — NATS / Synadia, n.d. (site v2.15 latest)
- Ack responses and redelivery — NATS / Synadia, n.d. (site v2.15 latest)
- Delivery and acknowledgment — NATS / Synadia, n.d. (site v2.15 latest)
- Retention policies — NATS / Synadia, n.d. (site v2.15 latest)
- Publishing — NATS / Synadia, n.d. (site v2.15 latest)
- Advanced publishing — NATS / Synadia, n.d. (site v2.15 latest)
- Pull consumers — NATS / Synadia, n.d. (site v2.15 latest)
- A pool of workers — NATS / Synadia, n.d. (site v2.15 latest)
- Create Stream (API reference, JSON schema) — NATS / Synadia, n.d. (v2.15 latest)
- Publish Acknowledgement (API reference) — NATS / Synadia, n.d. (v2.15 latest)
- Get Next Message (API reference) — NATS / Synadia, n.d. (v2.15 latest)
- Consumer Info (API reference) — NATS / Synadia, n.d. (v2.15 latest)
- Max Deliveries Exceeded advisory — NATS / Synadia, n.d. (v2.15 latest)
- Message Terminated advisory — NATS / Synadia, n.d. (v2.15 latest)
- Consumers (legacy docs page source) — NATS / Synadia, n.d. (repo HEAD 2026-08-24)
- Server Out of Space advisory — NATS / Synadia, n.d. (v2.15 latest)
- ADR-1 JetStream JSON API Design — nats-io, 2020-04-30
- ADR-8 JetStream based Key-Value Stores — nats-io, 2021-06-30
- Headers (legacy docs page source) — NATS / Synadia, n.d. (repo HEAD 2026-08-24)
- ADR-20 JetStream based Object Stores — nats-io, 2021-11-03
- nats-server v2.15.1 server/stream.go — nats-io, 2026-10-09
- nats-server v2.15.1 server/consumer.go — nats-io, 2026-10-09
- nats-server v2.15.1 server/store.go — nats-io, 2026-10-09
- nats-server v2.15.1 server/jetstream_api.go — nats-io, 2026-10-09
- nats-server v2.15.1 server/jetstream_errors_generated.go — nats-io, 2026-10-09
- nats-server v2.15.1 server/feature_flags.go — nats-io, 2026-10-09
- JetStream Model Deep Dive (legacy; was docs.nats.io/using-nats/developer/develop_jetstream/model_deep_dive) — NATS / Synadia, n.d. (repo HEAD 2026-08-24)
- Developing with JetStream (legacy; was docs.nats.io/using-nats/developer/develop_jetstream) — NATS / Synadia, n.d. (repo HEAD 2026-08-24)
- NATS API Reference / JetStream wire API Reference (legacy) — NATS / Synadia, n.d. (repo HEAD 2026-08-24)
- Source and Mirror Streams (legacy) — NATS / Synadia, n.d. (repo HEAD 2026-08-24)
- Key/Value store (legacy) — NATS / Synadia, n.d. (repo HEAD 2026-08-24)
- Forming a cluster — NATS / Synadia (nats.docs.v2), n.d. (repo 2026-10-22)
- Raft and leaders — NATS (nats.docs.v2), n.d.
- Replication and R=3 — NATS (nats.docs.v2), n.d.
- Surviving node loss — NATS (nats.docs.v2), n.d.
- JetStream in a cluster — NATS (nats.docs.v2), n.d.
- Disaster recovery — NATS (nats.docs.v2), n.d.
- Authorization — NATS (nats.docs.v2), n.d.
- Accounts and multitenancy — NATS (nats.docs.v2), n.d.
- Cross-account — NATS (nats.docs.v2), n.d.
- Your first cluster — NATS (nats.docs.v2), n.d.
- Authentication basics — NATS (nats.docs.v2), n.d.
- Decentralized authentication — NATS (nats.docs.v2), n.d.
- Encryption & TLS — NATS (nats.docs.v2), n.d.
- Auth callout — NATS (nats.docs.v2), n.d.
- Rolling upgrades — NATS (nats.docs.v2), n.d.
- Prometheus & dashboards — NATS (nats.docs.v2), n.d.
- Advisories & events — NATS (nats.docs.v2), n.d.
- Upgrade to NATS 2.12 — NATS (nats.docs.v2), n.d.
- Route (protocol reference) — NATS (nats.docs.v2), n.d.
- Mirrors and sources — NATS (nats.docs.v2), n.d.
- Clustering (legacy GitBook docs) — nats-io (archived repo), archived 2026-08-24
- v2 Routes (legacy) — nats-io (archived repo), archived 2026-08-24
- JetStream Clustering (legacy) — nats-io (archived repo), archived 2026-08-24
- Super-cluster with Gateways (legacy) — nats-io (archived repo), archived 2026-08-24
- Gateway (protocol reference) — NATS (nats.docs.v2), n.d.
- Leaf Nodes (legacy) — nats-io (archived repo), archived 2026-08-24
- Leaf node Configuration (legacy) — nats-io (archived repo), archived 2026-08-24
- JetStream on Leaf Nodes (legacy) — nats-io (archived repo), archived 2026-08-24
- NATS Adaptive Deployment Architectures (legacy) — nats-io (archived repo), archived 2026-08-24
- JetStream (legacy concepts) — nats-io (archived repo), archived 2026-08-24
- Streams (legacy) — nats-io (archived repo), archived 2026-08-24
- Authorization (legacy) — nats-io (archived repo), archived 2026-08-24
- Multi Tenancy using Accounts (legacy) — nats-io (archived repo), archived 2026-08-24
- NKeys (legacy) — nats-io (archived repo), archived 2026-08-24
- Leafnode (protocol reference) — NATS (nats.docs.v2), n.d.
- System Events (legacy) — nats-io (archived repo), archived 2026-08-24
- Monitoring (legacy) — nats-io (archived repo), archived 2026-08-24
- Upgrading a Cluster (legacy) — nats-io (archived repo), archived 2026-08-24
- nats bench (legacy) — nats-io (archived repo), archived 2026-08-24
- ADR-22 JetStream Publish Retries on No Responders — nats-io, 2022-03-18
- ADR-26 NATS Authorization Callouts — nats-io, 2022-11-28
- ADR-59 JetStream Stream Sourcing and Mirroring — nats-io, 2026-03-03
- ADR-61 Unsafe meta group quorum rescue for disaster recovery — nats-io, 2026-07-07
- nats-server server/client.go — nats-io, 2026-10-20 (commit)
- nats-server server/gateway.go — nats-io, 2026-10-20 (commit)
- nats-server server/raft.go — nats-io, 2026-10-20 (commit)
- nats-server server/const.go — nats-io, 2026-10-20 (commit)
- nats-server server/server.go — nats-io, 2026-10-20 (commit)
- nats-server server/sublist.go — nats-io, 2026-10-20 (commit)
- Topologies — NATS (nats.docs.v2), n.d.
- nats-server server/stream.go — nats-io, 2026-10-20 (commit)
- nats-server server/errors.json (JetStream API errors) — nats-io, 2026-10-20 (commit)
- Security (legacy concepts) — nats-io (archived repo), archived 2026-08-24
- Automatic Reconnections (legacy) — nats-io (archived repo), archived 2026-08-24
- Super-clusters — NATS (nats.docs.v2), n.d.
- Leaf nodes — NATS (nats.docs.v2), n.d.
- NATS - Go Client (README) — NATS.io / Synadia (nats-io org), 2026-10-23 (tag v1.55.0)
- nats.go Release v1.55.0 — nats-io, 2026-10-23
- Core (nats-core README) — nats-io, 2026-05-08 (tag v3.4.0)
- JetStream (@nats-io/jetstream README) — nats-io, 2026-05-08 (tag v3.4.0)
- NATS Node Transport README — nats-io, 2026-05-08 (tag v3.4.0)
- jsmsg.ts (JsMsg interface) — nats-io, 2026-05-08 (tag v3.4.0)
- core/src/nats.ts (request defaults) — nats-io, 2026-05-08 (tag v3.4.0)
- npm registry metadata: @nats-io/transport-node, @nats-io/jetstream, @nats-io/nats-core — npm, 2026-05-08
- npm registry metadata: nats (legacy package) — npm, 2025-03-19
- NATS.py - Python Client and Tools (README) — nats-io, 2026-09-16 (tag v2.16.0)
- JetStream Simplified Client (jetstream package README) — nats-io, 2026-10-23 (tag v1.55.0)
- nats/aio/msg.py (JetStream ack methods) — nats-io, 2026-09-16 (tag v2.16.0)
- nats/errors.py — nats-io, 2026-09-16 (tag v2.16.0)
- NATS JetStream (nats-jetstream package README) — nats-io, 2026-09-16 (tag v2.16.0)
- PyPI JSON: nats-py, nats-core, nats-jetstream — PyPI, 2026-09-16
- nats.go source (defaults, option doc comments, errors) — nats-io, 2026-10-23 (tag v1.55.0)
- NATS by Example: Request-Reply (Go) — ConnectEverything (Synadia), 2024-10-15 (last commit in dir)
- Request-Reply Semantics — NATS docs (nats-io/nats.docs), 2024-10-28 (last commit)
- Automatic Reconnections — NATS docs, 2022-11-08 (last commit)
- js.go (legacy JetStreamContext) — nats-io, 2026-10-23 (tag v1.55.0)
- Compare NATS (vendor-authored) — NATS docs, 2025-10-28 (last commit)
- nats-server latest release v2.15.1 — nats-io, 2026-10-09
- Introduction (Kafka 4.3 docs) — Apache Software Foundation, n.d. (4.3 docs)
- Design (Kafka 4.3 docs) — Apache Software Foundation, n.d. (4.3 docs)
- Upgrading (Kafka 4.3 docs) — Apache Software Foundation, n.d. (4.3 docs)
- AMQP 0-9-1 Model Explained — RabbitMQ (Broadcom), n.d.
- Queues — RabbitMQ, n.d. (docs v4.4)
- Quorum Queues — RabbitMQ, n.d. (docs v4.4)
- Streams and Super Streams — RabbitMQ, n.d. (docs v4.4)
- Reliability Guide — RabbitMQ, n.d. (docs v4.4)
- Consumers — RabbitMQ, n.d. (docs v4.4)
- Effectively-Once Stream Processing — RabbitMQ, n.d. (docs v4.4)
- Messaging (Pulsar 5.0.x docs) — Apache Software Foundation, n.d. (5.0.x docs)
- Architecture Overview (Pulsar 5.0.x docs) — Apache Software Foundation, n.d. (5.0.x docs)
- jetstream/message.go (Msg interface) — nats-io, 2026-10-23 (tag v1.55.0)
- Why transactions? (Pulsar 5.0.x docs) — Apache Software Foundation, n.d. (5.0.x docs)
- Redis Streams — Redis Ltd., n.d. (docs/latest)
- XACK — Redis Ltd., n.d.
- Redis replication — Redis Ltd., n.d.
- Go module proxy @latest for nats.go — Go module mirror (Google), 2026-10-23