Blog
NATS for tiny backends: pub/sub, req/reply, and JetStream without the drama
If your app is a handful of services and you want them to talk without dragging Kafka into your living room, NATS is the sweet spot. One binary. Fast. Easy. It does pub/sub, request-reply, and with JetStream, you get durable streams and consumers. That's most of what I need for small backends and email pipelines.
This is the fast tour + copy/paste snippets I wish I had the first time.
Why NATS (and when not to)🔗
Why:
- Single binary, zero ceremony. Devs actually run it locally.
- Subjects are just strings with wildcards. Routing is "obvious" instead of Rabbit's "huh?"
- Request/Reply is built-in. You don't need an RPC framework for simple service calls.
- JetStream gives you at-least-once + replay without a yak farm.
When not to:
- You need huge immutable logs, crazy long retention, and batch analytics -> that's Kafka's party.
- You want broker-side super-fancy routing rules and delayed queues with a thousand knobs -> RabbitMQ folks will be thrilled to help (lol).
Quick start (local dev)🔗
Download a release binary and run:
# plain NATS (no persistence)
nats-server
# with JetStream (persistence)
nats-server -js
Default URL is nats://127.0.0.1:4222.
Mental model in 30 seconds🔗
- Subjects:
emails.sent,accounts.*.paused,users.>.events*matches one token,>matches the rest. - Pub/Sub: fire-and-forget broadcast.
- Request/Reply: RPC-style — send a request, get one response.
- JetStream: define Streams (where messages live) and Consumers (how you read them).
Pub/Sub (Go)🔗
import (
"log"
"github.com/nats-io/nats.go"
)
func main() {
nc, _ := nats.Connect(nats.DefaultURL)
// subscriber
nc.Subscribe("emails.sent", func(m *nats.Msg) {
log.Println("got:", string(m.Data))
})
// publisher
nc.Publish("emails.sent", []byte(`{"id":"abc123","to":"user@example.com"}`))
nc.Flush()
select {} // keep running
}
Request/Reply (Go)🔗
// service
nc.Subscribe("accounts.lookup", func(m *nats.Msg) {
// pretend to look up something...
nc.Publish(m.Reply, []byte(`{"ok":true,"plan":"pro"}`))
})
// client
msg, err := nc.Request("accounts.lookup", []byte(`{"id":42}`), 1500*time.Millisecond)
if err != nil { log.Fatal(err) }
log.Println("reply:", string(msg.Data))
JetStream basics (Go)🔗
Turn on JetStream (nats-server -js) and then:
js, _ := nc.JetStream()
// 1) Create a stream (once at startup)
js.AddStream(&nats.StreamConfig{
Name: "EMAILS",
Subjects: []string{"emails.*"}, // captures emails.sent, emails.failed, etc
Storage: nats.FileStorage, // or Memory
Retention: nats.LimitsPolicy, // respect MaxMsgs/MaxBytes/MaxAge
MaxAge: 72 * time.Hour,
})
// 2) Publish to the stream
js.Publish("emails.sent", []byte(`{"id":"abc123"}`))
// 3) Create a consumer (pull style)
js.AddConsumer("EMAILS", &nats.ConsumerConfig{
Durable: "mailer",
FilterSubject: "emails.sent",
AckPolicy: nats.AckExplicitPolicy,
})
// 4) Consume with explicit acks
sub, _ := js.PullSubscribe("emails.sent", "mailer")
for {
msgs, _ := sub.Fetch(10)
for _, m := range msgs {
// do work...
_ = m.Ack() // at-least-once
}
}
Pro tip: if your handler can crash, ack after the side-effects are fully done (idempotent write, etc.). That's basically the contract for at-least-once.
Rust snippet (Axum/Tokio vibe)🔗
use async_nats::jetstream;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let nc = async_nats::connect("nats://127.0.0.1:4222").await?;
let js = jetstream::new(nc);
// stream (idempotent)
let _ = js
.get_or_create_stream(jetstream::stream::Config {
name: "EMAILS".into(),
subjects: vec!["emails.*".into()],
..Default::default()
})
.await?;
// publish
js.publish("emails.sent".into(), "hello".into()).await?;
// pull consumer
let consumer = js
.get_or_create_consumer("EMAILS", jetstream::consumer::pull::Config {
durable_name: Some("mailer".into()),
filter_subject: Some("emails.sent".into()),
..Default::default()
})
.await?;
loop {
let mut batch = consumer.fetch().max_messages(10).expires_in(std::time::Duration::from_secs(2)).await?;
while let Some(msg) = batch.next().await {
// do work...
msg.ack().await?;
}
}
}
Subject naming that won't bite you later🔗
-
Nouns first, verbs after:
emails.sent,emails.failed,accounts.paused,gpt.alerts.created -
Wildcards with intention:
emails.*(one step) vsemails.>(everything under emails)
-
Keep IDs in payload, not subject. Subjects are for routing, payload is for data.
Idempotency (aka "no duplicate chaos")🔗
At-least-once delivery means duplicates can happen. Survive them:
- Use a dedupe key (
message_id,event_id) in a table with a short TTL. - Make handlers idempotent: check if you already applied the effect.
- For HTTP-ish work, consider "request key -> result" caching for a few minutes.
Observability (cheap but useful)🔗
- Start NATS with
-jsand-m 8222for a management port, then visithttp://127.0.0.1:8222. - Grafana: scrape the NATS Prometheus exporter (easy win).
- Emit app metrics: publish/consume counts, ack latency, retries, and dead-letter counts (you can model DLQs as separate subjects/streams, e.g.,
emails.dlq).
Security cliff notes🔗
- Use NKEYS or user/pass with TLS for anything non-local.
- Firewalls matter because NATS is… fast and friendly to everyone by default.
- Multi-server clusters exist (routes + leafnodes), but don't overcomplicate day one.
How I use it in practice🔗
- Blanket Cat: worker microservices publishing
results.ingested,oauth.refresh, etc., with JetStream consumers doing retries sanely. - Warmcraft: Gmail IMAP events ->
inbox.newsubjects; reply actions published toactions.reply; JetStream ensures we never "miss a day" when a worker restarts.
NATS vs. the usual suspects (quick and slightly spicy)🔗
- Kafka: If your data is the log, or you're doing analytics and long retention, go Kafka. Otherwise you're just building a space program to deliver pizza.
- RabbitMQ: Powerful, but I don't love babysitting broker config. If your use case is heavy on broker-side patterns, Rabbit's your buddy. If you want devs to ship something now, NATS is simpler.
Minimal docker-compose (handy for teammates)🔗
version: "3.8"
services:
nats:
image: nats:latest
command: ["-js", "-m", "8222"]
ports:
- "4222:4222" # client
- "8222:8222" # monitoring
volumes:
- ./nats-data:/data
The TL;DR🔗
For small services, queues, and email pipelines, NATS hits the "get it done without a committee" sweet spot. Start with pub/sub and request-reply. Add JetStream when you need durability. Keep subjects boring, handlers idempotent, and dashboards honest. That's it. Ship it.
AI-assisted writing
I draft and edit all articles myself, and I use AI as an assistant for outlining, phrasing, and cleanup. Curious how I use it—and where I draw the lines?