Self-Host NATS: Lightweight Messaging for Your Apps and AI Agents
Every distributed system I've ever built started the same way: two services talking over HTTP. Then it's five services, each one hardcoding the others' URLs, and one restarts during a deploy while three callers time out. Suddenly you're bolting retry loops and circuit breakers onto code that was supposed to be simple. At some point you realize the services shouldn't call each other directly at all. They should drop messages onto a shared bus and let the bus sort out delivery.
That bus is what NATS gives you, and it does it in a single Go binary small enough to forget it's running. Let's wire up something real.
What NATS actually is
NATS is a messaging system built around subjects. A subject is just a dotted string like orders.created or agent.tasks.summarize. Publishers send messages to a subject, subscribers listen on subjects (with wildcards), and NATS handles the routing. Nobody needs to know anybody else's address.
Out of the box you get patterns that cover most of what you'll ever need:
- Publish/subscribe: one message, every interested subscriber gets a copy.
- Request/reply: send a message, get one answer back, like an RPC call but with no hardcoded URL.
- Queue groups: many workers share a subject and NATS load-balances messages across them.
If you need messages to survive a restart, you turn on JetStream, which adds durable streams and at-least-once delivery. Core NATS is fire-and-forget and blazing fast; JetStream is when you need a paper trail.
Getting it running on Elestio
NATS is genuinely tiny, but you still want it patched, monitored, and backed up if you're leaning on JetStream. Deploying managed NATS on Elestio gives you a dedicated VM with TLS, updates, and backups handled, plus a management UI at your instance URL (log in with root and the admin password from your dashboard). Because the server itself is so light, a NC-MEDIUM-2C-4G instance at around $16/month is plenty for most workloads, and it's open-source so there are no per-message or per-connection fees, just the VM.
To connect a client, you need the server URI. Elestio gives you one in this format:
export NATS_URL="nats://root:YOUR_ADMIN_PASSWORD@your-instance.vm.elestio.app:4222"
Store that in NATS_URL and every example below just works.
A quick test from the CLI
Before writing any code, prove the connection with the nats CLI. Open one terminal and subscribe to everything under orders:
nats --server "$NATS_URL" sub "orders.>"
In another, publish an event:
nats --server "$NATS_URL" pub orders.created '{"id": 42, "total": 19.99}'
The subscriber prints it instantly. The > wildcard matches every level below orders, so orders.created and orders.refunds.issued both land in the same listener. Use * when you want exactly one level: orders.* catches orders.created but not orders.refunds.issued.
Pub/sub from your app (Node.js)
The CLI is for poking around. In real code you use a client library. Install the official one:
npm install nats
Here's the whole pub/sub loop. A subscriber prints anything on demo.hello, and a publisher sends one message:
// pubsub.js
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: process.env.NATS_URL });
const sc = StringCodec();
const subject = "demo.hello";
const sub = nc.subscribe(subject);
(async () => {
for await (const m of sub) {
console.log("Received:", m.subject, sc.decode(m.data));
}
})();
setTimeout(() => {
nc.publish(subject, sc.encode("hello world"));
console.log("Published to", subject);
}, 500);
Run it with node pubsub.js and you'll see the message go out and come straight back in.
Request/reply, no URLs required
This is the pattern people underestimate. Instead of your app calling http://pricing-service:8080/quote, it asks a subject a question. The responder subscribes and calls msg.respond(); the caller uses nc.request() and awaits the answer:
// reqrep.js
import { connect, StringCodec } from "nats";
const nc = await connect({ servers: process.env.NATS_URL });
const sc = StringCodec();
// Responder: answers "svc.add" requests
nc.subscribe("svc.add", {
callback: (_err, msg) => {
const { a, b } = JSON.parse(sc.decode(msg.data));
msg.respond(sc.encode(JSON.stringify({ sum: a + b })));
},
});
// Caller: sends one request, waits up to 1s for the reply
const res = await nc.request("svc.add", sc.encode(JSON.stringify({ a: 2, b: 3 })), {
timeout: 1000,
});
console.log("Got reply:", sc.decode(res.data)); // {"sum":5}
The responder can move hosts, scale to ten copies, or restart, and the caller never changes a line. That decoupling is the whole point.
Scaling workers with queue groups
Say an AI agent drops summarization jobs onto agent.tasks.summarize and you want a pool of workers to share the load. Start each worker in the same queue group:
nats --server "$NATS_URL" sub --queue workers 'agent.tasks.summarize'
Run that on three machines and each message goes to exactly one worker, round-robin. Add a fourth and it just starts pulling its share. No config change, no rebalance dance. In the client library it's one option: nc.subscribe("agent.tasks.summarize", { queue: "workers" }).
Why this matters for AI agents
Agent systems are messaging systems whether you admit it or not. You've got a planner handing tasks to tool-runners, workers reporting results, and a supervisor watching progress. Doing that over direct HTTP means every component has to know where every other one lives and what to do when one is missing. Put NATS in the middle and the planner publishes agent.tasks.*, workers pull from a queue group, and results flow back on a reply subject. Components come and go freely, which is exactly how you want a fleet of agents to behave.
The lightweight argument, honestly
The usual alternative for this kind of thing is Kafka, and Kafka is a lot of machine for a job NATS often does with a rounding error of the resources.
| Aspect | NATS (core) | Kafka |
|---|---|---|
| Runtime | Single Go binary | JVM-based |
| Coordination | None for core; Raft for JetStream | KRaft / ZooKeeper |
| Baseline memory | Tens of MB | Gigabytes |
| Comfortable home | One small VM (~$16/mo) | Multi-node cluster |
If you truly need Kafka's connector ecosystem and long-retention log replay, use Kafka. For service-to-service messaging, agent coordination, and request/reply, NATS is the one I reach for first.
Troubleshooting
- Connection refused or timing out. NATS listens on port
4222. Make sure your URI usesnats://(ortls://if TLS is enforced), nothttp://, and that the host and port are right. - "Authorization Violation". Your credentials are wrong or missing. Use the full
nats://root:PASSWORD@host:4222URI from your Elestio dashboard. - Messages vanish when a subscriber is offline. That's core NATS working as designed: it's at-most-once. If you need messages to wait for a consumer, create a JetStream stream (
nats stream add) and publish to a subject the stream captures. - Wildcard catches too much or too little. Remember
*is exactly one token and>is one-or-more.orders.*will not matchorders.eu.created; useorders.>for that.
Where to go from here
Start by replacing one internal HTTP call with a request/reply subject and watch how much retry code you get to delete. From there, add a queue group for your background workers, and reach for JetStream only when you actually need durability. You can spin up managed NATS on Elestio in a couple of minutes and have a real message bus instead of a web of direct calls.
Thanks for reading ❤️ See you in the next one 👋