Shared poll subscriptions 🔮
Shared poll subscriptions is an experimental feature available since Centrifugo v6.8.0. Configuration options, client SDK API, and proxy protocol may change in future releases. At this point only centrifuge-js SDK supports shared poll subscriptions on the client side.
Many applications poll the backend to keep data fresh — vote counts, stock prices, live scores, inventory levels, configuration. Polling is simple but wasteful: most requests return unchanged data, backend load grows linearly with the number of clients, and there is an inherent trade-off between update freshness and request rate.
Shared poll subscriptions move the polling from clients to Centrifugo. Clients establish a persistent connection, register their interest in specific items, and Centrifugo polls the backend on a configurable schedule — collecting current data and pushing only the changes to interested clients. Instead of 10,000 clients each polling your backend every second, Centrifugo makes one request per cycle per Centrifugo node and fans out updates to all subscribers on that node. The backend load depends on the number of unique items being watched and the number of Centrifugo nodes, not on the number of connected clients (O(unique_items × active_nodes) instead of O(clients)). See How it works for the multi-node behaviour and Centrifugo PRO's shared poll relay for centralised polling that drops the × active_nodes factor.
Your backend just answers one question: "what is the current state of these items?" This works with any data source you can read from: your own database, a third-party API, a legacy system. Since Centrifugo re-polls on a schedule, all clients always converge to the latest data (eventual consistency) — even if something is temporarily missed, the next poll cycle catches up.
Why shared poll?
Each client sees a different subset of items (different pages, filters, search results), the set changes as users scroll, and the total item universe is large while any single client cares about a small slice.
-
Traditional channels — one channel per item means 50 visible posts need 50 subscriptions with full lifecycle overhead. A single channel for all posts solves that but delivers every update to every client regardless of what they're watching, and lacks granular per-item authorization. Shared poll combines the best of both: per-item granularity with per-key HMAC authorization, but only a single subscription.
-
Push from the backend — couples every write path to a publish call, requires the backend to know what's currently tracked, and doesn't work for third-party data sources or legacy systems. Shared poll inverts this: Centrifugo tells the backend which items are watched, and the backend just returns current state.
Overview
Clients subscribe to a shared poll channel, then track specific keys to start receiving data. Centrifugo aggregates tracked keys across all connections and polls the backend periodically, fetching only tracked items in batches. Centrifugo detects changes and pushes only updated items to interested clients.
The trade-off is latency: updates arrive within the polling interval (configurable, default 10s) rather than instantly on write. This is acceptable for use cases like vote counts, view counts, prices, and scores where near-real-time is sufficient. For instant delivery, use direct publish or regular pub/sub channels.
How it works
- Clients subscribe to a shared poll channel and track specific items by key
- Centrifugo collects all tracked keys across all connections
- On a configurable interval, Centrifugo calls your backend proxy with the list of tracked keys
- Your backend returns current data for each key (and optionally a version)
- Centrifugo detects changes and pushes only updated items
- Items returned with
removed: trueare removed from tracking and clients are notified
When items are split into multiple batches, dispatches are spread evenly over the refresh interval to reduce burst load.
Refresh cycle (interval=1s, 3000 tracked keys, batch_size=1000)
Clients Centrifugo Backend
─────── ────────── ───────
│ │ │
│ track(keys) │ │
├────────────────────►│ │
│ │ collect all tracked keys │
│ │ split into batches │
│ │ │
│ t=0 │── batch 1 (keys 1-1000) ─────►│
│ │ │
│ t=333ms │── batch 2 (keys 1001-2000) ──►│
│ │ │
│ t=666ms │── batch 3 (keys 2001-3000) ──►│
│ │ │
│ │◄── responses ─────────────────│
│ │ │
│ │ compare versions │
│ │ per client │
│ │ │
│ update(key, data) │ │
│◄────────────────────│ push only changed items │
│ │ │
│ t≈1s │ next cycle starts │
│ │ │
subscribe is lightweight (no data delivery, no recovery) — track is where data starts flowing. Shared poll works for both authenticated and anonymous users.
On a single Centrifugo node, backend load scales with the number of unique tracked items, not connected clients — if many clients watch the same 200 items, those 200 items are polled once per cycle. In a multi-node Centrifugo deployment, each node runs its own refresh worker and polls the backend independently for the keys tracked by its own connections. With N nodes that each have at least one connection tracking a given key, that key is polled N times per cycle. The backend still scales with O(unique_items × active_nodes) rather than O(clients), but operators sizing the backend should account for the node count. Centrifugo PRO's shared poll relay centralises polling into a single process so the backend sees one request per cycle regardless of node count.
Authorization with HMAC signatures
Shared poll uses HMAC signatures to authorize which items a client can track. Your backend generates a signature over the list of keys, and the client presents it when calling track(). This ensures clients can only track items your backend has explicitly authorized.
The signature string has the format:
iat:exp:hmac_hex
Where:
iat— issued-at Unix timestamp (seconds)exp— expiry Unix timestamp (seconds),0for no expiryhmac_hex— hex-encoded HMAC-SHA256
The HMAC is computed over the following payload, with fields joined by null bytes (\x00):
HMAC-SHA256(secret, iat \x00 exp \x00 user_id \x00 channel \x00 keys_hash)
Where keys_hash is the hex-encoded SHA-256 of keys joined with null bytes (\x00), and secret is the hmac_secret_key from the shared_poll configuration. The user_id is the authenticated user's ID (empty string for anonymous users).
The null-byte separator is important: using a printable separator like : would make the payload for (user="alice", channel="news:tech") byte-identical to the one for (user="alice:news", channel="tech"), allowing a signature issued for one tuple to be replayed against the other. NUL bytes never appear in real channel names or user IDs, so the field boundaries are unambiguous.
The outer signature string itself (iat:exp:hmac_hex) still uses : separators — its fields are integers and hex by construction, so no ambiguity is possible there.
The keys are hashed in the order they appear in the request — no canonical sort. Your backend must sign over the keys in the same order it returns them to the client; the SDK forwards that order verbatim to the server, which verifies against the keys received in the track() call.
Your backend generates this signature when the client requests authorization for a set of keys. Centrifugo verifies the HMAC on every track() call and rejects requests with an invalid signature, or with a signature whose exp is more than 5 seconds in the past (a small fixed grace period for clock skew and in-flight requests). For already-tracked keys, an additional namespace-configurable window — track_expired_extra_delay, default 25s — lets the client refresh the signature before the server drops the keys from per-connection state. The two windows are independent: the 5s applies at signature-verify time, the 25s applies to server-side bookkeeping of keys already authorized.
Backend signature generation
- Python
- NodeJS
- Go
- Java
- PHP
- Ruby
import hashlib, hmac, time
def make_shared_poll_signature(secret, user_id, channel, keys, ttl):
now = int(time.time())
exp = now + ttl
kh = hashlib.sha256("\x00".join(keys).encode()).hexdigest()
payload = f"{now}\0{exp}\0{user_id}\0{channel}\0{kh}"
mac = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
return f"{now}:{exp}:{mac}"
const crypto = require('crypto');
function makeSharedPollSignature(secret, userId, channel, keys, ttl) {
const now = Math.floor(Date.now() / 1000);
const exp = now + ttl;
const kh = crypto.createHash('sha256').update(keys.join('\x00')).digest('hex');
const payload = `${now}\0${exp}\0${userId}\0${channel}\0${kh}`;
const mac = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return `${now}:${exp}:${mac}`;
}
import (
"crypto/hmac"
"crypto/sha256"
"fmt"
"strings"
"time"
)
func makeSharedPollSignature(secret, userID, channel string, keys []string, ttl int) string {
now := time.Now().Unix()
exp := now + int64(ttl)
kh := sha256.Sum256([]byte(strings.Join(keys, "\x00")))
payload := fmt.Sprintf("%d\x00%d\x00%s\x00%s\x00%x", now, exp, userID, channel, kh)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(payload))
return fmt.Sprintf("%d:%d:%x", now, exp, mac.Sum(nil))
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
public static String makeSharedPollSignature(String secret, String userId, String channel, String[] keys, int ttl) throws Exception {
long now = System.currentTimeMillis() / 1000;
long exp = now + ttl;
String kh = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(String.join("\0", keys).getBytes(StandardCharsets.UTF_8)));
String payload = String.format("%d\0%d\0%s\0%s\0%s", now, exp, userId, channel, kh);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return String.format("%d:%d:%s", now, exp, HexFormat.of().formatHex(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))));
}
function makeSharedPollSignature(string $secret, string $userId, string $channel, array $keys, int $ttl): string {
$now = time();
$exp = $now + $ttl;
$kh = hash('sha256', implode("\x00", $keys));
$payload = "{$now}\0{$exp}\0{$userId}\0{$channel}\0{$kh}";
return "{$now}:{$exp}:" . hash_hmac('sha256', $payload, $secret);
}
require 'openssl'
require 'digest'
def make_shared_poll_signature(secret, user_id, channel, keys, ttl)
now = Time.now.to_i
exp = now + ttl
kh = Digest::SHA256.hexdigest(keys.join("\x00"))
payload = "#{now}\0#{exp}\0#{user_id}\0#{channel}\0#{kh}"
"#{now}:#{exp}:#{OpenSSL::HMAC.hexdigest('SHA256', secret, payload)}"
end
Secret key rotation
To rotate the HMAC secret without disrupting active clients, Centrifugo supports a two-key transition:
- Set
hmac_previous_secret_keyto your current secret - Set
hmac_secret_keyto the new secret - Optionally set
hmac_previous_secret_key_valid_untilto a Unix timestamp — signatures issued (byiat) after this time must use the new key
During the transition window, Centrifugo accepts signatures signed with either key. Once all clients have refreshed their signatures (which happens automatically via the getSignature callback on TTL expiry), remove the previous key from the configuration.
Configuration
Shared poll subscriptions are configured per channel namespace using subscription_type: "shared_poll".
Minimal example
{
"shared_poll": {
"hmac_secret_key": "your-secret-key"
},
"channel": {
"proxy": {
"shared_poll_refresh": {
"endpoint": "http://localhost:3001/centrifugo/refresh",
"timeout": "5s"
}
},
"namespaces": [
{
"name": "post_votes",
"subscription_type": "shared_poll",
"shared_poll": {
"refresh_interval": "1s",
"max_keys_per_connection": 5000
},
"allow_subscribe_for_client": true
}
]
}
}
Proxy configuration
The shared poll refresh proxy defines how Centrifugo calls your backend to fetch item data. It can be configured in two ways:
Default proxy — set in channel.proxy.shared_poll_refresh (used when proxy_name is not specified in namespace config):
{
"channel": {
"proxy": {
"shared_poll_refresh": {
"endpoint": "http://localhost:3001/centrifugo/refresh",
"timeout": "5s"
}
}
}
}
Named proxy — reference a proxy from the proxies array by name:
{
"proxies": [
{
"name": "poll_backend",
"endpoint": "http://localhost:3001/centrifugo/refresh",
"timeout": "5s"
}
],
"channel": {
"namespaces": [
{
"name": "post_votes",
"subscription_type": "shared_poll",
"shared_poll": {
"proxy_name": "poll_backend"
},
"allow_subscribe_for_client": true
}
]
}
}