NATS JetStream Explained: Streams, Consumers, KV, and When to Use Core NATS
Core NATS is a fast, fire-and-forget message bus: publish a message to a subject, and any subscriber currently connected receives it. If nobody is subscribed, the message is gone. That is perfect for request/reply and live dashboards, and useless for anything that must survive a disconnect.
JetStream is NATS' built-in persistence layer, and the KV and Object stores are built on top of it. This guide walks through streams and consumers, then retention, ack semantics, replay, and how to decide between Core NATS, a stream, and a KV bucket.
Streams: The Durable Message Store
A stream is a server-side store of messages, bound to one or more subjects. When you publish to a subject a stream is bound to, the server persists the message instead of just broadcasting it to current subscribers.
Publish → orders.created → Stream "ORDERS"
orders.fulfilled → ├── msg 1 (orders.created)
orders.cancelled → ├── msg 2 (orders.created)
└── msg 3 (orders.cancelled)
Key stream settings:
- Subjects: which subjects the stream captures (wildcards allowed, e.g.
orders.>). - Storage: file-backed (durable across restarts) or memory-backed (fast, lost on restart).
- Retention policy: how long messages are kept (see below).
- Max age / max bytes / max messages: the practical limits that bound the stream.
Retention Policies
| Policy | Keeps messages while | Best for |
|---|---|---|
| Limits | Within max age/bytes/messages | Time-series, telemetry, event logs |
| Interest | At least one consumer is behind | Task queues, work distribution |
| Work queue | Until a consumer acks | Exactly-one-processing jobs |
The default is Limits: keep everything up to your configured bounds, then drop the oldest. That is the right choice for sensor data, logs, and any append-mostly workload. Work queue is the interesting one for job processing: each message is delivered to exactly one consumer and removed once acknowledged.
Consumers: The Stateful View
A consumer is a server-side, stateful view of a stream. The server tracks how far this consumer has progressed, so your application does not have to remember offsets. You can create multiple consumers over the same stream, each with its own position: one for a dashboard reading from the start, one for a worker only taking new messages.
Push vs Pull
- Push consumers: the server delivers messages to a subscription as they arrive. Good for continuous processing where the consumer can keep up.
- Pull consumers: the application asks for the next message (or a batch) when it is ready. Good for workers that want back-pressure, or for on-demand processing.
Pull consumers are the workhorse of task processing: a fleet of workers each pull the next message, process it, and ack it. The server hands out each message to only one pull consumer in the group.
Acknowledgements and Redelivery
JetStream consumers acknowledge messages. The ack tells the server "I processed this, you can move on." The ack policy controls what happens if the consumer dies or takes too long:
- Ack none: no ack expected; messages are delivered and forgotten.
- Ack explicit: the application must ack each message; unacked messages are redelivered after
ack_wait. - Ack all: acking message N also acks everything before it (batch-friendly).
Redelivery is bounded by max_deliver. When a message exceeds that, it is moved to the dead-letter subject if you configured one. Otherwise it is dropped. That is the mechanism that makes "exactly-once-ish" processing possible: at-least-once delivery plus deduplication plus bounded retries.
Mental model: Core NATS gives you at-most-once with zero overhead. JetStream gives you at-least-once with ack-based redelivery. It does not magically make processing idempotent: your handler should still be safe to run twice.
Replay and Catch-Up
Because the stream stores messages, a new consumer can replay history. You choose the delivery policy when you create the consumer:
- Deliver all: start from the oldest message in the stream.
- Deliver new: only messages published after the consumer starts.
- Deliver by sequence / start time: replay from a specific offset or timestamp.
Replay is what makes JetStream a historian, not just a bus: a downed dashboard reconnects, creates a consumer from its last sequence, and catches up on everything it missed.
KV and Object Store: Latest Value, Not History
NATS KV is built on a JetStream stream but exposes a different interface: get, put, delete, plus watches and a bounded revision history per key. Each key holds one current value, and an update overwrites the previous one in place. It is a state store, not a message log.
- Configuration: services watch a bucket and react to changes without a restart.
- Last known device state: current mode of line A, firmware version of gateway 9, last state of pump 3. This is the retained-message use case, backed by a real store.
- Small coordination state: feature flags, leader election locks.
The Object store is the same idea for large blobs such as firmware images or files. It splits the object into chunks on top of JetStream. Do not push a 4 MB file through a single message: it runs into the default max_payload of 1 MB.
Core NATS vs JetStream vs KV: What to Put Where
| Core NATS | JetStream stream | KV bucket | |
|---|---|---|---|
| Delivery | At-most-once, live subscribers only | Durable, ack-based, replayable | Latest value per key, watchable |
| Latency | Lowest: no disk, no ack | Higher: persistence + acks | Same as a stream write |
| Use for | Request/reply, live telemetry, fan-out, presence heartbeats | Event history, work queues, catch-up, buffering between producer and consumer | Config, device state, flags |
| Storage cost | None | Disk, retention limits to manage | Disk, bounded by key count and history depth |
A typical system uses all three on one server: Core NATS for live telemetry and request/reply, a stream for the audit history of events that matter, and a KV bucket for device configuration that services watch.
Common Mistakes
- Putting everything in JetStream "just in case." A live dashboard fed from a stream pays disk I/O and ack overhead for data nobody replays. Keep hot data on Core subjects and mirror only the subset you need as history.
- Using KV as an event log. It overwrites values. If you need the sequence, use a stream.
- Using Core NATS for data you cannot lose. If a message vanishing is unacceptable, use JetStream or KV.
Getting Hands-On
The fastest way to internalize streams and consumers is to create a stream, publish to its subject, then create a couple of consumers with different delivery policies and watch each one advance independently. A GUI makes that far more tangible than typing nats CLI commands.
NATS Explorer ships an embedded NATS server with JetStream enabled. You can start the server, create a stream bound to orders.>, publish a few messages, then create a durable consumer and a pull consumer over the same stream, and watch their positions diverge as you publish more. The same window has KV and Object store browsers, so you can put a config key in a bucket and watch it update live.
See JetStream Visually
NATS Explorer for macOS includes an embedded server plus stream, consumer, KV, and Object store browsers. No CLI required.
Explore NATS Explorer →