Skip to content
Go back

RabbitMQ vs NATS: Pick a Message Bus

By KingPin 14 min read
Contents

Your background jobs need a bus, not a database table

You built a web app. It grew a worker process. Now the worker needs a job from the web process, and you’re reaching for a jobs table with a status column and a cron that polls it every 10 seconds. Stop. That’s a message queue wearing a trench coat, and it’s worse at the job than an actual message queue.

The verdict up front: if you’re running a typical self-hosted app (a web service plus a handful of background workers, maybe two or three microservices) and you’re not sure which broker to reach for, run RabbitMQ. It gives you per-message acknowledgment, retry and dead-letter handling, and a web UI where you can see exactly what’s stuck, all without writing a line of extra code. NATS earns its spot when you want a single static binary with no disk footprint doing pub/sub and request/reply between services, and you’re willing to turn on JetStream and manage streams yourself the moment you need messages to survive a restart.

Both are free, both run happily in a docker-compose.yml next to your app, and neither needs a paid tier to do real work. The difference is what they assume you already know. RabbitMQ assumes you want a broker that manages queues, retries, and routing for you. NATS assumes you’ll build the durability logic you need, in exchange for a broker that gets out of your way.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/self-hosting/rabbitmq-vs-nats

What each one actually is

RabbitMQ is a message broker built around the AMQP 0-9-1 model: producers publish to an exchange, exchanges route messages to queues via bindings, and consumers pull from queues. It has been the default “just use RabbitMQ” answer in self-hosted stacks for over a decade, and RabbitMQ 4.3.6 (the current release as of September 2026) still ships that same core, plus quorum queues, streams, and native AMQP 1.0 support.

NATS is a messaging system built around subjects, which are dot-delimited strings like orders.created that publishers send to and subscribers match against, including with wildcards. Core NATS (the part that’s always on) is fire-and-forget: no queues, no disk, no acknowledgment tracking. If you need messages to persist and be redelivered until acknowledged, you turn on JetStream, which adds streams, durable consumers, and disk-backed storage on top of the same subject model. NATS Server 2.15.0 is current as of this month.

Delivery semantics: the part that actually matters

This is the decision that should drive your pick, so don’t skim it.

Core NATS is at-most-once. If no subscriber is listening on a subject when you publish, the message is gone. There’s no queue backing it up, no disk write, nothing to replay. This is fine for live dashboards, service discovery pings, and chat presence. It’s a bad fit for “a customer just paid, send them a receipt email,” because a crashed worker means a lost receipt.

JetStream adds at-least-once (and, with careful use, exactly-once via deduplication). Once you create a stream and attach a durable consumer, messages persist to disk, get redelivered on failure, and you get explicit acks (msg.ack(), msg.nak(), msg.term()). You’re now doing the same job RabbitMQ does out of the box, just through a different API and with a stream/consumer model you have to design yourself.

RabbitMQ is acknowledgment-based from the start. Every consumer either acks a message or the broker redelivers it (to the same consumer or another one) when the connection drops. Classic queues are the default and live on one node. For replication across a cluster, RabbitMQ 4.x uses quorum queues, built on the Raft consensus algorithm. Classic mirrored queues were removed in RabbitMQ 4.0. If your queue needs to survive a node dying, quorum queues are the only supported path now, so plan for that from day one, not as a migration you do “later.”

There’s also RabbitMQ Streams, a separate append-only log structure for large fan-out and replay scenarios, non-destructive reads, and high throughput. Quorum queues are your durable work queue. Streams are RabbitMQ’s answer to “I basically want Kafka but smaller.” Most self-hosted apps never need streams. Know they exist so you don’t reinvent them with 50 queues bound to the same exchange.

Routing: exchanges and bindings vs subjects and wildcards

RabbitMQ routing happens at the exchange. Four exchange types cover almost every case:

You declare the exchange, declare the queue, and bind them together with a routing key or pattern. That’s three objects to manage for one message flow, which feels like a lot the first time and becomes muscle memory by the third.

NATS skips the exchange step entirely. Publishers send to a subject, subscribers match against it, and the matching happens on the subject string itself:

