Updated October 2026: fixed three code errors (KV TTL units, KV key names, and the optimistic-locking option) and tightened the wording. Part 2 builds an AI chat backend with the features NATS added in 2.11 through 2.15.
A common platform setup looks like this. Kafka handles event streaming, with its KRaft controllers and partition planning. Redis Sentinel handles sessions and rate limits. S3 or MinIO stores model weights and config files. Each system has its own runbook, its own dashboards, and its own ways to fail.
When you get paged at 3 AM, you're debugging that infrastructure as well as your application.
NATS can cover most of that stack with one server binary. It's been running large distributed systems for over a decade. With JetStream, the Key-Value store, and the Object Store now mature, it's a full messaging platform as well as a fast pub/sub system.
This post walks through each piece with working code.
What NATS replaces
| Need | Usual tool | NATS feature |
|---|---|---|
| Pub/sub messaging | Redis Pub/Sub, Kafka topics | Core NATS subjects |
| Persistent streaming | Kafka, Pulsar, Kinesis | JetStream streams |
| Key-value storage | Redis, etcd, Consul KV | KV store |
| Object and blob storage | S3, MinIO | Object Store |
| Request-reply between services | HTTP, gRPC | Built-in request-reply |
| Service discovery | Consul, Eureka, Kubernetes DNS | Services framework |
The operational difference is large. A production Kafka deployment usually means:
- A KRaft controller quorum (Kafka 4.0 removed ZooKeeper; older clusters still run it)
- Broker nodes with partition and replication planning
- A schema registry for Avro or Protobuf
- Kafka Connect for data pipelines
- JMX exporters for monitoring
A NATS server with persistence is one command:
nats-server -js
There are no external dependencies. The -js flag turns on JetStream.
Core NATS
Core NATS moves messages between publishers and subscribers on named subjects, like events.user.signup. Nothing is stored at this layer. If no subscriber is listening when a message arrives, the message is dropped. In exchange, latency is typically under a millisecond.
Start the server
# Run the binary
nats-server
# Or with Docker
docker run -p 4222:4222 nats:latest
# Turn on JetStream for persistence
nats-server -js
Publish and subscribe
import { connect } from "@nats-io/transport-node";
const nc = await connect({ servers: "localhost:4222" });
// Subscriber (usually another process)
const sub = nc.subscribe("events.user.*");
(async () => {
for await (const msg of sub) {
console.log(`Received on ${msg.subject}:`, msg.string());
}
})();
// Publisher
nc.publish("events.user.signup", JSON.stringify({
userId: "user-123",
email: "dev@example.com",
timestamp: Date.now(),
}));
events.user.* matches one token in that position, so the subscriber gets events.user.signup, events.user.login, and so on. > matches one or more tokens at the end of a subject.
With Kafka, the same flow needs a topic created with partitions and a replication factor, producer serializers, and a consumer group that handles partition assignment and rebalancing. With NATS you publish to a subject and subscribe to it.
Request-reply
NATS has request-reply built in, so internal services can call each other without HTTP or gRPC:
// Service: answer requests
const svc = nc.subscribe("api.users.get");
(async () => {
for await (const msg of svc) {
const { userId } = msg.json<{ userId: string }>();
const user = await db.findUser(userId);
msg.respond(JSON.stringify(user));
}
})();
// Client: send a request and wait for the reply
const response = await nc.request(
"api.users.get",
JSON.stringify({ userId: "user-123" }),
{ timeout: 5000 },
);
console.log("User:", response.json());
If several instances subscribe with the same queue group (nc.subscribe("api.users.get", { queue: "users" })), NATS sends each request to one of them. That's load balancing without a load balancer.
For AI workloads: request-reply fits synchronous inference. The application sends a prompt to
inference.chat.query, one model server in the queue group takes it, and the reply comes back on the same connection. Add model servers and they share the load.
JetStream: persistent streams
JetStream is the persistence layer. A stream stores every message published to its subjects, and consumers read from it, with acknowledgment and replay. It runs inside the same server, so there's no second cluster to manage.
Create a stream
With the CLI:
nats stream add ORDERS \
--subjects "orders.*" \
--retention limits \
--max-msgs 1000000 \
--max-bytes 1GB \
--max-age 7d \
--defaults
--defaults accepts the default for every setting you didn't pass. Without it, the CLI prompts for each one.
Or in code:
import { connect } from "@nats-io/transport-node";
import { AckPolicy, DeliverPolicy, jetstream, jetstreamManager } from "@nats-io/jetstream";
const nc = await connect({ servers: "localhost:4222" });
const jsm = await jetstreamManager(nc);
await jsm.streams.add({
name: "ORDERS",
subjects: ["orders.*"],
retention: "limits",
max_msgs: 1_000_000,
max_bytes: 1024 * 1024 * 1024, // 1 GB
max_age: 7 * 24 * 60 * 60 * 1_000_000_000, // 7 days, in nanoseconds
});
Stream durations like max_age are in nanoseconds. Remember that when you get to the KV section, because nats.js KV options use milliseconds.
Publish to a stream
const js = jetstream(nc);
const ack = await js.publish("orders.created", JSON.stringify({
orderId: "ORD-12345",
customerId: "CUST-789",
items: [{ sku: "WIDGET-01", quantity: 2 }],
total: 49.99,
}));
console.log(`Stored in ${ack.stream} at sequence ${ack.seq}`);
// Deduplicate retries with a message ID
await js.publish("orders.created", JSON.stringify({ orderId: "ORD-12346" }), {
msgID: "ORD-12346",
});
A JetStream publish waits for the server to confirm it stored the message. With msgID, a retry of the same publish inside the stream's duplicate window (two minutes by default) is dropped instead of stored twice. Delivery to consumers is at-least-once. For exactly-once processing, combine msgID on publish with ackAck() on the consumer, which waits for the server to confirm the ack.
Consume with a durable consumer
await jsm.consumers.add("ORDERS", {
durable_name: "order-processor",
ack_policy: AckPolicy.Explicit,
deliver_policy: DeliverPolicy.All, // start from the first message
filter_subject: "orders.created",
max_ack_pending: 100,
});
const consumer = await js.consumers.get("ORDERS", "order-processor");
const messages = await consumer.consume();
for await (const msg of messages) {
const order = msg.json<{ orderId: string }>();
console.log(`Processing order ${order.orderId}`);
await processOrder(order);
msg.ack();
}
A durable consumer keeps its position on the server. If the process restarts, it continues from the last acknowledged message. A message that isn't acknowledged in time is delivered again.
Check a stream from the CLI:
nats stream info ORDERS
State:
Messages: 15,234
Bytes: 2.1 MiB
First Sequence: 1 @ 2026-01-15 10:30:00
Last Sequence: 15,234 @ 2026-01-15 14:22:31
For AI workloads: JetStream works well as a durable log of agent actions. Publish each decision to
agent.<agent-id>.actions, and you can replay the exact sequence later when you debug why an agent did something. You can also queue inference requests in a stream, so a model server restart doesn't lose them.
Key-Value store
The KV store is built on JetStream. You get a key-value API, plus per-key history, watchers, and TTL.
import { Kvm } from "@nats-io/kv";
const kv = await new Kvm(nc).create("sessions", {
history: 5, // keep the last 5 values of each key
ttl: 60 * 60 * 1000, // 1 hour, in milliseconds
});
await kv.put("user-123", JSON.stringify({
userId: "user-123",
role: "admin",
lastActivity: Date.now(),
}));
const entry = await kv.get("user-123");
console.log("Session:", entry?.string());
console.log("Revision:", entry?.revision); // increases on every write
// Optimistic locking: write only if nobody changed the key since we read it
try {
await kv.update("user-123", JSON.stringify({ role: "viewer" }), entry!.revision);
} catch {
console.log("Someone else changed this session first");
}
// Watch for changes
const watch = await kv.watch();
for await (const e of watch) {
console.log(`${e.key}: ${e.operation}`);
}
Keys can use letters, digits, -, _, /, =, and .. A colon isn't allowed, so the Redis habit of session:user-123 throws an invalid key error. Use dots instead (session.user-123). Dots also let you watch a group of keys with a wildcard, like session.>.
Compared with Redis:
- Redis needs Sentinel or Cluster for high availability. A KV bucket replicates like any stream (
replicas: 3). - Redis keeps one value per key. KV keeps as many past values as you set in
history. - Watching keys in Redis means keyspace notifications or Pub/Sub. KV has watchers built in.
- Both have TTL, but only KV gives every write a revision you can use for optimistic locking.
From the CLI:
nats kv put sessions user-456 '{"userId":"user-456"}'
nats kv get sessions user-456
nats kv history sessions user-456
For AI workloads: KV fits conversation state. Store the context window, tool results, and memory under a key like
agent.<session-id>.state. History shows how the state changed, watchers let other components react to changes, and the bucket TTL removes abandoned sessions.
Object Store
The Object Store keeps files too large for a single message. It splits each file into chunks stored in a stream, and records a SHA-256 digest so readers can check integrity.
import { readFile } from "node:fs/promises";
import { Objm, StorageType } from "@nats-io/obj";
const store = await new Objm(nc).create("models", {
storage: StorageType.File,
description: "Model artifacts",
});
const info = await store.putBlob(
{ name: "classifier/v2.3", description: "Text classifier, trained 2026-01" },
await readFile("./model-v2.3.bin"),
);
console.log(`Stored ${info.name}: ${info.size} bytes in ${info.chunks} chunks`);
console.log(`Digest: ${info.digest}`);
const data = await store.getBlob("classifier/v2.3"); // Uint8Array, or null
for (const obj of await store.list()) {
console.log(`${obj.name}: ${obj.size} bytes`);
}
getBlob() loads the whole object into memory. For large files, use store.get(), which returns a readable stream.
From the CLI:
nats object put models ./model-v2.3.bin --name "classifier/v2.3"
nats object get models "classifier/v2.3" --output ./downloaded-model.bin
nats object ls models
For AI workloads: store model configs, LoRA adapters, and smaller model files in the Object Store. Keep the current version in a KV key such as
models.classifier.current. Inference servers watch that key and pull the new object when it changes. That gives you a basic model registry without S3. For very large weights, measure first; dedicated object storage may still be the better fit.
Security and multi-tenancy
NATS security is built on accounts, users, and permissions, and it supports decentralized JWT auth for multi-tenant systems.
Users and permissions
# nats-server.conf
authorization {
users = [
{
user: "order-service"
password: "$2a$11$..."
permissions: {
publish: ["orders.>"]
subscribe: ["orders.*.status"]
}
},
{
user: "analytics-service"
password: "$2a$11$..."
permissions: {
publish: { deny: [">"] } # read-only
subscribe: ["orders.>"]
}
}
]
}
These permissions cover Core NATS subjects. A service that uses JetStream also needs access to the JetStream API subjects ($JS.API.>, or a narrower set), and a service that sends requests needs to subscribe to its reply inbox (_INBOX.>).
Accounts
Accounts give each tenant a separate namespace:
jetstream: enabled
accounts {
TEAM_A: {
users: [{ user: team_a_admin, password: "..." }]
jetstream: enabled
}
TEAM_B: {
users: [{ user: team_b_admin, password: "..." }]
jetstream: enabled
}
}
Team A's orders stream and Team B's orders stream are separate, even on the same cluster. Accounts share data only through explicit exports and imports.
In production, use JWT-based auth managed with the nsc tool. Account and user credentials are signed keys, so the server doesn't need a central user database.
Putting it together for AI
Here's how the pieces fit in an AI agent system:
JetStream streams agent.*.actions, inference.results
KV buckets sessions, agent-state, config
Object Store models, embeddings, documents
Request-reply inference.chat.query <-> model servers
Pub/sub events.* -> any number of subscribers
One agent turn:
- The user's message is published to
chat.<session-id>.input. - The agent loads its state from KV:
agent-statekey<session-id>. - The agent sends a request to
inference.chat.queryand waits for the reply. - The agent logs its reasoning to JetStream on
agent.<session-id>.actions. - The agent writes its updated context back to the same KV key.
- The agent loads tool config from the Object Store:
tools/web-search/config.json. - The response is published to
chat.<session-id>.output.
All of this runs on one NATS cluster, with one set of runbooks and metrics.
Get started
- Install the server and the CLI:
brew install nats-server nats-io/nats-tools/nats
Or download them from nats.io/download.
- Start the server with JetStream:
nats-server -js
- Try it:
nats server info
nats stream add TEST --subjects "test.*" --defaults
nats pub test.hello "Hello NATS!"
nats kv add config
nats kv put config app.version "1.0.0"
nats kv get config app.version
Wrapping up
The point of this post: queuing, caching, and blob storage don't have to be three systems.
When you reach for Kafka for events, Redis for state, and S3 for files, check whether NATS covers the need first. Often it does, and you run one system instead of three.
NATS won't replace Postgres for transactional data. It can replace much of the infrastructure between your services, and that's where most of the operational work is.
Start with Core NATS, add JetStream when you need persistence, and see how far nats-server -js gets you before you add anything else.
Resources
- NATS documentation
- NATS CLI
- nats.js
- NATS .NET client
- JetStream concepts
- NATS by Example
- Part 2: an AI chat backend on NATS 2.15
What are you building with NATS? I'd like to hear about it.
Top comments (0)