NATS
Core pub/sub, JetStream persistence, KV store, and running a 3-node cluster — the fast path to reliable messaging without Kafka complexity.
Real-World Analogy
Walkie-talkies vs a recorded dispatch system: core NATS is walkie-talkies — instant, lightweight, but if you’re not listening when the message is sent, you miss it. JetStream is the dispatch recording system — messages are stored, replayed on demand, and acknowledged on receipt. Same underlying radio network, with persistence layered on.
Why NATS
NATS is a cloud-native messaging system written in Go. The server is a single ~20MB binary. Core characteristics:
- 1M+ messages/sec on modest hardware
- Sub-millisecond latency
- Subjects are strings with wildcards (
>,*) - No per-message routing config — subjects are the routing
- JetStream adds persistence, at-least-once delivery, and KV store on top
Use NATS when you want Kafka-level throughput with dramatically simpler operations.
Running NATS
# Single node — local dev
docker run -d --name nats -p 4222:4222 -p 8222:8222 nats:latest
# With JetStream enabled
docker run -d --name nats -p 4222:4222 -p 8222:8222 nats:latest -js
# 3-node cluster (docker-compose)
services:
nats1:
image: nats:latest
command: -p 4222 -cluster nats://0.0.0.0:6222 -routes nats://nats2:6222,nats://nats3:6222 -js -sd /data
ports: ["4222:4222"]
volumes: ["nats1:/data"]
nats2:
image: nats:latest
command: -p 4222 -cluster nats://0.0.0.0:6222 -routes nats://nats1:6222,nats://nats3:6222 -js -sd /data
ports: ["4223:4222"]
volumes: ["nats2:/data"]
nats3:
image: nats:latest
command: -p 4222 -cluster nats://0.0.0.0:6222 -routes nats://nats1:6222,nats://nats2:6222 -js -sd /data
ports: ["4224:4222"]
volumes: ["nats3:/data"] Core Pub/Sub
import { connect, StringCodec } from 'nats';
const nc = await connect({ servers: 'nats://localhost:4222' });
const sc = StringCodec();
// Subscribe
const sub = nc.subscribe('orders.created');
(async () => {
for await (const msg of sub) {
const order = JSON.parse(sc.decode(msg.data));
console.log('Order received:', order.id);
}
})();
// Publish
nc.publish('orders.created', sc.encode(JSON.stringify({
id: 'ord-123',
customerId: 'cust-456',
total: 99.99,
}))); Subject wildcards:
orders.*— matchesorders.created,orders.cancelledbut notorders.payment.failedorders.>— matchesorders.created,orders.payment.failed, any depth
Core pub/sub is fire-and-forget — if no subscriber is listening when you publish, the message is gone. Use JetStream for persistence.
JetStream Streams
JetStream stores messages in streams with configurable retention:
import { connect, JSONCodec, RetentionPolicy, StorageType } from 'nats';
const nc = await connect({ servers: 'nats://localhost:4222' });
const js = nc.jetstream();
const jsm = await nc.jetstreamManager();
const jc = JSONCodec();
// Create stream — subjects it captures
await jsm.streams.add({
name: 'ORDERS',
subjects: ['orders.>'],
retention: RetentionPolicy.Limits,
storage: StorageType.File,
max_age: 7 * 24 * 60 * 60 * 1e9, // 7 days in nanoseconds
max_msgs: -1, // unlimited
num_replicas: 3, // replicate across 3 nodes
duplicate_window: 2 * 60 * 1e9, // 2-minute deduplication window
});
// Publish to stream
await js.publish(
'orders.created',
jc.encode({ id: 'ord-123', customerId: 'cust-456' }),
{ msgID: 'ord-123' } // deduplication key
); JetStream Consumers
// Push consumer — server pushes to a subject
const pushConsumer = await jsm.consumers.add('ORDERS', {
durable_name: 'payment-service', // named = durable = remembers position
deliver_subject: '_INBOX.payments', // server pushes here
deliver_policy: DeliverPolicy.New, // start from new messages only
ack_policy: AckPolicy.Explicit, // must ack each message
max_deliver: 5, // retry up to 5 times
ack_wait: 30 * 1e9, // 30s to ack before redeliver
filter_subject: 'orders.created', // only this subject
});
// Pull consumer — consumer requests batches (preferred for workers)
const pullConsumer = await jsm.consumers.add('ORDERS', {
durable_name: 'analytics',
ack_policy: AckPolicy.Explicit,
filter_subject: 'orders.>',
});
// Pull a batch
const messages = await js.fetch('ORDERS', 'analytics', { batch: 100, expires: 5000 });
for await (const msg of messages) {
const order = jc.decode(msg.data);
await processForAnalytics(order);
msg.ack();
} Work Queues (Competing Consumers)
JetStream work queues: each message delivered to exactly one consumer in the group.
// Create work queue stream
await jsm.streams.add({
name: 'EMAIL_JOBS',
subjects: ['jobs.email.>'],
retention: RetentionPolicy.WorkQueue, // delete on ack
storage: StorageType.File,
num_replicas: 3,
});
// Multiple workers consume from same durable consumer
// Each message goes to exactly one worker
await jsm.consumers.add('EMAIL_JOBS', {
durable_name: 'email-workers',
ack_policy: AckPolicy.Explicit,
max_ack_pending: 50, // max outstanding unacked per consumer
});
// Worker process (run multiple instances)
const consumer = await js.consumers.get('EMAIL_JOBS', 'email-workers');
const iter = await consumer.consume({ max_messages: 10 });
for await (const msg of iter) {
await sendEmail(jc.decode(msg.data));
msg.ack();
} KV Store
JetStream includes a distributed key-value store:
const kv = await js.views.kv('config', {
ttl: 3600 * 1e9, // 1 hour TTL
replicas: 3,
});
// Put
await kv.put('feature-flags', jc.encode({ darkMode: true, newCheckout: false }));
// Get
const entry = await kv.get('feature-flags');
const flags = jc.decode(entry.value);
// Watch for changes (reactive config)
const watcher = await kv.watch({ key: 'feature-flags' });
for await (const entry of watcher) {
const flags = jc.decode(entry.value);
updateFeatureFlags(flags);
} This replaces Consul KV or Redis for distributed config — you get change notifications, history, and TTL built in, all on the same NATS cluster your messaging uses.
Request-Reply
NATS has built-in request-reply — no setup needed:
// Server
nc.subscribe('user.lookup', {
callback: async (err, msg) => {
const { userId } = jc.decode(msg.data);
const user = await db.findUser(userId);
msg.respond(jc.encode(user));
}
});
// Client
const response = await nc.request(
'user.lookup',
jc.encode({ userId: '123' }),
{ timeout: 5000 }
);
const user = jc.decode(response.data); NATS handles the correlation and reply routing automatically. The client blocks until the server responds or the timeout fires.
Monitoring
# NATS monitoring HTTP API (port 8222)
curl http://localhost:8222/varz # server stats
curl http://localhost:8222/connz # connections
curl http://localhost:8222/jsz # JetStream stats
curl http://localhost:8222/subsz # subscriptions
# JetStream stream status
nats stream info ORDERS
nats consumer info ORDERS payment-service
# Consumer lag
nats consumer ls ORDERS
# Shows pending messages (lag) per consumer Prometheus metrics via nats-server built-in exporter (enable with -m 8222), scraped by nats_prometheus_exporter.
When to Use NATS Over Kafka
Use NATS when:
- You want a single binary to deploy and operate
- You need request-reply as a first-class primitive
- Your throughput requirements are < 1M msg/sec per node
- You want the KV store for config alongside messaging
- Operational simplicity matters more than Kafka’s ecosystem
Use Kafka when:
- You need long-term event retention (weeks/months)
- You’re building stream processing (Kafka Streams, Flink)
- You need the Kafka Connect ecosystem (hundreds of connectors)
- Your team already knows Kafka
- Log compaction is a primary requirement