No binding step, no exchange object. You just pick a subject naming scheme and subscribe to the slice you want. It’s less to configure and less to get wrong, but it also means routing logic that would live in RabbitMQ’s exchange type now lives in your subject naming discipline. Name your subjects badly and you’ll be untangling wildcard subscriptions at 2 AM instead of untangling bindings.

RabbitMQ: Docker Compose, producer, consumer

docker-compose.yml
services:
rabbitmq:
image: rabbitmq:4.3-management
container_name: rabbitmq
ports:
- "5672:5672" # AMQP 0-9-1 and AMQP 1.0
- "15672:15672" # management UI
environment:
RABBITMQ_DEFAULT_USER: appuser
RABBITMQ_DEFAULT_PASS: change-me
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
rabbitmq_data:

The default guest/guest login only works when you connect from localhost inside the container network; anything else gets rejected with a login error. Set RABBITMQ_DEFAULT_USER and RABBITMQ_DEFAULT_PASS for anything that isn’t a throwaway local test, the same way you wouldn’t leave admin/admin on a router you actually use.

producer.py
import pika
connection = pika.BlockingConnection(
pika.ConnectionParameters(
host="localhost",
credentials=pika.PlainCredentials("appuser", "change-me"),
)
)
channel = connection.channel()
channel.queue_declare(queue="receipts", durable=True)
channel.basic_publish(
exchange="",
routing_key="receipts",
body=b'{"order_id": 4821, "email": "[email protected]"}',
properties=pika.BasicProperties(delivery_mode=2), # persistent
)
connection.close()
consumer.py
import pika
connection = pika.BlockingConnection(
pika.ConnectionParameters(
host="localhost",
credentials=pika.PlainCredentials("appuser", "change-me"),
)
)
channel = connection.channel()
channel.queue_declare(queue="receipts", durable=True)
channel.basic_qos(prefetch_count=1)
def callback(ch, method, properties, body):
print(f"sending receipt: {body}")
ch.basic_ack(delivery_tag=method.delivery_tag)
channel.basic_consume(queue="receipts", on_message_callback=callback)
channel.start_consuming()

delivery_mode=2 marks the message persistent so it survives a broker restart. basic_qos(prefetch_count=1) stops RabbitMQ from dumping every queued message on one worker while your other workers sit idle, which is the single most common “why is worker 2 doing nothing” bug in a fresh setup.

NATS: Docker Compose, producer, consumer, JetStream

docker-compose.yml
services:
nats:
image: nats:2.15-alpine
container_name: nats
command: ["-js", "-m", "8222", "--store_dir", "/data"]
ports:
- "4222:4222" # client connections
- "8222:8222" # HTTP monitoring
volumes:
- nats_data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8222/healthz"]
interval: 10s
timeout: 5s
retries: 5
volumes:
nats_data:

-js turns on JetStream, -m 8222 exposes the monitoring HTTP endpoint, and --store_dir gives JetStream a place to persist streams to disk. Without -js, this is core NATS only: fast, and it forgets everything the second a subscriber isn’t listening.

Note the -alpine tag. The plain nats:2.15 image is built from scratch: no shell, no wget, no curl. A healthcheck that calls wget against it fails with “executable file not found” and the container sits at unhealthy forever. The Alpine variant ships BusyBox wget, so the /healthz probe works.

Core pub/sub (no persistence, fire-and-forget):

publisher_core.py
import asyncio
import nats
async def main():
nc = await nats.connect("nats://localhost:4222")
await nc.publish("orders.created", b'{"order_id": 4821}')
await nc.flush()
await nc.close()
asyncio.run(main())
subscriber_core.py
import asyncio
import nats
async def main():
nc = await nats.connect("nats://localhost:4222")
async def handler(msg):
print(f"received on {msg.subject}: {msg.data.decode()}")
await nc.subscribe("orders.*", cb=handler)
await asyncio.sleep(3600)
asyncio.run(main())

That orders.* subscription catches orders.created, orders.shipped, anything one token deep under orders. Now the JetStream version, which is the one you actually want for anything you can’t afford to lose:

