Self-Host Centrifugo: Real-Time Updates Without Pusher

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:

  1. Your browser client connects to Centrifugo with a short-lived JWT that your backend signed.
  2. The client subscribes to a channel, say notifications:42 for user 42.
  3. Something happens in your app. Your backend POSTs to Centrifugo's API.
  4. 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, including https:// and no trailing slash.
  • 401 on connect: your JWT was signed with a different secret than hmac_secret_key, or it expired. Check the sub claim exists.
  • Publish returns 401: the X-API-Key header does not match http_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:42 and 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 👋