NATS vs RabbitMQ vs Kafka: Which One Fit One VPS?
Compare NATS, RabbitMQ, Kafka and Postgres for one VPS: delivery guarantees, memory and disk cost, restart behaviour, backlog checks, and when Postgres wins.
Di short answer for one server
Message queue for one VPS na decision about delivery guarantees, no be speed. For one box, broker hardly dey be bottleneck, because your application code, your database, and your one disk go reach limit first. Choose the tool wey you fit accept how e dey behave when failure happen, then measure the box wey you actually get.
Four options, for the order wey most readers suppose consider dem.
- Use the database wey you already dey run. Postgres with
SELECT ... FOR UPDATE SKIP LOCKEDna working job queue, and e no add new process wey you need monitor. - Use RabbitMQ when each message na unit of work wey dem must acknowledge, retry for limited number of times, then park somewhere wey human fit inspect am.
- Use NATS when messages na events wey different parts of your system dey react to. Turn on JetStream for events wey must survive restart.
- Use Kafka when downstream tool dey speak only Kafka protocol. For one server, na almost the only reason wey remain.
The rest of this guide go explain the reasoning: wetin each option cost for memory and disk on small VPS, wetin e dey do when box reboot, and the exact command wey go show you backlog before your users notice am.
Wetin delivery guarantee really mean
At most once mean broker hand message over and forget am. If no consumer connect, or consumer die halfway through the work, message don lost and nothing report am.
At least once mean consumer send acknowledgement (an ack) after the work succeed. Until that ack arrive, broker keep the message and go deliver am again. Redelivery na why your handlers must be idempotent: if dem process the same message twice, e no suppose charge the card twice. Exactly once, end to end, no be something broker fit give you. E come from unique key for your own database.
Replay na separate property. Queue drop message once e don get acknowledgement. Log keep am for retention window, so new consumer fit start from the beginning and read the complete history. Kafka and NATS JetStream na logs. RabbitMQ na queue. This difference dey shape more architectures than throughput.
Dead lettering na wetin happen to message wey keep failing. Without am, poison message go loop forever, and the loop go look like worker wey busy instead of worker wey don break.
Start with Postgres and make the broker prove itself
Most single-application workloads na a few thousand background jobs per day. One table fit handle am.
CREATE TABLE job (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
run_after timestamptz NOT NULL DEFAULT now(),
attempts int NOT NULL DEFAULT 0
);
CREATE INDEX job_ready_idx ON job (run_after, id);One worker claims one job inside a transaction.
BEGIN;
SELECT id, payload
FROM job
WHERE run_after <= now()
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1;
-- run the work, then remove the row
DELETE FROM job WHERE id = $1;
COMMIT;FOR UPDATE SKIP LOCKED na the whole trick. E lock the row wey e return and skip any row wey another transaction don already lock, so two workers no go claim the same job. If worker crash, Postgres go abort the transaction, release the lock, and make the row visible to the next worker. You get at-least-once delivery, retries by increasing attempts, and a dead letter table, all with the durability wey you already dey pay for. The backlog na one query: SELECT count(*) FROM job WHERE run_after <= now();
Where e stops working. Every claim and delete na write, so high job rate go leave dead row versions behind, and queue table na the classic case where bloat dey grow pass wetin autovacuum fit handle. Long jobs make am worse, because transaction wey remain open for the whole work duration also hold back the vacuum horizon for the entire database. Polling adds latency, and LISTEN with NOTIFY removes the polling but no remove the writes. When job table become the busiest table wey you get, or another service need the same events, move the work comot. This choice dey depend on how the database itself dey deployed, so first settle whether the database dey run for Docker or on the host before you add broker beside am.
Redis na the other thing wey you fit already dey run. Redis Streams give you consumer groups with XADD and XREADGROUP, one pending list for each group, and XAUTOCLAIM to take work back from consumer wey don die. E small and fast. The honest catch for one box: with the common appendfsync everysec setting, power loss fit make you lose about one second of writes. That one okay for cache invalidation but e wrong for payments. If your application na one process wey dey built around SQLite for production on a VPS, the same claim-and-delete pattern go work, though SQLite no get equivalent of SKIP LOCKED and every worker dey serialize on the one write lock.
NATS core: subject routing wey no dey keep memory
docker run -d --name nats \
-p 4222:4222 -p 127.0.0.1:8222:8222 \
nats:2.14 -m 8222As of August 2026, current server line na 2.14. -m 8222 go turn on HTTP monitoring port, wey dey off by default and get no authentication, so bind am to localhost like above.
Core NATS na at-most-once, and e no store anything. Publisher go send message go subject like orders.created, and every subscriber wey filter match go get one copy. If nobody subscribe, message go drop and publisher no go see error, because publisher work don end once server accept the bytes. A queue group (several subscribers wey share one group name) make server choose one member for each message. This one share work without storing queue.
Footprint na subscription state plus write buffer for each connection. So e dey follow connection count, no be message volume, and nothing dey accumulate for disk. Restart behaviour follow from this: in-flight messages don go, clients go reconnect by themselves, and no recovery step dey wait for.
No backlog dey to monitor, so monitor loss instead. When subscriber dey read its socket slower than server dey write to am, server buffer for that client go fill up. If client no catch up before write deadline, server go close the whole connection and increment one counter.
curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'A slow_consumers value wey dey continue climb mean say messages dey drop, so set alert on am instead of reading am only once. Core NATS good for message wey value dey expire quickly: metric, presence update, or cache invalidation wey next event go replace anyway.
NATS JetStream: durable streams and replay for the same process
JetStream no be second product. Na subsystem wey dey inside the same binary, and one flag dey enable am.
docker run -d --name nats \
-p 4222:4222 -p 127.0.0.1:8222:8222 \
-v nats-data:/data \
nats:2.14 -js -sd /data -m 8222-sd /data dey set the store directory. If you leave am out, JetStream go store data under /tmp, and e durable exactly as the name imply. Use the CLI to create stream. The CLI dey inside the nats-box image.
docker run --rm -it --network host natsio/nats-box:latest \
nats stream add ORDERS \
--subjects 'orders.>' \
--storage file \
--retention limits \
--max-age 72h \
--max-bytes=1073741824 \
--discard old \
--defaultsEvery limit for there get reason for small server. --storage file na wetin go survive crash, because memory stream no go. --max-bytes=1073741824 dey limit the stream to 1 GiB written as byte count, while --discard old dey remove the oldest messages when e reach the limit instead of rejecting new writes. If you no set the limit, one publisher wey no get control fit fill the disk. When that happen, your database go stop too because both dey use the same disk.
Durable consumer dey keep its own position for the stream and dey retain am across restart. Set --max-deliver for the consumer so message wey dey fail every time no go continue redeliver forever. When message exhausts its delivery attempts, JetStream publishes an advisory on $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>. Subscribe to that subject to build the dead letter path wey RabbitMQ gives you as feature. Na you go write that work yourself.
To see backlog, run nats stream report to check stored message counts, and nats consumer report ORDERS to check outstanding acknowledgements and unprocessed messages for each consumer. Na the unprocessed number you suppose set alarm for. You fit see disk usage with du -sh against the store directory, and e go keep growing until retention limit trims am.
RabbitMQ: acknowledge every message, park the failures
docker run -d --name rabbitmq \
-p 5672:5672 -p 127.0.0.1:15672:15672 \
-v rabbitmq-data:/var/lib/rabbitmq \
rabbitmq:4-managementAs of August 2026, the current series na 4.3. Port 5672 na AMQP (advanced message queuing protocol), while 15672 na the management interface. Keep the interface for localhost and reach am through an SSH tunnel.
Declare queues with the x-queue-type argument set to quorum; the default still na classic. Quorum queues always durable and dem dey write data to disk before anything else. So, for one node, you get one clear behaviour instead of different durable and transient options. Set the dead letter target with a policy.
docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
'{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
--apply-to queues --priority 7Message fit become dead letter for four reasons: consumer reject am with basic.reject or basic.nack and requeue set to false, per-message TTL (time to live) expire, queue pass length limit, or message exceed quorum queue delivery limit. From RabbitMQ 4.0 onward, that limit default na 20. So, if handler throw error and nack message, e go retry twenty times, then hand the message go dead letter exchange instead of looping.
Memory na where RabbitMQ dey surprise people for small VPS. Default high watermark na 0.6 of available RAM. When node cross am, RabbitMQ block every connection wey dey publish. Your application no go receive error. E go receive publish wey never return, and this go look like hang for your own code. Startup log dey print the number wey node calculate:
Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) totalDisk alarm block publishers the same way when free space drop below 50 MB by default. Quorum queues add their own calculation on top: documentation budget at least 32 bytes of in-memory metadata per message, about 1 MB for every 30,000 messages, and recommend at least three times the effective write-ahead log size for RAM. WAL limit default na 512 MiB, so that recommendation alone dey ask for 1.5 GB. For 2 GB server, lower am for rabbitmq.conf instead of hoping say the default go fit.
raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5Backlog get two numbers, and the pair dey tell you which failure happen.
docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledgedmessages_ready dey wait for consumer. messages_unacknowledged don deliver but dem never ack am. If unacknowledged count dey rise while ready count remain flat, e mean say your workers collect the jobs but stop finishing dem. This na different bug from queue wey just dey behind.
Kafka for one box, and where e no longer make sense
KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties
bin/kafka-server-start.sh config/server.propertiesNa the quickstart for Kafka 4.3.1, current as of August 2026, wey dey run for KRaft mode (Kafka Raft, the built-in controller wey replace ZooKeeper for Kafka 4.0). The container equivalent na apache/kafka:4.3.1.
The start script dey set export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" when you never set am yourself. So the broker reserve 1 GB Java heap before e store even one message, and e expect free RAM beyond that for the page cache wey e dey read from. For 2 GB VPS, your application dey compete with the JVM for the remaining memory.
Retention na the next surprise. log.retention.hours default na 168, wey be seven days, and log.retention.bytes default na -1, wey mean say no size limit dey at all. Kafka keep messages for the full window whether every consumer don read dem or not. Na this feature you come for, but for one small disk, na also the failure mode. So set byte limit per topic before you discover am.
Now make we talk the honest part. One broker mean replication factor 1, so acks=all resolve to one fsync for one disk. You get the durability of one machine, plus the operating cost of JVM broker and controller. Partitions fit give parallelism across brokers wey you no get. Replication, rack awareness, and the other fleet features just dey idle. JetStream give you the same durable replay for the same box with small fraction of the memory. Two reasons still fit justify Kafka here: downstream tool dey speak only Kafka protocol (change data capture with Debezium, or analytics loader), or you dey reproduce production topology in miniature. If you plan to grow into cluster, that plan mean say you go buy more machines. Until then, the trade-off na the same one as running k3s for one node, where you pay cluster complexity for the reliability of one node.
Backlog for Kafka na consumer lag.
bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-groupRead the LAG column, wey be LOG-END-OFFSET minus CURRENT-OFFSET for each partition. If lag dey rise for one partition while the others remain flat, e point to uneven key. All messages with the same key dey land for the same partition, and one consumer dey handle dem alone.
Wetin dey happen when the box restart
Core NATS dey lose everything wey dey in flight, then e come back immediately because nothing dey recover. JetStream dey reload streams and consumer positions from the store directory, so consumers dey continue from the offset wey dem get before. RabbitMQ dey recover quorum queues from disk, but classic transient queues and any message wey dem publish without persistent delivery mode go lost. Kafka dey replay im log segments when e start. After unclean shutdown, that recovery scan fit take minutes for small disk before the broker accept connection.
Two things make sense to set once. Give the container restart policy (restart: unless-stopped) or enable the systemd unit, so the broker go return after kernel upgrade reboot without you doing anything. Then handle the order: if broker ready twenty seconds after your application, e go reject the first connections, and some client libraries go exit instead of retrying. Make the app wait for the broker with Compose healthchecks wey hold dependent service back until broker ready.
Cost for your own VPS, measure am instead of quote
Throughput figures wey dem publish na measurements from hardware wey you no get, usually multi-core server with local NVMe. Treat dem as upper limit, then measure your own box.
docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_dataRun dem first when broker idle, then run dem again under your real traffic. The difference between both results na the number wey go show whether broker fit stay beside your application. For rough throughput floor, use each project own load generator instead of person blog post: nats bench pub test --msgs 100000 --clients 2 for NATS, bin/kafka-producer-perf-test.sh for Kafka, and PerfTest for RabbitMQ. If you run the generator for the same VPS, you dey measure broker and generator together. That one dey okay as long as you talk so when you report the number.
One ceiling dey apply to all of dem. Every durable option here dey wait for fsync. So, if your VPS dey use network-attached storage, disk go set the limit, and changing broker no go move am.
Workload three and the message queue wey each one need
- Background jobs for one web application, like sending email, resizing images, or delivering webhooks. Start with Postgres and
SKIP LOCKED. Move to RabbitMQ with quorum queues when you need per-message acks, delivery limit, and dead letter queue wey you fit inspect without writing that logic yourself, or when the job table don become the busiest table for the database. - Events wey several internal services dey react to, where newer message quickly replace any message wey get lost. Use Core NATS, with subjects as the routing scheme and queue groups where you need to share work. Add one JetStream stream for the small set of subjects wey must survive restart, and leave the rest for memory.
- An event log wey consumers dey read from the beginning, for audit trail, rebuilding read model, or feeding analytics later. Use JetStream with file storage and explicit byte cap. Choose Kafka only when downstream tool need Kafka protocol, and accept JVM heap as the price for that compatibility.
The cost of wrong choice for one server no be throughput. Na the recovery at three in the morning, when you need know whether the messages still dey exist. Choose based on that.
FAQ
I fit run Kafka for a 2 GB VPS?
E go start, but memory go tight. bin/kafka-server-start.sh dey set KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" when you never override am, so JVM go claim 1 GB before e store any message. Kafka still need free memory beyond that for page cache. Add your application and database for the same box, and swap go enter. You also get replication factor 1. That mean acks=all na one fsync for one disk, so you dey pay Kafka operating cost without getting the durability model. NATS JetStream fit give you durable replay for the same hardware with far less memory.
I need message queue if I already dey run Postgres?
Many times, no. A job table read with SELECT ... FOR UPDATE SKIP LOCKED inside a transaction gives at-least-once delivery, safe concurrent workers, retries, and a dead letter table. You no need another service to monitor, and you already dey take the backups. The signs to move out dey specific: queue table become your heaviest write load and autovacuum fall behind; long-running jobs keep transactions open and block vacuum for the whole database; or another service need consume the same events independently.
I suppose use NATS JetStream or RabbitMQ for background jobs?
Use RabbitMQ if you want per-message acknowledgement, delivery limit, and dead letter routing as built-in behaviour. Quorum queues always dey durable. Delivery limit defaults to 20 from RabbitMQ 4.0, and policy fit send messages wey exhaust their limit to a dead letter exchange wey you fit drain and inspect. Use JetStream if other consumers go need replay the same events later, because stream keeps messages after acknowledgement, while queue no keep dem. With JetStream, you set --max-deliver and build the dead letter path yourself from the $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> advisory.
How I fit know how far my consumers dey behind?
Each broker get one command. For RabbitMQ, rabbitmqctl list_queues name messages messages_ready messages_unacknowledged separates work wey dey wait for consumer from work wey broker deliver but never get ack. For JetStream, nats consumer report <stream> shows unprocessed messages and outstanding acknowledgements for each consumer. For Kafka, kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> prints a LAG column for each partition. Core NATS no get backlog to read because e stores nothing. Instead, monitor the slow_consumers counter on http://localhost:8222/varz. E counts connections wey server close because dem fall behind, and that one mean message loss.