What is NATS?
NATS is a lightweight messaging system. Services send messages to named subjects, and other services listen on those subjects. The sender never needs to know who listens. The listener never needs to know who sends. This pattern is called publish and subscribe.
Core NATS works in a fire-and-forget way. A publisher sends a message to a subject. NATS delivers the message to every listener connected at that moment. No listener means no delivery. The message is gone.
Why use NATS?
In a monolith, one module calls another module through a direct function call. In microservices, each service runs as a separate process, often on a separate machine. Direct calls no longer fit. Services need a shared way to talk without tight links to each other.
NATS gives you three benefits:
- Decoupling. The publisher sends an event and moves on. The publisher never tracks who reacts.
- Scalability. A new service subscribes to existing events and reacts on its own. You change no publisher code.
- Speed. NATS routes small messages fast, which suits high-traffic systems.
What is NATS Streaming?
Core NATS loses a message when no one listens. NATS Streaming, also called STAN, fixes this gap. NATS Streaming runs as a layer on top of core NATS and adds storage and delivery guarantees.
NATS Streaming adds four features:
- Persistence. Messages get stored in memory, in a file, or in a SQL store.
- Replay. A new subscriber requests messages from the start, from a sequence number, or from a point in time.
- At-least-once delivery. Subscribers acknowledge each message, so a crashed service does not lose data.
- Durable subscriptions. A service disconnects, reconnects, and resumes from the last processed message.
Here is the difference side by side:
| Feature | Core NATS | NATS Streaming |
|---|---|---|
| Delivery | At most once | At least once |
| Persistence | None | Memory, file, or SQL |
| Replay | No | Yes |
| Durable subscriptions | No | Yes |
| Status | Maintained | Deprecated, end of life June 2023 |
One warning before you build on this. Synadia ended support for NATS Streaming in June 2023 and points teams to JetStream, the persistence layer built into the NATS server. Learn the concepts here and apply them in JetStream for production work.
The main purpose of NATS
The main purpose is to act as an event bus between microservices. A service publishes an event such as OrderCreated or TicketUpdated. Other services subscribe and react. The publisher stays decoupled from every consumer.
NATS Streaming works well as an event bus for four reasons:
- No lost events on crash. Plain pub and sub drops events when a subscriber goes down. NATS Streaming stores them for later delivery.
- Ordering support. Events on a subject arrive in order, which matters for state changes like created, reserved, paid, expired.
- Replay for new services. A new service replays the full history from the start.
- Retry through acknowledgements. Unacknowledged messages get redelivered, giving you built-in retry logic.
Publisher example
import nats from 'node-nats-streaming';
const stan = nats.connect('ticketing', 'abc', {
url: 'http://localhost:4222',
});
// connect(clusterId, clientId, config)
// - clusterId ('ticketing'): matches the server cluster ID
// - clientId ('abc'): unique per client; a duplicate kicks off the first
// - config.url: address of the NATS Streaming server
stan.on('connect', () => {
console.log('Publisher connected');
const data = JSON.stringify({ id: '123', title: 'concert', price: 20 });
// NATS Streaming accepts strings or binary, so objects need JSON.stringify
stan.publish('ticket:created', data, () => {
console.log('Event published');
});
});
The client ID sits hardcoded as 'abc' in this example. In a real deployment with many pod replicas of one service, every pod tries the same client ID and kicks the others off. Generate the ID at runtime from a random value or the pod name.
The duplicate processing problem
You scale a service by running more copies. Picture three replicas of orders-service in Kubernetes. Each replica opens its own subscription to the ticket:created channel. With no grouping, every replica receives every event. Three replicas save the same comment three times. You get duplicate work and corrupted data.
Queue groups fix duplicates
A queue group is a named group inside a channel. When several instances join one group, NATS delivers each event to a single member of the group. Your three orders-service replicas then process each event once.
Different services keep their own queue groups on the same channel. A payments service and an orders service each receive a copy of every event. Inside each group, one member does the work.
Think of a pizza shop with three chefs on one team. One chef makes each pizza. No duplicates. A waiter works on a separate team and hears every order alone.
In node-nats-streaming, you pass the queue group name as the second argument to subscribe(). Every replica of the same service uses the exact same name.
const queueGroupName = 'orders-service-queue-group';
Ordering is the harder problem
Queue groups stop duplicate work. Ordering across different events is a separate challenge. A banking app shows why. The rule is simple. The balance never drops below zero. Four failures break this rule.
- An event fails to process. When a listener never acknowledges an event, NATS waits about 30 seconds and resends the event to another instance. Later events run before the failed one, and the balance goes negative.
- One instance runs slower than another. Instance A holds a backlog. Instance B sits idle and fast. A withdrawal reaches Instance B and runs before an earlier deposit still waiting in Instance A. Wrong order again.
- A service crashes. NATS needs time to detect a dead instance through heartbeats. Events sent to the dead instance wait until NATS times out and reassigns them. During the wait, other events run ahead.
- A race near the timeout. A service takes close to 30 seconds. NATS marks the event as failed and resends the event to another instance. The original instance finishes right after. Two instances process the same event.
These failures are not NATS bugs. Any system with parallel work faces them.
The same problem lives everywhere
A common reaction is to drop events and use synchronous requests, or to return to a monolith. Neither move helps. Synchronous calls carry the same concurrency risks. A monolith behind a load balancer runs many instances and hits the same out-of-order failures. Microservices and events make the problem more visible through network hops, retries, and delivery delays. The root problem exists anywhere processes run in parallel.
Three solutions and why each falls short
Global shared sequence tracking. All instances share one store of the last processed sequence number. Before processing event N, a service checks for N minus 1. You get exact order and zero duplicates. The cost is severe. The whole system now processes one event at a time. Jim's stuck deposit blocks Mary's unrelated deposit. Throughput drops to the floor.
Per-user sequence numbers. Give each user an independent sequence, so Jim and Mary never block each other. Concurrency improves. NATS Streaming breaks the plan. Sequence numbers reset only inside a channel, so each user needs a separate channel. NATS Streaming handles a limited number of channels, with a default near 1,000 and real overhead per channel. Millions of users need millions of channels. No scale.
Publisher tracks the previous sequence. The publisher remembers each user's last sequence number and attaches the expected previous number to every new event. Consumers process an event only when the stored number matches the expected previous number. Clever, and broken. NATS Streaming never returns the assigned sequence number to the publisher. Publishing runs one way. The publisher never learns the number to track.
The real fix: your own version numbers
The lesson lands hard. The problem is your design, not NATS. Stop asking how NATS orders events. Ask how your services make ordering simple.
Look at a blog app. The Post Service owns Posts. No other service creates or edits a Post. This is a single source of truth. Apply the same rule to money. A Transaction Service owns transactions. The service receives requests, saves each transaction, and publishes events.
Then the service assigns its own numbers, one sequence per user.
- Deposit 70 becomes transaction 1
- Deposit 40 becomes transaction 2
- Withdraw 100 becomes transaction 3
These numbers belong to your business, not to NATS. Every event carries userId, amount, and transactionNumber.
The Account Service listens and stores a balance plus the last transaction number. The rule is one line. Process an event only when the incoming number equals the stored number plus one.
Walk the failure. Event 1 crashes. Event 2 arrives with number 2. The stored last transaction is None. Expected previous is 1. No match. The Account Service skips the event and sends no acknowledgement. NATS resends Event 1 after 30 seconds. The service processes Event 1, sets the balance to 70, and stores 1. Event 2 arrives again. Expected 1 matches stored 1. Process. Balance 110. Event 3 arrives. Expected 2 matches stored 2. Process. Balance 10. Correct order, enforced by your logic, not by NATS.
Versioning in the ticketing app
The ticketing project applies the same idea with a version number. Only the Ticket Service changes a version.
- TicketCreated ships version 1
- TicketUpdated ships version 2
- TicketUpdated ships version 3
The Order Service also needs ticket prices, so the Order Service listens. The rule stays the same. Accept an event only when the incoming version equals the stored version plus one. Version 3 arriving before Version 2 fails the check. The Order Service sends no acknowledgement and waits. NATS retries later. Version 2 lands, then Version 3 lands, and the final price reaches 100, the correct value.
The consumer stays behind or level with the producer, never ahead. Mongoose helps here with a built-in version field. You configure each update to increment the version and each event to carry the number.
Recovery and replay: three options together
A crashed service misses events while offline. NATS Streaming stores every published event, so recovery works. Three subscription options solve recovery together.
setDeliverAllAvailable() replays every stored event on startup. A brand-new service uses this once to build local state. The downside shows fast. Every restart replays the full history. Five events feel fine. Five million events waste hours of CPU and network on each restart.
setDurableName('order-service') gives the subscription a stable identity. NATS remembers the last event this subscription processed. On restart, NATS sends only the missed events, not the whole history.
Queue Group keeps one member processing each event and holds the durable state alive across brief disconnects. Without a queue group, a Ctrl+C looks like a permanent disconnect, so NATS drops the durable state and replays everything again.
Used together the behavior stays clean. First startup replays all history and records progress. A restart with no new events delivers nothing. A restart after missing Events 4 and 5 delivers only 4 and 5. Three Order Service pods share one durable subscription and split the load, one event per pod.
The takeaway
Every quick fix trades one problem for another. Global sequencing gives correct order and terrible speed. Per-user sequencing gives speed and hits channel limits. Publisher-tracked sequencing reads well and dies on a NATS limitation. The working answer moves ordering into your own design through business-level version numbers, then pairs setDeliverAllAvailable, setDurableName, and a queue group for reliable replay, fault tolerance, and horizontal scale.

Top comments (0)