Matt Novak logo Matt Novak

Blog

NATS for tiny backends: pub/sub, req/reply, and JetStream without the drama

2025-07-15 • 4 min
nats messaging microservices go rust

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) vs emails.> (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 -js and -m 8222 for a management port, then visit http://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.new subjects; reply actions published to actions.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?