1.067.2 Real-Time Broadcast & Pub/Sub Servers#

Self-hostable real-time fan-out servers compared on delivery, recovery, presence, scaling, transports, auth and license; fan-out latency is indistinguishable across Centrifugo, Socket.IO and bare WebSocket on one host.

Evidence: measured, method not yet published. The numbers here were produced by probes run on a stated machine; the harness is not in the public repository yet, so a reader cannot re-run them.

At a glance#

LibraryBest forVerdictLatest release
CentrifugoChat and feeds from any backend languageApache-2.0; history stream with recovery, presence, Redis multi-node in the open edition; PRO is paid—
Socket.IONode.js apps with Socket.IO clientsMIT; recovery only on Redis Streams, MongoDB and in-memory adapters; Redis adapter quiet since 20244.8.4 · 2026-09-25
MercureOne-way updates with no client libraryAGPL-3.0; Last-Event-ID replay; free hub is single node, multi-node is paid—
Phoenix Channels and PubSubElixir backendsMIT; replicated Presence and native clustering; no history1.8.15 · 2026-09-25
NATS (WebSocket)Teams already running NATSApache-2.0; JetStream durable replay; browser-facing layers are the app’s3.4.0 · 2026-05-13
Laravel ReverbLaravel apps or Pusher clientsMIT; healthy; Laravel-centered—
SoketiNothing new; migrate offAGPL-3.0; stalled (42 open PRs, none merged since October 2023; last release 2024-03)1.6.1 · 2024-03-25
Redis / Valkey pub/subCross-node relayAt-most-once, no history; Redis 8 tri-license, Valkey BSD-3-Clause8.1.0 · 2026-07-30

Latest release observed from npm and PyPI in 2026-10.

What the research found

  • Every server in the category defaults to at-most-once delivery; what differs is the repair after a gap: Centrifugo history stream, Socket.IO state recovery on two adapters, Mercure Last-Event-ID, NATS JetStream, and nothing for Phoenix, Reverb and Redis pub/sub. — Keep the application database as the system of record; use the server for delivery and recent catch-up.
  • On one host, fan-out latency does not separate Centrifugo, Socket.IO and a bare WebSocket broadcaster at 1,000 or 4,000 subscribers; the load generator is most of the number. — Choose on recovery, presence, scaling and license, not on a latency claim.
  • High availability sits on different sides of the license line: Mercure’s multi-node needs a paid license, while Centrifugo (Redis), NATS and Phoenix cluster in open source. — Check the edition before designing for more than one node.
  • Soketi has stalled (42 open PRs, none merged since October 2023, last release March 2024); Laravel Reverb is the maintained Pusher-protocol server. — Do not start new work on Soketi.
  • Socket.IO connection state recovery does not work with the Redis pub/sub adapter; it needs the Redis Streams or MongoDB adapter, and the Streams adapter is small and pre-1.0. — Adapter choice decides what survives a disconnect.

What the research recommends

Default — Choose by what must survive a disconnect and how many nodes you need, then by language and license; keep the database as the record.

PersonaPick
chatCentrifugo for a polyglot backend; Phoenix for Elixir; Socket.IO with the Redis Streams adapter for Node, with an application-level catch-up.
live dashboardsMercure on one node, Centrifugo cache mode for several; NATS where it already carries the metrics.
notificationsStore in the app database and nudge online clients; Centrifugo user channels, Mercure private updates or Reverb private channels.
collaborative presencePhoenix Presence on Elixir; Centrifugo or a Pusher-protocol server otherwise, re-querying the roster because join/leave is at-most-once.

Explainer

Real-Time Broadcast and Pub/Sub Servers: Domain Explainer#

The hardware-store version#

A WebSocket library, covered in 1.067, is a telephone line: it connects one client to one server. A broadcast server is the switchboard. It knows who is on the line, which topics they care about, and how to pass one message to ten thousand listeners at once. Plenty of apps start with the telephone line and discover, around the second server or the first dropped connection, that they need the switchboard.

The problem#

A web request answers one question and ends. Anything that should arrive without being asked for (a new chat message, a price tick, a “your export is ready”) needs a connection that stays open, and something on the server that decides which connections receive which message.

Three things make this harder than it looks.

One server stops being enough. Connections live in one process’s memory. When two or more servers sit behind a load balancer, a message published on server A must also reach the clients connected to server B. That needs a shared channel between servers, called a backplane, and each framework solves it differently.

Networks drop. Phones go through tunnels and laptops sleep. A message sent while a client is disconnected is, by default, gone. Some systems keep a short history so a returning client can catch up; most do not, and the application must fetch what it missed.

Who is here? Chat, collaborative tools and live counters want a roster of connected users. Connections can vanish without saying goodbye, so a roster needs timeouts and reconciliation.

Approaches#

  • A standalone fan-out service next to the application. The backend publishes over HTTP; browsers connect to the service. Examples: Centrifugo, Mercure.
  • Realtime built into the app server. The application process holds the connections and shares messages between nodes through an adapter or its runtime. Examples: Socket.IO, Phoenix Channels.
  • A general message bus with a browser door. A broker used for service-to-service messaging that also accepts WebSocket clients. Example: NATS.
  • A protocol clone. A self-hosted server that speaks the API of a hosted service so existing clients keep working. Examples: Soketi and Laravel Reverb, which speak Pusher’s protocol.
  • A hosted service. Ably, Pusher Channels and Supabase Realtime run all of it for a fee per message or connection. They appear in this survey only as the alternative to compare against.

Underneath most of these sits a pub/sub relay such as Redis pub/sub, which carries messages between server nodes.

How a message travels#

A backend event happens: a comment is saved. The backend tells the broadcast server which channel it belongs to, usually with one HTTP call or a function call into the framework. The server looks up which connections are subscribed to that channel and writes the message to each. If other server nodes hold more subscribers, it passes the message to them first through the backplane, and each node writes to its own connections. The client library on the other end turns the bytes into an event in the page.

Each step has a failure mode a buyer should ask about: the publish call can succeed while a subscriber is offline; the backplane can drop while the nodes keep running; a client can reconnect to a different node than before. The systems in this survey differ in which of these they repair for you.

Trade-offs across the whole category#

  • Simplicity against capability. One-way Server-Sent Events (SSE) from a hub is the least to learn and run; two-way channels with history, presence and permissions need a real server and a client library.
  • Embedded against separate. Realtime inside the application reuses its authentication and data model but ties connection count to the app’s deploy cycle. A separate service scales and restarts on its own, at the price of another moving part and a second auth path.
  • Open against paid. Several of these have a free edition with a ceiling (a single node, fewer operations tools) and a paid one without. The ceiling is worth finding before design, not after.
  • Own against hosted. A hosted service removes operations and bills by messages or connections. A self-hosted one bills by machines and attention.

Vocabulary#

  • Channel, topic, room: the named group a message is addressed to. Different systems use different words.
  • At-most-once: a message is delivered once or not at all. At-least-once: it is retried until acknowledged, so duplicates are possible.
  • History and recovery: the server keeps recent messages and replays what a reconnecting client missed.
  • Presence: the list of who is connected to a channel.
  • Transport: the connection type. WebSocket is two-way and universal; SSE is one-way over plain HTTP with built-in reconnection; WebTransport is a newer two-way option over HTTP/3.

When you need one#

  • Several app servers share connections.
  • You want history, recovery or presence without writing them yourself.
  • Updates must reach thousands of clients quickly.

When you do not#

  • A single server with a few dozen connections is well served by a plain WebSocket library.
  • If updates are rare and a few seconds late is fine, polling is simpler.
  • If the only goal is a phone notification when the app is closed, that is mobile push (see 1.119.2), a different layer.

Sources#

The delivery vocabulary follows the documentation of Redis pub/sub (https://redis.io/docs/latest/develop/pubsub/), Socket.IO (https://socket.io/docs/v4/delivery-guarantees) and NATS JetStream (https://docs.nats.io/nats-concepts/jetstream). The backplane idea is described in the Socket.IO adapter documentation (https://socket.io/docs/v4/adapter/). The SSE and EventSource reconnection behavior is defined in the Mercure specification (https://mercure.rocks/spec). Server-Sent Events and WebTransport status were read from the sources cited in S4.

S1: Rapid Discovery

S1: The field at a glance#

Servers and frameworks that take a message from a backend (or another client) and fan it out to many connected browser and app clients. Client-library choice for a single WebSocket connection is covered in 1.067 WebSocket Libraries; this survey starts one layer up, where the question is channels, rooms, history, presence and more than one server.

Four shapes#

ShapeMembersWhat you operate
Standalone broadcast serverCentrifugo, MercureOne service next to your app; your backend publishes over HTTP
Realtime library inside the app serverSocket.IO, Phoenix ChannelsThe app process holds the connections
General message bus with a WebSocket doorNATSA broker you may already run; browsers connect through its WebSocket listener
Pusher-protocol serversSoketi, Laravel ReverbA drop-in for the hosted Pusher API, so existing Pusher clients keep working

Redis pub/sub is not a client-facing server. It is the backplane several of the above use to share messages between nodes, and it is profiled that way.

Ably, Pusher Channels and Supabase Realtime are hosted services. They appear as neighbors in hosted-neighbors.md so a reader can see what self-hosting trades away; they are not scored.

Evaluation dimensions#

  1. Delivery guarantee. At-most-once, at-least-once, ordered or not.
  2. History and recovery. What a client gets back after a dropped connection.
  3. Presence. Who is connected to a channel right now.
  4. Horizontal scaling. What it takes to run two or more nodes, and which edition allows it.
  5. Transports. WebSocket, SSE, HTTP streaming, WebTransport.
  6. Authentication and authorization. How a client proves identity and which channels it may join.
  7. License and viability. What the license obliges, and whether the project is still maintained.

Boundary#

In scope: self-hostable servers that fan out to browsers and mobile apps. Out of scope: document-state synchronization (CRDT libraries such as Yjs handle merging edits, and run on top of one of these transports), mobile push through APNs or FCM (see 1.119.2 Push Notification Libraries), task queues (see 1.043.1), and WebRTC media.

Facts below were read from vendor docs and the projects’ repositories on 2026-10-05 unless marked otherwise.


Centrifugo#

A standalone real-time messaging server written in Go. Your backend, in any language, publishes to it over an HTTP or gRPC API; clients subscribe to channels through official SDKs for JavaScript, Swift, Java, Dart, C# and Go (source: https://centrifugal.dev/docs/getting-started/introduction, accessed 2026-10-05). The same code base is available as a Go library, centrifuge (MIT).

Facts#

LicenseApache-2.0 server; a paid PRO edition adds features (source: https://api.github.com/repos/centrifugal/centrifugo, accessed 2026-10-05)
Latest releasev6.9.7, 2026-09-29 (source: GitHub releases API, accessed 2026-10-05)
Starsabout 10.8k (source: GitHub API, accessed 2026-10-05)
Stall test6 open PRs, 117 merged since 2026-04-05 (source: GitHub search API, accessed 2026-10-05)
TransportsWebSocket, HTTP streaming, SSE, gRPC, WebTransport (experimental) (source: https://centrifugal.dev/docs/transports/overview)
DeliveryAt-most-once pub/sub; recovery replays missed messages from a bounded history stream
HistoryPer-channel stream with history_size and history_ttl; epoch and offset let a reconnecting client check it has the same stream
PresencePresence query, presence stats, join/leave events; join/leave are at-most-once
ScalingMemory engine for one node; Redis (or Redis-compatible) engine for many nodes with history and presence; NATS broker for many nodes without history; PostgreSQL broker marked experimental
AuthConnection JWT (HMAC, RSA, ECDSA; EdDSA via JWKS), a backend connect proxy, subscription tokens, per-namespace permission flags

Behavior to know#

  • Channels default to deny. A subscription from a client is refused until the channel’s namespace sets allow_subscribe_for_client, and an anonymous user also needs allow_subscribe_for_anonymous (measured 2026-10-05; Centrifugo 6.9.7; a subscribe without it returned code 103 permission denied).
  • Recovery has two modes: stream mode replays every missed publication, cache mode delivers only the latest one. Cache mode suits dashboards and tickers (source: https://centrifugal.dev/docs/server/history_and_recovery).
  • A recovered: false result tells the app to reload state from its own database. The docs call streams ephemeral.

Edition line#

PRO is a paid license, quoted on request, and adds push notification API, rate limiting, user blocking and token revocation APIs, tracing, SSO for the admin UI, and delta compression among others (source: https://centrifugal.dev/docs/pro/overview). The open-source edition already includes multi-node Redis scaling, history, presence and recovery.

Trade-offs#

  • Strong on the features chat and feeds need, with the history model written down plainly.
  • A separate process to deploy and a client SDK to adopt; the SDK is required for the bidirectional transports.
  • History is a sliding window, not storage of record.

Hosted neighbors#

Not scored. These are the managed services the self-hosted options are usually weighed against. List prices were read on 2026-10-05 and change.

ServiceModelPrice shapeSource
Pusher ChannelsHosted Pusher protocolFlat tiers by daily message and concurrent connection cap: Sandbox free (200k messages a day, 100 connections), Startup $49 a month (1M a day, 500 connections), Pro $99 (4M, 2,000), Business $299 (10M, 5,000), up to 90M a day and 30,000 connections on higher tiers(vendor: https://pusher.com/channels/pricing)
AblyHosted pub/sub with history, presence and delivery-guarantee featuresMetered: $2.50 per million messages, $1.00 per million channel-minutes and per million connection-minutes at list, discounts at volume; a free package of 6 million messages a month and 200 concurrent connections(vendor: https://ably.com/docs/platform/pricing)
Supabase RealtimeBroadcast, Presence and Postgres Changes, tied to a Supabase projectBundled with Supabase plans; the server (Elixir) is open source, Apache-2.0, release v2.140.7 on 2026-10-01, with 30 open PRs and 415 merged since 2026-04-05(source: GitHub API, accessed 2026-10-05; vendor: https://supabase.com/docs/guides/realtime)

What self-hosting trades away#

  • Operations. Connection capacity planning, TLS termination, a proxy that does not break WebSockets or SSE, and a backplane to run.
  • Metering shape. Hosted prices scale with messages or connections; self-hosted cost scales with machines. Flat daily caps (Pusher) and per-message metering (Ably) behave differently under a burst.
  • Protocol lock-in. The Pusher protocol is implemented by the Soketi and Reverb servers, so leaving Pusher Channels is cheap. Ably’s and Supabase’s client protocols are theirs; Centrifugo and Mercure are alternatives with different protocols.
  • Features. Ably documents history and delivery features as a product; in the self-hosted set only Centrifugo (history and recovery) and NATS JetStream cover the same ground.

Mercure#

An open protocol and a reference hub for pushing updates to browsers over Server-Sent Events. A backend publishes with a plain HTTP POST; browsers subscribe with the native EventSource API, no client library required. The hub is a Go module that runs inside Caddy.

Facts#

LicenseAGPL-3.0 for the hub (source: https://api.github.com/repos/dunglas/mercure, accessed 2026-10-05); paid Self-Hosted and Cloud editions
Latest releasev1.0.3, 2026-10-03 (source: GitHub releases API, accessed 2026-10-05)
Starsabout 5.3k (source: GitHub API, accessed 2026-10-05)
Stall test10 open PRs, 187 merged since 2026-04-05 (source: GitHub search API, accessed 2026-10-05)
TransportSSE over HTTP; HTTP/2 or later recommended for multiplexing (source: https://mercure.rocks/spec)
DeliveryBest effort; the spec says a subscriber should not assume no events are lost
RecoveryEvent ids and the Last-Event-ID header or lastEventID query parameter replay events after a disconnect
PresenceOptional subscriptions API lists active subscriptions and publishes subscription events
ScalingThe open-source hub is single-node with a BoltDB transport; multi-node needs the Self-Hosted license, with Redis/Valkey, PostgreSQL, Kafka or Pulsar transports (source: https://mercure.rocks/docs/hub/cluster)
AuthJWT for publishers and subscribers, private updates, topic selectors

Behavior to know#

  • Version 1.0 changed authentication. The hub’s default configuration validates access tokens in a stricter mode (issuer, audience and scope claims, RFC 9068 style), and the legacy simple-JWT setup runs only under an explicit compatibility switch (measured 2026-10-05, v1.0.3: a token carrying only the classic mercure claim was rejected with 401 on the default configuration; the subscribe parameter topic was refused with a message pointing to match). Tutorials written for 0.x do not work unchanged.
  • The cluster page states that open source, Cloud and Self-Hosted tiers speak the same protocol, so a client can move between them by changing the hub URL. Self-Hosted tiers are listed from 1,500 to 12,000 euros or more a year (source: https://mercure.rocks/docs/hub/cluster, accessed 2026-10-05).
  • AGPL-3.0 obliges anyone who modifies the hub and offers it over a network to publish those modifications; running it unmodified behind an app creates no such duty for the app.

Trade-offs#

  • Smallest client footprint: browsers, mobile webviews and any HTTP client work with no SDK.
  • One-way only: clients receive; sending goes through your own HTTP endpoints.
  • High availability is the paid line; free users run one node.

NATS with WebSocket#

A messaging system written in Go. Core NATS is subject-based publish/subscribe with request-reply; JetStream adds persistence. The server has a built-in WebSocket listener, so browsers connect straight to the broker with the JavaScript client.

Facts#

LicenseApache-2.0 (source: https://api.github.com/repos/nats-io/nats-server, accessed 2026-10-05)
Latest releasev2.15.0, 2026-09-17 (source: GitHub releases API, accessed 2026-10-05)
Starsabout 20.8k (source: GitHub API, accessed 2026-10-05)
Stall test22 open PRs, 415 merged since 2026-04-05 (source: GitHub search API, accessed 2026-10-05)
Browser clientnats.ws, about 71k downloads a week (source: https://api.npmjs.org/downloads/point/last-week/nats.ws, week ending 2026-10-03); the current client packages are @nats-io/nats-core and @nats-io/transport-node
TransportsNATS protocol over TCP; WebSocket listener with optional TLS, compression, origin allow-list, JWT cookie (source: https://docs.nats.io/running-a-nats-service/configuration/websocket/websocket_conf)
DeliveryCore NATS is at-most-once to currently connected subscribers; JetStream gives at-least-once with acknowledgements and redelivery (source: https://docs.nats.io/nats-concepts/jetstream)
History and recoveryJetStream streams replay from a start, a sequence or a time; consumers track position
PresenceNone built in; subscription and connection events can be observed with system accounts
ScalingClustering, superclusters and leaf nodes are part of the open-source server
AuthUsers, accounts, NKeys, JWT with decentralized trust, per-user subject permissions

Behavior to know#

  • A browser that connects to NATS speaks the broker’s own protocol with its subject model; there is no channel-permission layer designed for end users in the way Centrifugo or Pusher-style servers have one. Per-user subject permissions and auth callout are the tools for restricting what a browser can subscribe to.
  • NATS is a good fit when the broker already carries internal traffic and the same subjects should also reach browsers. It is a heavier commitment than a fan-out gateway when nothing else uses it.
  • Centrifugo can use NATS as its inter-node broker, without history or presence.

Trade-offs#

  • Highest project velocity in the set and open-source clustering and persistence.
  • Browser-facing concerns (presence, per-channel recovery for UI clients, user-scoped channels) are yours to assemble.

Phoenix Channels and PubSub#

The realtime layer of the Elixir web framework Phoenix. Channels give topic-based message routing over WebSocket (with long-polling as the only other transport); Phoenix.PubSub distributes broadcasts across nodes; Phoenix.Presence tracks who is on a topic.

Facts#

LicenseMIT (source: https://api.github.com/repos/phoenixframework/phoenix, accessed 2026-10-05)
Latest releasev1.8.15, 2026-09-25 (source: GitHub releases API, accessed 2026-10-05)
Starsabout 23k (source: GitHub API, accessed 2026-10-05)
Stall testPhoenix: 32 open PRs, 126 merged since 2026-04-05; phoenix_pubsub: 6 open, 3 merged (source: GitHub search API, accessed 2026-10-05)
TransportsWebSocket and long-polling (source: https://phoenix.hexdocs.pm/channels.html)
DeliveryAt-most-once; the server persists nothing, so a restart loses undelivered messages and offline clients miss what was sent
History and recoveryNone built in; the application stores and replays
PresencePhoenix.Presence: replicated across the cluster with no single source of truth and no external dependency (source: https://phoenix.hexdocs.pm/presence.html)
ScalingErlang distribution between nodes, one message sent per additional node; a Redis adapter exists for environments without distributed Elixir (source: channels guide)
AuthPer-socket connect callback and per-channel join callback, both in app code

Behavior to know#

  • The server and the app are the same program. Channels reuse the app’s authentication and database, which removes a network hop and a service boundary, and requires the backend to be Elixir.
  • Clients are the Phoenix JavaScript library, plus community clients for Swift, Kotlin and others. The wire format is Phoenix’s own, so there is no generic client.
  • Connections are Erlang processes. Fan-out cost per node comes from the BEAM scheduler; no benchmark for it is included here because this survey’s harness did not stand up an Elixir app.

Trade-offs#

  • The most complete built-in presence story in the field, and clustering with no extra infrastructure.
  • Not usable by a team that does not run Elixir; the Redis adapter is a niche escape hatch.
  • No history means recovery is entirely your design.

Pusher-protocol servers: Soketi and Laravel Reverb#

The Pusher Channels protocol has two halves: a WebSocket client protocol with public, private and presence channels, and a signed HTTP API for publishing. Servers that implement it let an app switch from hosted Pusher to self-hosting by changing a host name. Presence channels and private-channel authorization are part of the protocol.

Soketi#

A Node.js server built on uWebSockets.js that implements Pusher protocol v7 (source: https://raw.githubusercontent.com/soketi/soketi/master/README.md, accessed 2026-10-05).

LicenseAGPL-3.0 (source: GitHub API, accessed 2026-10-05)
Latest release1.6.1, 2024-03-25 (source: GitHub releases and npm registry, accessed 2026-10-05)
Last commit2025-03-03 (source: GitHub API pushed_at, accessed 2026-10-05)
Starsabout 5.6k
Stall test42 open PRs, 0 merged since 2026-04-05 and none since 2023-10-11, the last merge (source: GitHub search API, accessed 2026-10-05); this is the stalled cell of the four-cell reading
npm downloadsabout 2,700 a week (source: https://api.npmjs.org/downloads/point/last-week/@soketi/soketi, week ending 2026-10-03)

An issue titled “the Soketi project is abandoned” was opened 2024-02-11 and closed; a newer issue, “Is Soketi maintained?”, opened 2026-02-12 and was still open on 2026-10-05 (source: GitHub issues 1114 and 1305). The README says the project is meant to stay free and maintained. Read the durability signal as stalled: patches are arriving and none are merged.

Laravel Reverb#

A first-party Laravel WebSocket server in PHP on a ReactPHP event loop, speaking the Pusher protocol so Laravel Echo and Pusher clients work (source: https://laravel.com/docs/12.x/reverb).

LicenseMIT
Latest releasev1.12.0, 2026-09-22 (source: GitHub releases API, accessed 2026-10-05)
Starsabout 1.6k
Stall test3 open PRs, 21 merged since 2026-04-05 (source: GitHub search API, accessed 2026-10-05)
ScalingRedis pub/sub between servers behind a load balancer, switched on by one environment variable (source: Laravel docs)
ConnectionsThe default event loop is limited to about 1,024 open files; the ext-uv loop lifts that (source: Laravel docs)

Reverb is built for Laravel apps. It can serve other apps through the Pusher protocol, but its install path and monitoring (Pulse) assume Laravel.

What the protocol gives and withholds#

  • Presence channels, private channels and client events: yes.
  • History, replay after reconnect: no. Pusher Channels does not replay; apps refetch from their database.
  • Delivery: at-most-once.

S1 recommendation: which shape fits which situation#

If you haveLook first atWhy
A backend in any language and a need for history, recovery and presence at moderate scaleCentrifugoOnly standalone server here with replay from a bounded stream and Redis-backed multi-node in the open-source edition
An Elixir backendPhoenix Channels and PresenceClustering and presence need no extra service; recovery is yours to build
A Node.js backend and a Socket.IO-shaped clientSocket.IO with the Redis Streams adapter if you want recoveryBiggest community; the adapter choice decides what survives a disconnect
One-way updates to browsers and a wish for no client SDKMercure (single node)Native EventSource, plain HTTP publish; high availability is a paid license
NATS already in productionNATS WebSocket listenerReuses the broker; the browser-facing layers are yours
A Laravel app, or Pusher clients already in placeLaravel ReverbSame protocol as hosted Pusher; avoid Soketi because it has stalled
Several app nodes behind a load balancer and nothing elseRedis (or Valkey) as the backplaneCheapest cross-node relay; it is at-most-once and stores nothing

Findings that cut across the field#

  1. Almost every server here is at-most-once. Centrifugo, Socket.IO, Phoenix, Mercure, Reverb and Redis pub/sub all say so in their docs. Replay after a gap is the feature that varies: Centrifugo (history stream), Socket.IO (state recovery on two adapters), Mercure (Last-Event-ID, best effort) and NATS JetStream have it; Phoenix and Pusher-protocol servers do not.
  2. High availability sits on different sides of the license line. Centrifugo’s Redis-backed multi-node is in the Apache-2.0 edition, Phoenix clusters natively, NATS clusters in open source, and Mercure’s multi-node requires a paid Self-Hosted license.
  3. The server’s protocol decides the client. Socket.IO, Phoenix, Centrifugo and NATS each need their own client library; Mercure needs none; Pusher-protocol servers reuse Pusher’s clients.
  4. One project has stalled. Soketi shows 42 open PRs and none merged since October 2023, with no release since March 2024.
  5. Licenses differ in what they ask. Apache-2.0 and MIT for Centrifugo, NATS, Socket.IO and Phoenix; AGPL-3.0 for Mercure and Soketi.

Redis pub/sub as a backplane#

Redis pub/sub is a channel-based message relay inside Redis. It is not a client-facing server: browsers do not talk to it. Socket.IO, Centrifugo, Laravel Reverb and Phoenix’s Redis adapter use it so that a message published to one app node reaches subscribers connected to the others.

Semantics#

  • At-most-once. A message is delivered once if at all; a subscriber that is disconnected or too slow loses it permanently (source: https://redis.io/docs/latest/develop/pubsub/, accessed 2026-10-05).
  • Ordered per publisher. Subscribers receive messages in publish order.
  • Fire-and-forget. Nothing is stored. Redis Streams add persistence and at-least-once delivery, and are what the Socket.IO Streams adapter and Centrifugo’s history use.
  • Sharded pub/sub (Redis 7.0 and later). Shard channels hash to cluster slots and propagate only within a shard, which limits cluster-bus traffic and lets pub/sub scale by adding shards (source: same page).

Licensing#

The license of the server underneath matters when it is a component of your deployment.

ServerLicense
Redis 7.2 and earlierBSD-3-Clause
Redis 7.4 to 7.8RSALv2 or SSPLv1
Redis 8.0 and laterRSALv2, SSPLv1 or AGPLv3, your choice
Valkey (Linux Foundation fork)BSD-3-Clause (source: GitHub API, accessed 2026-10-05)

(source: https://redis.io/legal/licenses/, accessed 2026-10-05). Centrifugo documents compatibility with Valkey, KeyDB, DragonflyDB and managed Redis services (source: https://centrifugal.dev/docs/server/engines). Valkey 9.1.2 was released 2026-09-01 and Redis 8.10.2 on 2026-09-17 (source: GitHub releases API).

Failure behavior#

If the connection to Redis drops, Socket.IO’s adapter keeps delivering only to clients on the local node (source: https://socket.io/docs/v4/redis-adapter/). Messages published during the gap do not reach other nodes later. A backplane outage therefore shows up as partial delivery rather than an error.

When the backplane is the wrong tool#

Redis pub/sub has no history, so it cannot support recovery by itself, and every node receives every message on the channels it subscribes to. NATS, Kafka and managed pub/sub services are alternatives with their own durability. The Socket.IO adapters list covers Postgres, MongoDB and three cloud buses for teams that do not run Redis.


Socket.IO#

A Node.js realtime library and wire protocol: events, acknowledgements, rooms and namespaces, with automatic reconnection and a polling fallback. Server implementations exist for Node.js and Python (see 1.067 for python-socketio and the client-side details); this survey covers the multi-node story, which is the adapter.

Facts#

LicenseMIT (source: https://api.github.com/repos/socketio/socket.io, accessed 2026-10-05)
Latest releasesocket.io 4.8.4, 2026-09-25 (source: npm registry, accessed 2026-10-05)
Starsabout 63k (source: GitHub API, accessed 2026-10-05)
Downloadsabout 23 million a week on npm (source: https://api.npmjs.org/downloads/point/last-week/socket.io, week ending 2026-10-03)
Stall test26 open PRs, 15 merged since 2026-04-05 (source: GitHub search API, accessed 2026-10-05)
TransportsWebSocket, HTTP long-polling; WebTransport support exists as an option (unverified in this pass)
DeliveryAt-most-once by default; ordered per connection; client-to-server retries available; server-to-client at-least-once is left to the app (source: https://socket.io/docs/v4/delivery-guarantees)
RecoveryConnection state recovery restores socket id, rooms and missed packets within maxDisconnectionDuration; works with the in-memory, Redis Streams and MongoDB adapters, not the Redis pub/sub adapter (source: https://socket.io/docs/v4/connection-state-recovery)
PresenceNo presence service; rooms can be listed across nodes, the app builds “who is online”
ScalingEight official adapters: Redis pub/sub, Redis Streams, MongoDB, Postgres, cluster, Google Cloud Pub/Sub, AWS SQS, Azure Service Bus (source: https://socket.io/docs/v4/adapter/)
AuthMiddleware on the handshake; whatever the app implements

Behavior to know#

  • Sticky sessions are required when long-polling is enabled across multiple nodes; without them clients get HTTP 400 from a node that does not know the session (source: https://socket.io/docs/v4/redis-adapter/).
  • The Redis adapter carries no data persistence and no authentication; anyone who can publish to its channels can inject packets, so Redis must stay on a trusted network.
  • If Redis drops, broadcasts reach only clients on the node that sent them.
  • The Redis pub/sub adapter and connection state recovery are mutually exclusive; recovery needs the Redis Streams adapter, which has a much smaller footprint (42 stars; 0.3.1, 2026-03-12; source: GitHub API).
  • The Redis adapter: last release 8.3.0 on 2024-03-13, 10 open PRs and none merged since 2026-04-05 (source: GitHub). It is stable and quiet rather than abandoned, but it is the part of the stack with the least movement.

Trade-offs#

  • Largest community and client matrix of the group; the protocol is custom, so only Socket.IO clients talk to a Socket.IO server.
  • The library lives inside the app process, so deploys and connection counts are coupled to the app.
  • Server-to-client recovery depends on picking the right adapter.
S2: Comprehensive

S2: Technical comparison#

Evidence types, strongest first: measured on one host (the fan-out benchmark), read from the project’s documentation (delivery, recovery, presence, scaling, auth), and read from the repository or registry (versions, licenses, activity). Each figure carries its marker. Where a feature could not be confirmed from a page read during this work, the table says so.

  • measurement-plan.md: what was run and what was deferred
  • feature-comparison.md: side-by-side tables
  • delivery-and-recovery.md: four recovery designs
  • fanout-benchmark.md: latency at 1,000 and 4,000 subscribers

Configuration shapes#

Two short examples where the setting is the difference between servers. Both ran in the benchmark harness (measured 2026-10-05, Centrifugo 6.9.7, Mercure v1.0.3).

Centrifugo: channels are closed until opened#

{
  "client": { "allow_anonymous_connect_without_token": true },
  "channel": { "without_namespace": {
      "allow_subscribe_for_client": true,
      "allow_subscribe_for_anonymous": true } },
  "http_api": { "key": "<api key>" }
}

Without the two allow_subscribe_* flags, a client that connected successfully was refused on subscribe with code 103 (permission denied). Production setups use connection JWTs and subscription tokens instead of anonymous access.

Mercure 1.0: legacy tokens need an explicit switch#

mercure {
    publisher_jwt  {env.MERCURE_KEY} HS256
    subscriber_jwt {env.MERCURE_KEY} HS256
    anonymous
    protocol_version_compatibility 8
}

The default 1.0 configuration validates access tokens in a stricter mode with issuer, audience and scope claims. The block above opts into the older behavior: the publisher_jwt and subscriber_jwt directives are refused unless compatibility mode is set, and in compatibility mode the subscribe parameter is topic, where the strict mode rejected it and asked for match.

Socket.IO: recovery is a constructor option plus an adapter choice#

Connection state recovery is enabled with a connectionStateRecovery option carrying maxDisconnectionDuration; it takes effect with the in-memory adapter, the Redis Streams adapter or the MongoDB adapter, and is not supported by the Redis pub/sub adapter (source: https://socket.io/docs/v4/connection-state-recovery).


Delivery guarantees and recovery#

What at-most-once means to a user#

A message is sent once. If the client’s connection is down at that moment, or its buffer is full, or the node restarts, the message is gone. All of the servers in this survey default to this. They differ in what they offer to repair a gap after the fact.

Four recovery designs#

1. Bounded history stream with a position (Centrifugo). Every channel with history enabled keeps a sliding window of recent publications, each with an incrementing offset, under a stream identifier called an epoch. A reconnecting client sends its last epoch and offset. If the epoch matches and all missed offsets are still in the window, the server replays them and reports recovered: true; otherwise it reports recovered: false and the application must reload state from its own store (source: https://centrifugal.dev/docs/server/history_and_recovery). Window size and TTL are per-namespace settings, and the docs describe streams as ephemeral. An optional positioning check disconnects a client whose stream has a gap (disconnect code 3010) so it recovers on reconnect. The same page describes a cache mode that delivers only the latest publication, for dashboards and tickers.

2. Session and packet backup (Socket.IO). The server hands the client a session id and an offset on every packet. Within maxDisconnectionDuration, a reconnecting client regains its socket id, rooms and the packets it missed. It works with the in-memory, Redis Streams and MongoDB adapters, not the Redis pub/sub adapter, and the docs say recovery will not always succeed (source: https://socket.io/docs/v4/connection-state-recovery). Server-to-client at-least-once beyond that is application logic with event ids and stored offsets (source: https://socket.io/docs/v4/delivery-guarantees).

3. Event id replay (Mercure). Each update carries an id; the browser’s EventSource sends the last one in Last-Event-ID on reconnect, and the hub sends everything after it. The spec says hubs may discard older events by policy and subscribers should not assume nothing was lost (source: https://mercure.rocks/spec). This is built into the EventSource API, so the browser does the bookkeeping.

4. Durable stream with acknowledgements (NATS JetStream). Streams persist messages and consumers track a position; unacknowledged messages are redelivered, giving at-least-once. Replay can start from the beginning, a sequence number or a time (source: https://docs.nats.io/nats-concepts/jetstream). A browser can use it through the WebSocket listener, at the price of handling consumers and acknowledgements in client code.

What has no recovery#

Phoenix Channels, Reverb and other Pusher-protocol servers, and Redis pub/sub have no replay. The Phoenix guide says applications needing stronger guarantees must add persistence and recovery themselves (source: https://phoenix.hexdocs.pm/channels.html). The usual pattern for all of these is to store messages in a database and have a reconnecting client fetch what it missed by id or timestamp, which is also the fallback when any of the four recovery designs above reports failure.

Presence delivery#

Presence is a view of who is connected, and it carries the same at-most-once nature. Centrifugo states that join and leave events are delivered at most once (source: https://centrifugal.dev/docs/server/presence), so a client should treat the presence query as the truth and the events as hints. Phoenix Presence replicates state between nodes without a central source and self-heals (source: https://phoenix.hexdocs.pm/presence.html).


Fan-out latency on one host#

Workload#

N subscribers connect to one server on the same machine, using each server’s official client library in Node.js. A backend publisher sends one JSON message of about 100 bytes every 100 ms (10 messages a second) through the server’s publish API: HTTP POST for Centrifugo, Mercure, the Socket.IO control server and the bare ws control; the native TCP client for NATS. Subscribers receive over WebSocket (Centrifugo, Socket.IO, NATS and ws) or SSE (Mercure). Each message carries a monotonic-clock timestamp taken just before the publish call; each subscriber records the difference when its callback fires. Ten warmup messages are excluded. Latency is therefore publish call to client callback, one way, and includes the HTTP publish for four of the five targets.

Subscribers run in four worker threads that share the same monotonic clock. 1,000 subscribers: 100 measured messages per run, three runs per target, alternating target order. 4,000 subscribers: 60 messages per run, two runs. Recipes and raw output are in harness/1-067-2-realtime-broadcast-pubsub/ (run-all.sh, results/n1000.jsonl, results/n4000.jsonl).

Environment#

(measured 2026-10-05) Linux 6.6.87.2-microsoft-standard-WSL2, aarch64, 8 logical CPUs, 11 GB RAM, with other containers running on the host; Node.js v20.20.0; Centrifugo v6.9.7 (memory engine, defaults except permission flags); Socket.IO 4.8.4 server and client with WebSocket transport only and the default in-memory adapter; Mercure hub v1.0.3 in protocol-compatibility mode 8 with BoltDB defaults; NATS server v2.15.0 with a no-TLS WebSocket listener and @nats-io/nats-core 3.4; ws 8.22 as a floor. One machine runs servers, publisher and all subscribers, so they compete for CPU.

Results#

1,000 subscribers, median across 3 runs (measured 2026-10-05; milliseconds):

Targetp50p90p99maxDelivered
bare ws broadcast (floor)14.927.937.842.2100%
Centrifugo15.727.638.262.2100%
Socket.IO14.430.556.377.3100%
NATS (WebSocket)19.038.856.768.7100%
Mercure (SSE)33.856.188.2117.4100%

4,000 subscribers, median across 2 runs (measured 2026-10-05; milliseconds):

Targetp50p90p99maxDelivered
bare ws broadcast (floor)39.277.893.9104.6100%
Socket.IO41.375.993.9110.1100%
Centrifugo47.980.793.5112.399.98% (one run lost 49 of 240,000)
NATS (WebSocket)50.686.3114.7137.9100%
Mercure (SSE)65.695.6127.9150.4100%

How to read it#

  • The load generator is most of the number. The bare ws floor is 15 ms at p50 with 1,000 subscribers and 39 ms with 4,000, with no framework in the path. Each worker thread processes its subscribers’ callbacks one after another, so latency grows with subscribers per thread. Differences smaller than the floor’s own run-to-run spread are not evidence of a faster server.
  • Run-to-run spread is wide. At 1,000 subscribers the p50 of Centrifugo ranged from 14.5 to 28.5 ms across its three runs, and p99 of Socket.IO from 50 to 65 ms. A first set of runs with every subscriber on a single loopback address gave the same ordering (appendix file n1000_single-ip.jsonl) with some p99 outliers up to 494 ms, which is why subscribers were later spread over four loopback addresses; the host’s ephemeral port range here is only 4,096 ports, which blocked a single-address run at 5,000.
  • What the data supports. Centrifugo, Socket.IO and the bare ws floor are indistinguishable at 1,000 subscribers; NATS sits a few milliseconds above; Mercure is about twice as high at 1,000 subscribers (p50 33.8 against 14.4 to 15.7 ms) and about 1.5x at 4,000 (65.6 against 41 to 48 ms). Mercure’s number includes the cost of the SSE client library in Node and Caddy’s HTTP layer, so it describes this stack, not the Mercure protocol.
  • Delivery was complete in nine of ten runs at 4,000 subscribers. One Centrifugo run delivered 99.98% of messages. The cause was not isolated: the survey did not capture client or server logs for it. Because all servers default to at-most-once, a skipped message under load is within their contract.

What this does not measure#

Multi-node fan-out through a backplane, history or recovery cost, TLS, a real network, publishers that are browsers, message sizes other than about 100 bytes, rates above 10 messages a second, or Phoenix and Reverb (they need an application to host them). Memory use per connection was not recorded. The Mercure run used its legacy-compatibility configuration, because the default 1.0 token rules could not be satisfied from the documentation within the time available.


Feature comparison#

Sources are the projects’ documentation pages cited in S1, read 2026-10-05. A dash means the feature is not built in.

Delivery, history, presence#

CentrifugoSocket.IOPhoenix ChannelsMercureNATS (WS)Reverb / Pusher-protocolRedis pub/sub
Delivery defaultAt-most-onceAt-most-onceAt-most-onceBest effortAt-most-once (core); at-least-once (JetStream)At-most-onceAt-most-once
OrderingPer channel, offset-numberedPer connectionPer topic within a nodeEvent idsPer subject per publisher; per stream in JetStreamPer connectionPer publisher
Replay after disconnectHistory stream, epoch plus offset, recovered flagState recovery (in-memory, Redis Streams, MongoDB adapters)-Last-Event-IDJetStream consumer positions--
Latest-value modeCache recovery mode---JetStream key-value (built on streams)--
PresenceQuery, stats, join/leave (at-most-once)- (list sockets in a room)Phoenix.PresenceSubscriptions API and events-Presence channels-
Client-to-server messagesYes (RPC, publish if allowed)Yes (events, acks)YesNo (use your own HTTP)YesClient events-

Scaling and transport#

CentrifugoSocket.IOPhoenixMercureNATSReverbRedis
Single nodeMemory engineIn-memory adapterYesBoltDBYesYesYes
Multi-node, open sourceRedis, NATS (no history), Postgres (experimental)Eight official adaptersErlang distribution- (license required)Cluster, leaf nodesRedis pub/subCluster, sharded pub/sub
Sticky sessions neededNot stated in the pages read (unverified)Yes with long-pollingNot stated (unverified)Not stated (unverified)No (not applicable)Load balancer, no stated need-
WebSocketYesYesYes-YesYes-
SSE / HTTP streamingYes / Yes--SSE---
Long-polling-YesYes----
WebTransportExperimentalOption exists (unverified)-----
Native browser API without SDKUnidirectional SSE/WS--EventSource---

Authentication and authorization#

Mechanism
CentrifugoConnection JWT (HS/RS/ES, EdDSA via JWKS) or connect proxy; per-channel subscription tokens; namespace permission flags, deny by default
Socket.IOHandshake middleware; channel rules are application code
Phoenixconnect and join callbacks in the app; token signing helpers in the framework
MercureJWT for publisher and subscriber; private updates matched against topic selectors; 1.0 requires issuer/audience-bearing tokens unless a compatibility mode is set
NATSUsers, accounts, NKeys, JWT; per-user subject permissions; WebSocket JWT cookie and no-auth user options
Reverb / PusherApp key and secret; HMAC-signed auth endpoint for private and presence channels
RedisACLs and TLS; the Socket.IO docs warn that unauthenticated adapter channels let a Redis client inject packets

License and edition line#

LicensePaid line
CentrifugoApache-2.0PRO: push API, rate limiting, user and token APIs, tracing, delta compression, SSO (quoted on request)
Socket.IOMITNone
PhoenixMITNone
MercureAGPL-3.0Multi-node, custom transports; Self-Hosted tiers from 1,500 euros a year, Cloud
NATSApache-2.0Managed offerings from a vendor
ReverbMITLaravel Cloud hosting
SoketiAGPL-3.0None; project stalled
Redis 8RSALv2 / SSPLv1 / AGPLv3Redis Software

Measurement plan#

LevelWhat it settlesCostRung reachableCoverage
L0 Registry and repo facts (versions, licenses, stall test)Which projects are alive and under what licenseminutescitedall
L1 Documented semantics (delivery, recovery, presence, scaling) read from each project’s docsWhat each system promisesan hourcitedall
L2 Local fan-out latency: N subscribers on one host, one backend publisherWhether the servers differ by a visible margin at 1,000 and 5,000 subscribers on commodity hardwareabout an hourmeasured-localCentrifugo, Socket.IO, Mercure, NATS WebSocket, plus a bare ws floor
L3 Multi-node scaling, recovery after a forced disconnect, backplane failureHow delivery behaves when it is stresseda day plus a second machinenot rundeferred

L2 is the only level that runs in one container with no external service, so it is the cut line. Phoenix and the Pusher-protocol servers are not in L2: Phoenix needs an Elixir application to measure, and Reverb needs a Laravel one. The coverage gap follows from S1’s finding that Phoenix and Reverb are frameworks rather than standalone servers. L3 items are deferred as follow-ups.

L2 does not reach a browser, so there is no floor model; the numbers are measured-local only.


S2 recommendation#

  1. Fan-out speed does not separate these servers on one host. Centrifugo, Socket.IO and a bare ws broadcaster are within noise at 1,000 and 4,000 subscribers (measured 2026-10-05, local, aarch64 WSL2). NATS is a few milliseconds above; Mercure is about twice as high at 1,000 subscribers and about 1.5x at 4,000, with its HTTP stack and SSE client in the path and the hub in legacy compatibility mode 8. The numbers describe this stack, not the Mercure protocol. Choose on features and operations.
  2. Recovery is the main technical difference. Centrifugo’s history stream with offsets, Socket.IO’s state recovery (on the Streams and MongoDB adapters only), Mercure’s Last-Event-ID replay and NATS JetStream each fix a gap in a different way. Phoenix, Reverb and Redis pub/sub have none.
  3. Multi-node is a license question for Mercure and an adapter question for Socket.IO. Centrifugo and NATS do it in open source; Phoenix does it with Erlang distribution; Mercure needs a paid license.
  4. Presence differs most between Phoenix (replicated, self-healing) and everything else (a roster you query and reconcile).
  5. Protocol decides the client. Only Mercure works with a native browser API and no library for receiving; Pusher-protocol servers reuse Pusher’s clients.

Scaling and backplanes#

How a message published on one node reaches clients connected to another, and what breaks when the link between nodes does.

ServerCross-node mechanismIf the link failsState kept
CentrifugoEngine: Redis (pub/sub plus streams for history), NATS broker (no history), PostgreSQL broker (experimental, needs PostgreSQL 16 or later); Redis sharding by consistent hash, Redis Cluster and Sentinel supported (source: https://centrifugal.dev/docs/server/engines)Depends on engine; history survives node restarts when stored in RedisHistory and presence in Redis
Socket.IOAdapter (eight official). Redis pub/sub adapter relays each broadcast through a Redis channel; sharded variant uses Redis 7 shard channels with static, dynamic and dynamic-private modes (source: https://socket.io/docs/v4/redis-adapter/)Broadcasts reach only clients on the sending node; nothing is queuedNone in the pub/sub adapter; session backup in the Streams and MongoDB adapters
PhoenixPhoenix.PubSub over Erlang distribution; one message sent per additional node (source: https://phoenix.hexdocs.pm/channels.html)Netsplit behavior not covered in the pages read (unverified)Presence replicated in memory
MercureHub transport: BoltDB (single node), Redis/Valkey, PostgreSQL, Kafka or Pulsar for the paid multi-node edition (source: https://mercure.rocks/docs/hub/cluster)Depends on transportEvent log in the transport
NATSNative clustering, superclusters, leaf nodes; JetStream replicates streamsRaft-based replication for JetStream (unverified in this pass)Streams on disk
ReverbRedis pub/sub between Reverb servers; load balancer in front (source: https://laravel.com/docs/12.x/reverb)Servers stop relaying to each otherNone

Connection ceilings#

Documented limits are mostly about the operating system, not the server:

  • Laravel documents a default event loop limited to about 1,024 open files, and the ext-uv loop removes that (source: https://laravel.com/docs/12.x/reverb).
  • Reverb’s docs also point out that one address has a bounded ephemeral port range (28,231 ports on a typical Linux default), which limits connections from a single client IP behind a proxy.
  • On the benchmark host used here the ephemeral port range was 4,096 ports, which capped a single loopback address below 5,000 connections (measured 2026-10-05).

Reading the table#

Redis appears as the shared backplane in four of the six rows. It is also the source of the main failure mode: when it is unreachable, the pub/sub-based paths degrade to per-node delivery without raising an error. Centrifugo’s NATS and PostgreSQL brokers, Mercure’s transports and NATS’s own clustering are the alternatives for teams that do not want Redis, and Socket.IO lists Postgres, MongoDB and three cloud buses.

S3: Need-Driven

S3: Personas#

PersonaNeedsEliminates
ChatPer-room history, recovery, presence, per-room authorization, two-way messagesMercure alone (one-way); Phoenix without Elixir; Redis pub/sub alone (no replay)
Live dashboardsOne-way fan-out of the latest value, many viewers, tolerance for a skipped tickAnything that requires per-user state per message
NotificationsPer-user channels, private delivery, a fallback for offline usersServers with no per-user authorization model
Collaborative presenceWho is here, cursors and selections, fast joins and leaves, low-cost ephemeral dataServers with no presence; long-polling-only paths

Document merging (shared text editing) is a separate problem layered on top of one of these transports and is out of scope.


S3 recommendation#

PersonaPickNextAvoid
ChatCentrifugo (any backend); Phoenix (Elixir)Socket.IO with Redis Streams adapterSoketi; Redis pub/sub alone
Live dashboardsMercure (single node); Centrifugo cache mode (multi-node)NATS where already deployedAnything that needs sticky sessions to scale a one-way feed
NotificationsWhichever fits the stack, with notifications stored in the app databaseMercure private updates; Centrifugo user channelsTreating the real-time server as the notification store
Collaborative presencePhoenix Presence (Elixir); Centrifugo otherwiseReverb or a Pusher-protocol serverRelying on join/leave events without a roster query

One conclusion repeats across all four: every server here can lose a message, so the application database remains the system of record and the real-time server is the delivery layer.


Persona: chat#

Situation#

Rooms or direct conversations, messages that must not silently vanish, users who switch networks and wake laptops, and a need to show who is online or typing.

What matters#

  • History and recovery. After a reconnect the client must catch up or know it cannot. Every server here is at-most-once, so the question is the repair path.
  • Per-room authorization. A user may join some rooms and not others.
  • Two-way traffic. Sending a message and typing indicators.
  • Presence at room scale.

Options#

  • Centrifugo. A history stream per room, with offset and epoch recovery, a recovered signal that says when to reload from the database, presence and join/leave, and per-namespace permissions. Backend can be any language. The open-source edition scales across nodes with Redis.
  • Phoenix Channels. Excellent presence and clustering when the backend is Elixir. Recovery is built by the application: store messages, fetch by id after reconnect.
  • Socket.IO. Rooms are first-class and the community is large. For recovery, run the Redis Streams or MongoDB adapter rather than the Redis pub/sub adapter, and keep an application-level fetch for the cases recovery cannot cover. Presence is application code.
  • NATS JetStream. Strong durability when the team already runs it; per-room permissions and presence are yours.
  • Mercure. One-way, so sending goes through your HTTP API and receiving through SSE. Workable for small chat; presence through the subscriptions API; multi-node needs the paid license.

Verdict#

Centrifugo for a polyglot backend; Phoenix when the stack is Elixir; Socket.IO when the stack is Node and the team accepts storing history in its own database. Whichever is chosen, keep messages in a database and treat server-side history as a cache of recent ones.


Persona: collaborative presence#

Situation#

A shared document, board or form where people want to see who else is here, where their cursors are and what they are selecting. Updates are small, frequent and disposable: a stale cursor position is useless a second later.

What matters#

  • A reliable “who is here” list. Joins, leaves and crashed tabs must resolve to a correct roster.
  • Cheap ephemeral broadcasts for cursor and selection data, with no need for history.
  • Cluster-consistent presence if more than one node serves a room.

Options#

  • Phoenix Presence. Built to replicate a roster across nodes without a central store and to self-heal; changes broadcast on PubSub. The strongest built-in answer for an Elixir team.
  • Centrifugo. Presence query, presence stats and join/leave events, with per-connection info attached from the token. Join and leave are at-most-once, so clients should poll or re-query the roster occasionally. Presence uses extra server state, so enable it only on namespaces that need it (source: https://centrifugal.dev/docs/server/presence).
  • Pusher-protocol servers (Reverb). Presence channels are part of the protocol and expose a member list to clients.
  • Socket.IO. No presence service. Apps keep a roster in the server or Redis and update it from connect and disconnect events; listing sockets in a room works across nodes through the adapter.
  • Mercure. Subscriptions API publishes subscription events, which can drive a roster but gives no client-to-client path.
  • Hosted Supabase Realtime. Offers Broadcast and Presence as a service (vendor: https://supabase.com/docs/guides/realtime); its server is open source (Apache-2.0).

Verdict#

Phoenix Presence if on Elixir; Centrifugo or a Pusher-protocol server otherwise, accepting at-most-once join and leave events and re-querying the roster; Socket.IO when the team already builds on it and is willing to own the roster. Merging concurrent edits to document content is a separate library layered on the same transport.


Persona: live dashboards#

Situation#

Metrics, prices, queue depths or job progress shown to many viewers. Updates are server to client only, a missed tick is replaced by the next one, and the newest value is what matters on reconnect.

What matters#

  • Latest-value recovery. A viewer returning from a sleeping laptop should see the current state, not replay of every tick.
  • Cheap fan-out to many viewers of the same channel.
  • Proxy-friendliness. One-way traffic can ride plain HTTP.

Options#

  • Mercure. SSE with the browser’s own EventSource, no client library, and Last-Event-ID resume. Publishing is a plain HTTP POST from the metrics job. Single node on the open-source hub; multiple nodes need a Self-Hosted license. AGPL-3.0 applies to the hub.
  • Centrifugo. Cache recovery mode delivers only the most recent publication on reconnect, which matches the need exactly. It also offers unidirectional SSE and WebSocket transports that work with native browser APIs.
  • NATS. Subjects per metric and JetStream key-value for the current value; the browser needs the NATS client and permission setup. A fit if NATS already carries the metrics.
  • Socket.IO. Works, with rooms per dashboard; replay is not needed here, so the adapter choice matters only for multi-node scaling.
  • Redis pub/sub as the only layer. Not reachable from browsers.

Verdict#

Mercure for a single-node, minimal-client setup; Centrifugo when more than one node is needed without a license fee, or when the latest-value recovery is required; NATS when it is already the metrics bus. The fan-out numbers in the benchmark page show all four deliver within tens of milliseconds at 1,000 subscribers on one host, which is far below dashboard refresh needs.


Persona: notifications#

Situation#

In-app alerts to individual users: a comment reply, a finished export, a payment result. Each message is for one user (or a small group), must not leak to anyone else, and matters even if the user is offline when it fires.

What matters#

  • Per-user channels with enforced privacy.
  • A fallback for offline users. Real-time servers deliver to connected clients; a user who is away needs the notification stored and shown on return, or sent through email or mobile push.
  • Many mostly idle connections, with low traffic per connection.

Options#

  • Centrifugo. User-limited channels (the # boundary reserves a channel to a named user) and connection JWTs make per-user delivery a configuration matter (source: https://centrifugal.dev/docs/server/channels). A push notification API for device tokens exists in the paid PRO edition.
  • Mercure. Private updates are matched against the subscriber’s token, so a per-user topic stays private; SSE reconnects with Last-Event-ID replay recent ones.
  • Laravel Reverb or any Pusher-protocol server. Private channels with a signed auth endpoint, a natural fit if the app is on Laravel or already uses Pusher clients. No replay.
  • Phoenix Channels. A per-user topic joined after authentication; recovery is the app’s job.
  • Socket.IO. One room per user id, joined in handshake middleware.
  • Hosted Pusher or Ably. Per-connection pricing makes a large number of idle connections the cost driver; compare against machine cost.

Verdict#

The deciding step is storing notifications in the application database and using the real-time server only to nudge connected clients. With that in place, any server here works; choose by the language and protocol the team already runs. Mobile and offline delivery is outside these servers and covered in 1.119.2.

S4: Strategic

S4: Viability#

Signals read on 2026-10-05: latest release, last push, the stall test (open PRs against PRs merged since 2026-04-05), license, and what the license or edition does to a self-hosting team over time. Source for every count is the GitHub REST and search APIs unless noted.


Licenses and editions#

OptionLicenseWhat the license asksWhere the paid line sits
CentrifugoApache-2.0Notice and attributionPRO: push API, rate limiting, user blocking and token revocation APIs, tracing, SSO for the admin UI, delta compression, more (source: https://centrifugal.dev/docs/pro/overview)
Socket.IO and adaptersMITNoticeNone
Phoenix, Phoenix.PubSubMITNoticeNone
NATS serverApache-2.0Notice and attributionManaged hosting by vendors
Laravel ReverbMITNoticeLaravel Cloud hosting
Mercure hubAGPL-3.0Source for modified versions offered over a networkMulti-node and custom transports in the Self-Hosted license; Cloud
SoketiAGPL-3.0Same as MercureNone; stalled
Redis 8RSALv2, SSPLv1 or AGPLv3 (choose one)RSALv2 bars offering Redis as a managed service; AGPLv3 asks for source of modified network-served versions (source: https://redis.io/legal/licenses/)Redis Software and Cloud
ValkeyBSD-3-ClauseNoticeNone
Supabase Realtime serverApache-2.0NoticeSupabase plans

What AGPL changes in practice#

AGPL-3.0 applies to the program covered by it. A hub run unmodified beside an application, and reached only over the network, does not extend the license to the application. A team that patches the hub and exposes the patched version to users owes those users the patch source. Legal review of a specific deployment is outside this survey.

Editions that change what you can do#

Mercure’s free hub is limited to one node; the license is the high-availability switch. Centrifugo’s open-source edition scales across nodes with Redis, so its paid PRO line sits at operations and push features. A requirement list that includes multi-node, push notifications and rate limiting reads very differently across the two.


Portability and exit#

How hard it is to leave each option, in terms of the things the application is coupled to.

OptionClient couplingServer-side couplingExit path
CentrifugoCentrifugo SDKs and protocolHTTP/gRPC publish API; channel and namespace configRewrite client subscriptions and publish calls; history is not portable
Socket.IOSocket.IO client and protocolRooms, namespaces and events inside app codeRewrite both sides; the most application-embedded option
MercureNative EventSourceOne HTTP POST per publish, JWT in a headerSmallest exit cost; any SSE-capable hub can replace it, and the three tiers share one protocol
Phoenix ChannelsPhoenix JS clientChannel modules in ElixirTied to Elixir
NATSNATS clientSubjects, JetStream consumersMoves with the broker; browser code changes
Reverb or SoketiPusher clientsPusher HTTP APICheapest to move between Pusher-protocol servers and hosted Pusher
Redis pub/subNone for browsersPub/sub commandsReplace with Valkey with no code change; other brokers need an adapter

Practice#

Put publishing behind one function in the backend and keep subscription setup in one client module. That confines a migration to two files. Where recovery matters, keep message storage in the application database so that no server’s history format becomes the only copy.


S4 recommendation#

  • Safest long-term bets by activity and license: NATS (Apache-2.0, 415 merges in six months), Centrifugo (Apache-2.0, 117), Phoenix (MIT, 126).
  • Take care with: Soketi (stalled; move to Reverb or Centrifugo), the Socket.IO Redis Streams adapter (small and pre-1.0 yet required for recovery), Mercure’s AGPL-3.0 and paid multi-node line.
  • Keep the transport behind your own thin publishing function. Backends publish through an HTTP call in Centrifugo, Mercure, Reverb and Soketi alike; wrapping that call in one module keeps a later switch to a different server contained to that module and the client SDK.
  • Standards help portability. Mercure is a published protocol with a spec, Pusher’s protocol has two independent open implementations, and SSE and WebSocket are browser standards. Socket.IO, Phoenix, Centrifugo and NATS clients are tied to their servers.
  • WebTransport is now in all three major engines (Safari 26.4, March 2026 per one secondary report and caniuse.com/webtransport; Chrome since 97; Firefox since 114; source: https://webrtc.ventures/2026/04/webtransport-is-now-baseline-what-it-means-for-real-time-media/, accessed 2026-10-05), but Centrifugo still labels its server support experimental and not recommended for production (source: https://centrifugal.dev/docs/transports/webtransport). Plan on WebSocket and SSE.

Viability by option#

OptionLatest releaseOpen PRs / merged since 2026-04-05LicenseReading
Centrifugov6.9.7, 2026-09-296 / 117Apache-2.0, paid PROHealthy and commercially backed through PRO; the open-source edition keeps the core features
centrifuge (Go library)v0.39.3, 2026-09-309 / 68MITHealthy; still pre-1.0
Socket.IO4.8.4, 2026-09-2526 / 15MITHealthy but slow; very large user base
Socket.IO Redis adapter8.3.0, 2024-03-1310 / 0MITQuiet: patches waiting, none merged, repo last pushed 2026-06-22. A finished component or an unattended one; check the issue tracker before depending on new Redis features
Socket.IO Redis Streams adapter0.3.1, 2026-03-12not runMITSmall (42 stars) and pre-1.0; the only Redis path that supports connection state recovery
Mercurev1.0.3, 2026-10-0310 / 187AGPL-3.0, paid tiersVery active; 1.0 changed auth defaults. High availability is paid
Phoenixv1.8.15, 2026-09-2532 / 126MITHealthy
phoenix_pubsubno GitHub release; repo pushed 2026-09-176 / 3MITSmall, stable library
NATS serverv2.15.0, 2026-09-1722 / 415Apache-2.0Healthiest in the set by merge volume
Laravel Reverbv1.12.0, 2026-09-223 / 21MITHealthy; tied to the Laravel roadmap
Soketi1.6.1, 2024-03-2542 / 0AGPL-3.0Stalled: patches arriving, none merged; open “is it maintained” issue since 2026-02-12
Redis8.10.2, 2026-09-17not runRSALv2 / SSPLv1 / AGPLv3Active; license changed twice since 2024
Valkey9.1.2, 2026-09-01not runBSD-3-ClauseActive fork under a permissive license
Supabase Realtime (server)v2.140.7, 2026-10-0130 / 415Apache-2.0Very active; coupled to the Supabase platform

Patterns#

  • License changes are the main strategic risk in the backplane. The Redis license moved from BSD to source-available to a tri-license that includes AGPL in the span of 2024 to 2025 (source: https://redis.io/legal/licenses/). A team that wants a permissive license underneath can use Valkey, which Centrifugo lists as compatible.
  • Open-source ceilings. Mercure’s free hub is single-node; Centrifugo’s PRO adds operational and push features, not core multi-node. Check which side of the line a requirement sits on before committing.
  • Components age differently from their servers. Socket.IO is active while the Redis adapter’s last release is from March 2024. Adapters are where durability questions land, so their repositories deserve their own check.
Published: 2026-10-05 Updated: 2026-10-05