producer_jetstream.py
import asyncio
import nats
async def main():
nc = await nats.connect("nats://localhost:4222")
js = nc.jetstream()
await js.add_stream(name="ORDERS", subjects=["orders.*"])
await js.publish("orders.created", b'{"order_id": 4821}')
await nc.close()
asyncio.run(main())
consumer_jetstream.py
import asyncio
import nats
from nats.errors import TimeoutError as NatsTimeout
async def main():
nc = await nats.connect("nats://localhost:4222")
js = nc.jetstream()
sub = await js.pull_subscribe("orders.*", durable="receipts-worker")
while True:
try:
msgs = await sub.fetch(10, timeout=5)
except NatsTimeout:
continue # nothing arrived in 5s; poll again
for msg in msgs:
print(f"processing: {msg.data.decode()}")
await msg.ack()
asyncio.run(main())

The durable="receipts-worker" name is what makes this a real durable consumer: restart the process and it resumes from where it left off instead of re-reading the whole stream. Skip that name and you get an ephemeral consumer that vanishes the moment your subscription closes, which is a nasty surprise the first time your worker restarts and silently starts missing messages.

The try/except around fetch() is not optional. When no message shows up inside the timeout, nats-py raises nats.errors.TimeoutError instead of returning an empty list. Leave it out and your worker crashes the first time the queue goes quiet for five seconds, which on a home lab app is roughly always.

Operations: what you actually look at during an incident

RabbitMQ gives you a management UI at http://localhost:15672 the moment the management image boots. Queue depths, consumer counts, message rates, and a button to manually publish or purge a queue, all in a browser tab. For CLI work you’ve got rabbitmqctl for admin tasks against the running node and rabbitmqadmin for HTTP-API-backed scripting:

Terminal window
docker exec rabbitmq rabbitmqctl list_queues name messages_ready consumers
docker exec rabbitmq rabbitmqadmin -u appuser -p change-me declare queue --name receipts --durable true
docker exec rabbitmq rabbitmqadmin -u appuser -p change-me publish message --exchange amq.default --routing-key receipts --payload hello

The image ships rabbitmqadmin v2, a rewrite with --flag value arguments. Most tutorials online still show the old Python v1 name=receipts style, which v2 rejects with “unexpected argument”. It also needs -u/-p, because the guest user doesn’t exist once you set RABBITMQ_DEFAULT_USER.

NATS has no built-in web UI. Monitoring lives at the HTTP endpoint you exposed with -m 8222: hit /varz, /connz, /jsz for JetStream state, and so on, either in a browser or piped through jq. Day-to-day admin goes through the nats CLI instead:

Terminal window
nats -s localhost:4222 stream add ORDERS --subjects "orders.*" --storage file --replicas 1 --defaults
nats -s localhost:4222 consumer add ORDERS receipts-worker --pull --ack explicit --defaults
nats -s localhost:4222 pub orders.created '{"order_id": 4821}'
nats -s localhost:4222 sub "orders.>"

--defaults accepts the default for every setting you didn’t pass. Without it, stream add and consumer add stop to prompt for retention and start policy, and in a script with no terminal they error out with “cannot prompt for user input without a terminal”.

If you like clicking around a dashboard while you’re half-awake debugging a stuck queue, that alone might decide this for you. If you’re comfortable living in a terminal and scripting your monitoring with curl and jq, NATS won’t slow you down.

Resource footprint and clustering, briefly

RabbitMQ runs on the Erlang VM, which brings real overhead: a base RabbitMQ container idles noticeably heavier than a base NATS container, and Erlang’s scheduler and distribution protocol add operational surface you don’t get with a single Go binary. For a home lab running one broker on a spare box, this rarely matters. It starts to matter on a Raspberry Pi or a tiny VPS where every hundred megabytes of RAM is a line item.

NATS Server is a single static Go binary with no external runtime dependency. The Docker image is small, cold start is fast, and JetStream’s disk use is proportional to what you actually store, since core NATS keeps nothing at all.

