Self-Host Centrifugo: Real-Time Updates Without Pusher
A few years ago I lost the better part of a month to a "simple" live notifications feature. Just a little bell icon that lights up when something happens. How hard could it be? I wrote a WebSocket server, then spent the next two weeks fighting reconnections, dropped messages on mobile, a memory leak that only showed up under load, and the moment of dread when we added a second app server and realized our sockets were all pinned to the first one. The feature shipped. It was never fun.
If you have ever gone down that road, Centrifugo is the thing I wish I had known about. It is an open-source (Apache 2.0) real-time messaging server that sits next to your existing backend and handles the hard part of pushing data to browsers. You keep your app in whatever language you already use. Centrifugo just does the live layer.
What it actually is
Centrifugo is a user-facing PUB/SUB server written in Go. Clients (browsers, mobile apps) open one persistent connection to it and subscribe to channels. Your backend publishes messages to those channels over a plain HTTP API. Centrifugo fans each message out to everyone subscribed, instantly.
The key idea: your backend never talks WebSockets. It makes a normal HTTP POST when something interesting happens, and Centrifugo handles the sockets, reconnects, and delivery. Think of it as a self-hosted Pusher or Ably that you run on your own box.
It speaks WebSocket, Server-Sent Events, HTTP-streaming, and WebTransport, so it works even in locked-down networks where raw WebSockets get blocked.
How a message travels
The flow is refreshingly boring, which is exactly what you want from infrastructure:
- Your browser client connects to Centrifugo with a short-lived JWT that your backend signed.
- The client subscribes to a channel, say
notifications:42for user 42. - Something happens in your app. Your backend POSTs to Centrifugo's API.
- Centrifugo pushes the payload to every connected subscriber of that channel.
Let's wire it up.
Deploy it on Elestio
You can run Centrifugo on Elestio for about $16/month on a small VM, with TLS, backups, and updates handled for you. Once it is up, you configure two secrets: one HMAC key for signing client tokens, and one API key your backend uses to publish.
A minimal config.json looks like this:
{
"client": {
"token": {
"hmac_secret_key": "replace-with-a-long-random-string"
},
"allowed_origins": ["https://yourapp.com"]
},
"http_api": {
"key": "replace-with-another-long-random-string"
},
"channel": {
"namespaces": [
{ "name": "notifications" }
]
}
}
The notifications namespace lets you use per-user channels like notifications:42. Centrifugo rejects a namespace:channel name unless that namespace is declared here, so this one line matters.
The client side
Load the JS SDK and connect. The token comes from your own backend, never hardcoded:
<script src="https://unpkg.com/centrifuge@5.4.0/dist/centrifuge.js"></script>
const centrifuge = new Centrifuge(
"wss://your-centrifugo.vm.elestio.app/connection/websocket",
{ token: tokenFromYourBackend }
);
const sub = centrifuge.newSubscription("notifications:42");
sub.on("publication", (ctx) => {
console.log("New event:", ctx.data);
// update your UI here
});
sub.subscribe();
centrifuge.connect();
That is the whole frontend. Reconnects, ping/pong, and backoff are handled by the SDK.
Signing a token in your backend
Your backend signs a JWT with the same HMAC secret from the config. The sub claim is the user id:
import jwt from "jsonwebtoken";
const token = jwt.sign(
{ sub: "42" },
process.env.CENTRIFUGO_TOKEN_SECRET,
{ expiresIn: "24h" }
);
Send that token to the browser when the user loads the page.
Publishing an event
When something happens, POST it. That is it:
await fetch("https://your-centrifugo.vm.elestio.app/api/publish", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CENTRIFUGO_API_KEY
},
body: JSON.stringify({
channel: "notifications:42",
data: { title: "Your order shipped", orderId: 8891 }
})
});
The user's browser gets that object in milliseconds. No polling, no rewrite of your API, no WebSocket code in your app.
The features that saved me later
The basic publish is nice, but the extras are why you keep it:
- Presence: ask Centrifugo who is currently subscribed to a channel. Great for "3 people viewing this document."
- History and recovery: if a client drops offline for ten seconds, it can automatically catch up on missed messages when it reconnects. This was the exact bug that wrecked my DIY attempt.
- Scaling: point multiple Centrifugo nodes at a shared Redis (or NATS) broker and they distribute messages across the whole cluster. Adding a second app server stops being scary.
Centrifugo vs building it yourself
| Concern | Roll your own | Centrifugo |
|---|---|---|
| Reconnects and backoff | You write it | Built into the SDKs |
| Missed messages on drop | Manual buffering | History + recovery |
| Multi-server scaling | Sticky sessions, custom pub/sub | Redis or NATS broker |
| Who is online | Build a tracking table | Presence API |
To be fair, if all you need is a single alert on a single server for a hobby project, a bare WebSocket is fine. Centrifugo earns its keep the moment you have real users, mobile clients, or more than one app server.
Troubleshooting
- Client connects then immediately disconnects: almost always
allowed_origins. It must exactly match your site's origin, includinghttps://and no trailing slash. - 401 on connect: your JWT was signed with a different secret than
hmac_secret_key, or it expired. Check thesubclaim exists. - Publish returns 401: the
X-API-Keyheader does not matchhttp_api.key. Keep this key server-side only, never in browser code. - Messages arrive out of nowhere to the wrong user: you are publishing to a channel more people can subscribe to than you think. Namespace per-user channels like
notifications:42and lock down subscriptions.
Wrapping up
Real-time features have a reputation for being a rabbit hole, and honestly they earn it when you build the plumbing yourself. Centrifugo is the boring, battle-tested layer that lets you skip the plumbing and just publish events. Your backend stays the same. Your frontend gets one small SDK. And you get to ship the live feature without losing two weeks to reconnection bugs like I did.
Spin one up on Elestio and you can be pushing your first live update this afternoon.
Thanks for reading ❤️. See you in the next one 👋