← Back to the section

Microservices keep multiplying, and they need to exchange events: an order was placed, a payment went through, goods were shipped. You could install Kafka or RabbitMQ, but each of them is a separate cluster with its own operations, its own tuning and its own memory bill. And when the events are numerous but simple - "notify everyone who cares" - spinning up a heavy broker is like driving a truck to fetch a loaf of bread.

NATS solves this with a single binary under ten megabytes. It does not store events for months and knows nothing about exchange types, but it delivers millions of messages per second and starts in about a second. It is often deployed exactly where Kafka and RabbitMQ would be overkill: an internal microservice bus, IoT telemetry, edge computing.

Subjects: addressing without queues

publisher orders.new.eu NATS matches subjects orders.*.* billing orders.new.* analytics orders.> audit three subscriptions caught one event - no exchange or binding RabbitMQ: exchange + binding Kafka: topic + consumer group NATS: subject a subject is the address, the filter and the topic: a dot separates the levels orders . new . eu the wildcard * replaces one word, > replaces all the rest

A subject is a dot-separated string: orders.new.eu. A subscriber matches a literal subject or a wildcard.

In RabbitMQ the address of an event is an exchange plus a routing key plus a binding. In Kafka it is a topic and a partition. In NATS the address is a subject - a single dot-separated string: orders.new.eu. A publisher sends an event to that string, and NATS delivers it to everyone whose subscription matches.

Matching comes in three flavors:

  • exact: the subscription orders.new.eu catches only orders.new.eu;
  • the asterisk * replaces one word: the subscription orders.*.eu catches orders.new.eu and orders.paid.eu;
  • > replaces all remaining words: the subscription orders.> catches any subject starting with orders..

There are no exchanges or bindings to configure: the subject is its own routing key. Want everything about orders - orders.>. Want only new orders from Europe - orders.new.eu.

Pub/sub: fire and forget

The basic mode is core NATS: a publisher sends a message, NATS delivers it to all active subscribers and forgets it. This is at-most-once: if no subscriber is online at the moment of publishing, the message does not wait - it simply disappears.

nc, _ := nats.Connect(nats.DefaultURL)
defer nc.Drain()

nc.Publish("orders.new.eu", []byte(`{"id": 42, "amount": 1500}`))
import nats, json

nc = await nats.connect()
await nc.publish("orders.new.eu", json.dumps({"id": 42, "amount": 1500}).encode())

This is not a flaw but a design decision: core NATS is meant for a live bus where an event is valuable right now and loses meaning a second later. IoT telemetry, logs, metrics, UI notifications - if a subscriber was offline, it could not catch up on what it missed anyway.

But if a message must reach even an offline subscriber, you need JetStream - covered below.

Queue groups: load balancing

When a service runs in three copies, each message should be handled by one copy, not all three. In RabbitMQ several consumers listen to the same queue for this. In Kafka it is a consumer group. In NATS it is a queue group.

nc.QueueSubscribe("orders.>", "billing-workers", func(m *nats.Msg) {
    process(m.Data)
})
async def handler(msg):
    await process(msg.data.decode())

await nc.subscribe("orders.>", queue="billing-workers", cb=handler)

Three copies of the billing service with queue="billing-workers" receive events in turn: NATS itself decides who gets the next one. Subscribers outside the queue group keep receiving every event, as usual.

Request-reply: ask and answer

In RabbitMQ, request-reply requires a reply queue and a correlation id. In Kafka it is a whole protocol on top of topics. In NATS it is a built-in pattern:

msg, err := nc.Request("user.get", []byte("42"), 2*time.Second)
if err == nil {
    fmt.Println(string(msg.Data))
}
msg = await nc.request("user.get", b"42", timeout=2)
print(msg.data.decode())

The publisher sends a request, NATS supplies an inbox subject for the reply, and the responder calls m.Respond(data). One line instead of setting up queues. This makes NATS convenient for synchronous calls between microservices when HTTP feels heavy and an RPC framework feels excessive.

JetStream: persistence

Core NATS stores nothing. JetStream is a built-in layer that adds persistence and at-least-once guarantees:

publisher JetStream stream: ORDERS seq 1 seq 2 seq 3 consumer: billing ack: seq 2 consumer: analytics ack: seq 1 the stream stores events, consumers keep their own position no ack - JetStream redelivers; ack - the offset moves like Kafka: log + consumer offset + ack

JetStream: the stream keeps events in a log, the consumer tracks its ack position.

JetStream introduces the stream - a named log that stores events matching a filter (for example, orders.>). Consumers subscribe to a stream: they track their own position and acknowledge processing. It resembles Kafka: stream = topic + partition, consumer = consumer group.

js, _ := jetstream.New(nc)

js.CreateStream(ctx, jetstream.StreamConfig{
    Subjects: []string{"orders.>"},
    Retention: jetstream.LimitsPolicy,
    MaxAge: 24 * time.Hour,
})

cons, _ := js.CreateOrUpdateConsumer(ctx, "ORDERS", jetstream.ConsumerConfig{
    Durable: "billing",
    FilterSubject: "orders.new.>",
})

for msg := range cons.Messages() {
    process(msg.Data)
    msg.Ack()
}
js = nats.js.Client(nc)
stream = await js.add_stream(name="ORDERS", subjects=["orders.>"])
consumer = await js.add_consumer("ORDERS", durable_name="billing",
                                  filter_subject="orders.new.>")
async for msg in consumer.messages():
    await process(msg.data.decode())
    await msg.ack()

JetStream supports retention limits by time (MaxAge), by size (MaxBytes) and by count. The LimitsPolicy policy drops older records when a limit is exceeded - like retention in Kafka.

When NATS and when a heavy broker

People choose NATS when:

  • speed and lightness matter: one binary, millions of messages per second, microsecond latency;
  • the events are simple: "notify everyone who cares", without complex routing through exchange types;
  • edge and IoT: a device running NATS weighs less than the JVM needed for a Kafka client;
  • request-reply: the built-in pattern is more convenient than building one on RabbitMQ or Kafka.

A heavy broker (Kafka or RabbitMQ) is needed when:

  • long retention is required: events must live for days and weeks, with replay and re-reading;
  • delivery guarantees matter: exactly-once, transactions, complex rollback scenarios;
  • routing is complex: topic exchange, header exchange, consistent hash;
  • the ecosystem matters: Kafka Connect, Kafka Streams, Schema Registry.

In practice NATS and a heavy broker do not compete but live side by side: NATS as the internal bus for real-time events, Kafka as the log for analytics and integration.

In short

  • NATS is a lightweight broker: a single binary, subject-based addressing, millions of messages per second.
  • A subject is a dot-separated string (orders.new.eu); the wildcard * replaces one word, > replaces all the rest.
  • Core NATS is at-most-once: a message is delivered to active subscribers and forgotten.
  • A queue group lets several copies of one service share the load: each gets its own messages.
  • Request-reply is a built-in ask-and-answer pattern in one line.
  • JetStream is persistence on top of core NATS: the stream stores the log, the consumer tracks its ack position, with an at-least-once guarantee.
  • NATS for a light bus and the edge; Kafka for a log and analytics; RabbitMQ for task queues and complex routing.