Both cluster. RabbitMQ clusters nodes together and layers quorum queues (Raft) on top for per-queue replication; you pick which queues need replication rather than replicating everything. NATS clusters at the server level using its own Raft-based clustering for JetStream, and you set a stream’s replica count (1, 3, 5) per stream. Neither clustering setup is a Saturday-afternoon project. If you’re running a single self-hosted node, skip clustering entirely for now. It’s like insisting on a forklift to move one couch into a first-floor apartment: technically capable, but you’re overbuilding for a job a hand truck already covers.

RabbitMQ vs NATS at a glance

RabbitMQ 4.3.6NATS Server 2.15.0
ModelExchanges, bindings, queuesSubjects with wildcard matching
Default deliveryAt-least-once (acks required)At-most-once (core), at-least-once with JetStream
PersistenceBuilt in, per-queueOpt-in via JetStream streams
ReplicationQuorum queues (Raft)JetStream replica count per stream
Admin UIManagement plugin, web browserNone; HTTP monitoring endpoint + nats CLI
Client ports5672 (AMQP), 15672 (management)4222 (client), 8222 (monitoring)
RuntimeErlang VMSingle Go binary
Request/replyPossible via reply-to queues, clunkierNative, built into core
Good atWork queues, routing, retries, DLQsPub/sub fan-out, service-to-service RPC, lightweight events

Which one do you actually pick

Run RabbitMQ if your use case is “web app hands off a job to a worker” (image processing, email sending, PDF generation, webhook delivery). You want acks, retries, and dead-letter queues without writing them yourself, and you want to glance at a browser tab to see if a queue is backed up. This covers most self-hosted apps with background workers, which is most self-hosted apps.

Run NATS if you’re wiring together several small services that need to ping each other (service discovery, live status updates, internal RPC between microservices) and you don’t want to run Erlang for it. Turn on JetStream from the start if any of those messages matter enough to survive a restart. Don’t run core NATS for anything you’d be upset to lose, the same way you wouldn’t store your paycheck under a doormat because it’s convenient.

Running both because “RabbitMQ for jobs, NATS for service chatter” is a legitimate setup at real scale. For a first self-hosted deployment, that’s two brokers to patch, back up, and explain to your future self at 2 AM. Pick RabbitMQ, ship the thing, and add NATS later only when you have a concrete reason, not a hypothetical one.

Common Questions

Can NATS replace RabbitMQ for background job processing?

Yes, with JetStream enabled and a durable pull consumer per worker group. Core NATS alone cannot: it drops messages when no subscriber is listening, so a crashed worker loses the job. JetStream adds the persistence, acks, and redelivery RabbitMQ gives you by default, but you build the stream and consumer config yourself.

Does RabbitMQ 4.x still support classic mirrored queues?

No. RabbitMQ 4.0 removed classic queue mirroring entirely. Quorum queues, built on the Raft consensus algorithm, are now the supported way to run a replicated, highly available queue. If you’re upgrading from RabbitMQ 3.x with mirrored queues, migrate them to quorum queues before you upgrade the cluster.

Do I need JetStream for a simple pub/sub setup?

No. If subscribers are always online and losing a message during a restart is acceptable (live status updates, metrics, presence pings), core NATS without JetStream works and stays lighter. Add JetStream only when a message must survive a subscriber being briefly offline or the server restarting.

Which broker uses less memory on a small VPS or Raspberry Pi?

NATS uses less memory at idle. It’s a single Go binary with no external runtime, while RabbitMQ runs on the Erlang VM, which carries its own baseline overhead regardless of load. On a 1-2GB VPS or a Pi, that difference is worth caring about; on a dedicated home server with 8GB or more, it rarely changes your decision.

Can I run RabbitMQ and NATS on the same host?

Yes. They use different default ports (5672 and 15672 for RabbitMQ, 4222 and 8222 for NATS) and don’t conflict. Running both is common when RabbitMQ handles background job queues and NATS handles lightweight service-to-service messaging, but treat it as two systems to operate, not one.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Meilisearch vs Typesense for Self-Hosters

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts