Skip to main content

Map subscriptions ๐Ÿ”ฎ

Experimental

Map subscriptions is an experimental feature available since Centrifugo v6.8.0. All its parts โ€” configuration options, client SDK API, server API โ€” may change in future releases based on user feedback. At this point only centrifuge-js SDK supports map subscriptions on the client side.

A map subscription delivers a real-time key-value collection whose lifecycle is managed by Centrifugo. The map broker stores the entries, tracks per-key updates, and synchronizes them to every subscribed client โ€” clients receive a complete snapshot on subscribe, catch up after disconnects, and get live updates in real time. The application doesn't need to maintain its own snapshot table, write a separate "fetch initial state" endpoint, or reconcile race conditions between an HTTP read and a WebSocket stream. That's the whole point: Centrifugo owns the collection, the SDK keeps a live mirror.

Typical use cases โ€” workloads where Centrifugo is the natural store for the data:

  • Cursor positions, typing indicators โ€” short-lived per-client entries, no need for an external DB.
  • Map presence โ€” map_clients (one entry per connection) and map_users (one entry per user) are server-managed presence built on this same sync model.
  • Lobby members, IoT device fleet, feature flags, live polls โ€” collections that are naturally key-shaped, where having Centrifugo hold the canonical entries (with per-key TTL, optional persistence) avoids building a separate small-store + change-feed yourself.
  • Scoreboards, inventories โ€” persistent keyed state with efficient reconnect recovery.
YOUR TRANSACTIONBEGINyour business logiccf_map_publish(...)POSTGRESQLstate + outboxRolls back?Nothing gets sent.CENTRIFUGOPicks up changesfrom outboxWebSocket pushCLIENTSsyncupdate

When to use map subscriptions โ€” and when not toโ€‹

Map subscriptions are the natural fit when the broker should be the canonical store for a keyed collection โ€” your application is comfortable letting Centrifugo own the entries and reads them back through subscriptions (or, for backends that need it, the server map_read_state API).

When your data already lives in your own application database (orders, documents, tickets, notifications), there's an alternative shape worth knowing about: a stream subscription with a getState callback, backed by the PostgreSQL stream broker. You write to your own tables and call cf_stream_publish in the same SQL transaction โ€” clients render state from your own schema and receive events for incremental changes, with no duplicate state in the broker. See Transactional publishing for stream subscriptions with PostgreSQL and the pg_stream_broker example.

That said, map subscriptions can still be the right answer even when the data has a home elsewhere โ€” if the convenience matters more to you than the duplication. With map subscriptions you get the synchronized snapshot, paginated state delivery, and per-key TTL with auto-removal out of the box. With stream + getState you have to build the snapshot endpoint yourself and reason about what your subscription consumer rebuilds on the client. Neither is universally better โ€” pick by what you'd rather own.

You needโ€ฆNatural fit
Ordered events (chat, notifications, activity feeds)Stream subscription
Latest value of a single thing (with cache recovery)Stream subscription + cache recovery
Real-time sync of data already in your app DB, app DB stays the only source of truthStream subscription + getState (pattern)
A Centrifugo-managed keyed collection (cursors, presence, IoT fleet, feature flags, lobbies)Map subscription
Centrifugo-managed keyed collection backed by transactional PostgreSQLMap subscription + PostgreSQL map broker
Real-time sync of data in your app DB, where the convenience of map subscriptions outweighs the cost of mirroring entries into cf_map_stateMap subscription + PostgreSQL map broker (mirror via transactional cf_map_publish from your own SQL transactions)

The PostgreSQL map broker is for the last row โ€” it makes the broker-owned collection durable and queryable, and lets your backend update it inside its own SQL transactions when the data lives in cf_map_state rather than in your own table.

Design overviewโ€‹

Map channels add a state synchronization layer on top of regular channels: a set of key-value entries that clients can query, paginate, and receive incremental updates for.

Client subscription protocolโ€‹

When a client subscribes to a map channel, it goes through phases to build consistent state:

STATEPaginate fullkey-value stateSTREAMCatch up on changes(recoverable / persistent)LIVEReal-timePUB/SUB updatesMODESEphemeralEntries auto-expire. No stream history. On reconnect โ€” full state snapshot.STATELIVEskip STREAMRecoverableEntries auto-expire. Stream-based catch-up on reconnect. Falls back to snapshot if too far behind.STATESTREAMLIVEPersistentEntries persist until removed. Stream-based catch-up on reconnect. Falls back to snapshot if too far behind.STATESTREAMLIVESDK EVENTSโ—€โ”€โ”€syncโ€” full state snapshotโ—€โ”€โ”€updateโ€” incremental changeSDK handles all phases transparently.App receives only sync and update events.
  1. State phase โ€” client paginates through the current key-value state from the broker
  2. Stream phase โ€” client catches up on changes that occurred during state pagination (recoverable/persistent modes only)
  3. Live phase โ€” client receives real-time updates via PUB/SUB

The SDK handles all phases transparently โ€” the application receives sync (full state ready) and update (incremental change) events.

Subscription typesโ€‹

Each namespace must declare which subscription type it supports. The client specifies the matching type when subscribing:

TypeDescription
streamTraditional PUB/SUB with optional history stream, automatic recovery from stream, and cache recovery mode (default type, Centrifugo always had it)
mapMap subscription โ€” keyed state with real-time updates, configurable sync and retention via mode (ephemeral/recoverable/persistent) with stream-based catch-up in recoverable/persistent modes, per-key TTL support, and paginated state sync protocol
map_clientsA special type of map subscription for presence โ€” one entry per client connection, automatically managed by the server. Both joins and leaves are delivered immediately. The system is eventually consistent: if a remove operation to the broker fails (e.g. due to a transient network error), the stale entry will expire after the configured map.key_ttl rather than lingering indefinitely. map.key_ttl is required for this type (mode must be ephemeral or recoverable โ€” persistent is rejected at config-load because TTL-based cleanup is the only fallback)
map_usersA special type of map subscription for presence โ€” one entry per user ID, automatically managed by the server. New users appear immediately, but removals are driven by key TTL โ€” since a single user may have multiple connections, the entry can't be removed when one connection disconnects. Instead, it expires after the configured map.key_ttl once the last connection for that user leaves the channel. map.key_ttl is required for this type (mode must be ephemeral or recoverable โ€” persistent is rejected at config-load)

The map_clients and map_users types are automatically managed by the server for presence tracking. The map type is the general-purpose map subscription where the application controls keys and values. It's like real-time map which is synchronized to clients.

Map modesโ€‹

Each map namespace requires a mode setting. Modes control two things: whether entries auto-expire and whether a change stream exists for efficient reconnect recovery.

ModeEntries expire?Change stream?On reconnect
ephemeralYes (key_ttl)NoFull state snapshot
recoverableYes (key_ttl)YesCatch up from stream (falls back to snapshot if too far behind)
persistentNo (until explicitly removed)YesCatch up from stream (falls back to snapshot if too far behind)

Each step adds capability: ephemeral is the lightest โ€” no stream overhead. recoverable adds a change stream so clients recover efficiently on reconnect instead of re-fetching everything. persistent is the same as recoverable but entries live forever instead of expiring.

Which mode to pick:

Use caseModeWhy
Cursors, typing indicatorsephemeralShort-lived data, no need for stream overhead
Presence, heartbeatsrecoverableEntries auto-expire, but reconnecting clients catch up from stream instead of re-fetching
Time-limited polls, sessionsrecoverableEntries auto-expire, efficient reconnect recovery
Scoreboards, inventories, collaborative docspersistentPermanent state with efficient reconnect recovery
When your app already owns state

Map subscriptions fit "key-value real-time collection" use cases where the broker is the store โ€” presence, cursors, feature flags, IoT device fleet, lobby members. If your data already lives in your own database (orders, documents, tickets) and you want Centrifugo to just deliver change events, use a stream subscription with a getState callback backed by the PostgreSQL stream broker โ€” your writes and publishes commit together in one SQL transaction, and clients render state from your own schema. See Transactional publishing for stream subscriptions with PostgreSQL and the pg_stream_broker example.

Map brokersโ€‹

Map subscriptions require a map broker โ€” a backend that stores the keyed state and coordinates updates. By default, Centrifugo uses an in-memory map broker. Centrifugo supports three map broker types.

Centrifugo PRO allows configuring different map brokers for different channel namespaces โ€” for example, ephemeral cursor data in Redis and persistent scoreboard state in PostgreSQL.

Memoryโ€‹

In-memory storage. Single-node only. State is lost on restart (even when persistent mode is used).

config.json
{
"map_broker": {
"type": "memory"
}
}

Good for development and single-node setups. Memory is the default map broker type, so you don't need to configure it explicitly.

Redisโ€‹

Redis-backed storage for distributed multi-node deployments. Uses atomic Lua scripts for all operations.

Redis Cluster is supported only with sharded PUB/SUB enabled, which is a Centrifugo PRO feature. The open-source version works with a single Redis instance. Client-side consistent sharding across multiple standalone Redis nodes is still an option for OSS users.

config.json
{
"map_broker": {
"type": "redis",
"redis": {
"address": "localhost:6379"
}
}
}

Key options:

OptionTypeDefaultDescription
cleanup_intervalduration"1s"How often to remove expired entries. Set to "-1" to disable
cleanup_batch_sizeinteger100Max entries processed per channel per cleanup cycle
idempotent_result_ttlduration"5m"TTL for idempotent operation result cache

Redis map broker supports the same connection options as the Redis engine (address, cluster addresses, Sentinel, TLS, etc.).

Avoid Redis eviction policies with recoverable/persistent modes

When using recoverable or persistent mode, Redis must retain all stream data for recovery to work. If Redis evicts keys due to memory pressure, clients will be unable to catch up from the stream โ€” making stream-based catch-up impossible. Configure Redis with maxmemory-policy noeviction, carefully monitor memory usage, and plan capacity accordingly.

PostgreSQLโ€‹

PostgreSQL-backed storage for durable, persistent state. Requires PostgreSQL 16 or later. Centrifugo creates the required tables automatically on startup (unless skip_schema_init is set).

config.json
{
"map_broker": {
"type": "postgres",
"postgres": {
"dsn": "postgres://user:pass@localhost:5432/dbname?sslmode=disable"
}
}
}

Key options:

OptionTypeDefaultDescription
dsnstringPostgreSQL connection string (required)
pool_sizeinteger16Maximum connection pool size
num_shardsinteger8Number of delivery worker shards. Use the default for now โ€” more guidance will be provided later
ttl_check_intervalduration"1s"How often to check for expired keys
cleanup_intervalduration"1m"How often to clean up expired stream/meta entries
idempotent_result_ttlduration"5m"TTL for idempotency results
binary_databooleanfalseUse BYTEA instead of JSONB for data columns
table_prefixstring"cf"Namespace prefix for table and function names. Default produces cf_map_* tables and cf_map_publish(...) functions. Use distinct prefixes for multi-tenant deployments sharing one PostgreSQL instance
stream_retentionduration"24h"How long stream entries are kept
use_notifybooleanfalseEnable LISTEN/NOTIFY for low-latency delivery. See connection pooler note
notify_dsnstring""Separate DSN for the LISTEN connection. Use a direct PostgreSQL URL when dsn points at PGBouncer or another pooler incompatible with LISTEN/NOTIFY
skip_schema_initbooleanfalseSkip automatic table creation on startup
partition_lookahead_daysinteger2Number of future daily partitions to pre-create
partition_retention_daysinteger7Partitions older than this are dropped automatically. Set to 0 for unlimited retention

The stream table is always partitioned by created_at (daily). Old partitions are dropped entirely โ€” this is instant and avoids the table bloat and expensive vacuum operations that row-level DELETE produces at scale. The partition_retention_days setting controls how many days of partitions to keep; the partition_lookahead_days setting controls how many future partitions to pre-create (to avoid write failures at the day boundary).

Centrifugo PRO extends the PostgreSQL map broker with:

  • In-memory cache layer โ€” keeps channel state in memory on each node, reducing backend reads and improving subscribe latency
  • Read replicas โ€” distributes read load across PostgreSQL replicas
  • Broker fan-out โ€” only one node per shard polls PostgreSQL, then publishes updates through Redis or NATS. Reduces PostgreSQL load proportionally to cluster size โ€” essential for running many Centrifugo nodes

Transactional publishingโ€‹

A unique advantage of the PostgreSQL map broker is that your application can call Centrifugo's SQL functions directly within your own database transactions. This guarantees atomicity โ€” the map state update and your business logic commit or rollback together.

The architecture uses an outbox pattern โ€” all writes go into PostgreSQL tables atomically, and Centrifugo's outbox workers pick up new entries and deliver them to clients:

YOUR APPLICATIONBEGINBusiness logic / SQLcf_map_publish(...)COMMITBoth your data and thereal-time state updatecommit or rollbacktogether atomically.Also: server API or client SDKPOSTGRESQLcf_map_stateCurrent key-value snapshotcf_map_streamChange log (outbox)Sharded for parallelismcf_map_metaEpoch + offset trackingpg_notify()All updated atomically in one transactionpollCENTRIFUGOOutbox WorkersOne per shardCursor-based pollingPUB/SUBWebSocket deliveryState ReadsReadState / ReadStreamTTL / CleanupExpires keys, trims streamCLIENTSsync eventupdate eventSDK managesstate, stream,and live phasestransparently.

When your transaction commits, the state table (cf_map_state) and the stream/outbox table (cf_map_stream) are updated atomically. Centrifugo runs a pool of outbox workers (one per shard) that poll the stream table for new entries and deliver them to subscribed clients via WebSocket. When use_notify is enabled, PostgreSQL's LISTEN/NOTIFY wakes the workers immediately โ€” otherwise they poll every 100ms. This eliminates the dual-write problem: if the transaction rolls back, no real-time update is ever sent.

Centrifugo automatically creates these SQL functions when the PostgreSQL map broker initializes the schema:

FunctionDescription
cf_map_publish(...)Publish or update a key. Returns suppressed/suppress_reason for conditional checks
cf_map_publish_strict(...)Same as cf_map_publish, but raises a PostgreSQL exception on suppression (e.g. CAS conflict, key exists) instead of returning a flag
cf_map_remove(...)Remove a key. Returns suppressed/suppress_reason
cf_map_remove_strict(...)Same as cf_map_remove, but raises an exception if the key is not found
note

cf_map_expire_keys function is also created but is for Centrifugo internal use only โ€” do not call it from application code.

When binary_data option is enabled, the schema uses BYTEA columns instead of JSONB for data fields, and all tables and functions use the cf_binary_map_ prefix (e.g. cf_binary_map_publish, cf_binary_map_state). This is useful when data payloads are not valid JSON (e.g. Protobuf-encoded).

When a custom table_prefix is configured (e.g. "myapp"), all table and function names use that prefix instead of the default cf โ€” for example, myapp_map_publish(...), myapp_map_state, etc.

Common parameters for cf_map_publish:

ParameterTypeDescription
p_channelTEXTChannel name (required)
p_keyTEXTEntry key (required)
p_dataJSONBEntry data (required)
p_key_modeTEXT'if_new' (insert only) or 'if_exists' (update only)
p_key_ttlINTERVALPer-key TTL
p_meta_ttlINTERVALChannel metadata TTL

The function returns a row with channel_offset, epoch, suppressed, and suppress_reason fields.

Example โ€” recording a vote atomically (dedup + data update in one transaction):

BEGIN;
-- 1. Dedup: only allow one vote per user per option.
SELECT * FROM cf_map_publish(
p_channel := 'poll:votes',
p_key := 'poll1:opt_0:user42',
p_data := '{"voted": true}'::jsonb,
p_key_mode := 'if_new'
);
-- Check suppressed = true โ†’ user already voted, ROLLBACK.

-- 2. Publish updated vote count.
SELECT * FROM cf_map_publish(
p_channel := 'poll:results',
p_key := 'poll1_opt_0',
p_data := '{"optionId": "poll1_opt_0", "label": "Option A", "votes": 42}'::jsonb
);
COMMIT;

Centrifugo's outbox worker picks up new stream entries and delivers them to subscribers. This pattern eliminates the dual-write problem: instead of publishing to Centrifugo and updating your database separately (risking inconsistency), both happen in a single transaction.

Consistent TTLs across publishes

When calling cf_map_publish directly, use the same p_key_ttl for all publishes on a given channel. Mixing expiring keys with permanent keys (p_key_ttl = NULL) on the same channel can lead to metadata being expired while some keys remain โ€” breaking recovery for connected clients.

Centrifugo's own publish path (via HTTP/GRPC API or the SDK) uses the channel namespace's configured map.key_ttl for all publishes, so this is only a concern when calling SQL functions directly. The validation MetaTTL >= KeyTTL catches the common case, but can't detect per-channel history when p_key_ttl = NULL is mixed with prior expiring keys.

Channel namespace configurationโ€‹

Map subscriptions are configured per channel namespace. A namespace must declare which subscription types it supports.

All subscribers to the same channel must use the same subscription type. A single channel cannot have some subscribers using stream and others using map โ€” the subscription type is a property of the channel (determined by namespace configuration), not of individual subscribers.

Minimal exampleโ€‹

config.json
{
"map_broker": {
"type": "memory"
},
"channel": {
"namespaces": [
{
"name": "cursors",
"subscription_type": "map",
"map": {
"mode": "ephemeral",
"key_ttl": "60s",
"allow_publish_for_subscriber": true,
"client_key": "client_id"
},
"publication_data_format": "json_object",
"allow_subscribe_for_client": true
}
]
}
}
note

When allowing direct client publishing, use publication_data_format set to "json_object" to enforce that data payloads are valid JSON objects. This provides lightweight server-side validation without requiring a proxy roundtrip โ€” important for high-frequency updates like cursor positions. For stricter validation (checking specific fields, value ranges, etc.), use a map publish proxy.

Namespace optionsโ€‹

Subscription typeโ€‹

"subscription_type": "map"

Declares the subscription type for the namespace โ€” one of the supported types. Each namespace supports exactly one type โ€” use separate namespaces for presence tracking (see Presence channels).

Modeโ€‹

OptionTypeDefaultDescription
map.modestring"ephemeral", "recoverable", or "persistent". Required when using map types
map.key_ttldurationRequired for "ephemeral" and "recoverable" modes
map.stream_sizeintegerMax stream entries (auto-derived for recoverable/persistent: 100)
map.stream_ttldurationStream entry retention (auto-derived for recoverable/persistent: "1m")
map.meta_ttldurationMetadata retention (auto-derived)

Map publish permissionsโ€‹

OptionTypeDefaultDescription
map.allow_publish_for_clientbooleanfalseAuthenticated clients can map-publish to channels in this namespace
map.allow_publish_for_subscriberbooleanfalseClients subscribed to the channel can map-publish
map.allow_publish_for_anonymousbooleanfalseAnonymous clients can map-publish (requires one of the above)
map.publish_proxy_enabledbooleanfalseRoute map publish through a proxy
map.publish_proxy_namestring"default"Name of the proxy to use

Map remove permissionsโ€‹

OptionTypeDefaultDescription
map.allow_remove_for_clientbooleanfalseAuthenticated clients can map-remove from channels in this namespace
map.allow_remove_for_subscriberbooleanfalseClients subscribed to the channel can map-remove
map.allow_remove_for_anonymousbooleanfalseAnonymous clients can map-remove (requires one of the above)
map.remove_proxy_enabledbooleanfalseRoute map remove through a proxy
map.remove_proxy_namestring"default"Name of the proxy to use

Server-driven key assignmentโ€‹

"map": {
"client_key": "client_id"
}
ValueBehavior
"" (empty/default)Client-provided key is used as-is. In most cases you should validate it โ€” enable map.publish_proxy_enabled to route through a map publish proxy
"client_id"Key is overridden with the client's connection ID
"user_id"Key is overridden with the client's user ID. Anonymous clients (empty user ID) are rejected with ErrorPermissionDenied โ€” the server has no identifier to use as the key

This applies to both map publish and map remove operations. When set, the client-provided key is ignored.

Mutually exclusive with the proxy

map.client_key cannot be combined with map.publish_proxy_enabled or map.remove_proxy_enabled โ€” the two are different ways to control keying, and Centrifugo rejects this combination at config-load. When you need server-driven keying behind a proxy, derive the key inside the proxy and return it via result.key (see MapPublishResult).

Automatic cleanup on unsubscribeโ€‹

"map": {
"remove_client_on_unsubscribe": true
}

When a client unsubscribes or disconnects, the entry with key = client ID is automatically removed. Useful for cursor-like scenarios.

Presence channelsโ€‹

Subscriptions can automatically track client and user presence in separate map channels. The presence channel is constructed as prefix + channel โ€” you configure a channel prefix that determines which namespace (or pattern) the presence data is published to. This works with any subscription type (stream, map, shared_poll):

config.json
{
"channel": {
"namespaces": [
{
"name": "game",
"subscription_type": "map",
"map_clients_presence_channel_prefix": "clients:",
"map_users_presence_channel_prefix": "users:",
"map": {
"mode": "ephemeral",
"key_ttl": "60s"
},
"allow_subscribe_for_client": true
},
{
"name": "clients",
"subscription_type": "map_clients",
"map": {
"mode": "recoverable",
"key_ttl": "60s"
},
"allow_subscribe_for_client": true
},
{
"name": "users",
"subscription_type": "map_users",
"map": {
"mode": "recoverable",
"key_ttl": "60s"
},
"allow_subscribe_for_client": true
}
]
}
}
Use recoverable mode for presence channels

The recoverable mode is recommended for map_clients and map_users namespaces. It enables stream-based catch-up on reconnect โ€” clients receive only the join/leave changes they missed, rather than re-fetching the full participant list. With ephemeral mode, every reconnect triggers a full state snapshot, which is the same behavior as Centrifugo's traditional presence โ€” you lose the convergence advantage that map-based presence provides.

When a client subscribes to game:abc:

  • An entry with key = client ID is automatically published to clients:game:abc (client presence)
  • An entry with key = user ID is automatically published to users:game:abc (user presence)

The client can then separately subscribe to clients:game:abc or users:game:abc to track presence for that game channel.

This also works with Centrifugo PRO channel patterns. For example, with prefix "/clients" and a pattern channel /games/abc, presence is published to /clients/games/abc.

Map publish/remove proxyโ€‹

When map.publish_proxy_enabled or map.remove_proxy_enabled is set, the corresponding client-originated operation is forwarded to your application backend before execution. The proxy is the single trust boundary that can:

  • Allow or deny the operation, or disconnect the client
  • Validate that the client has permission to publish/remove for the specific key
  • Override the key (e.g. force it to a server-derived value) โ€” leave result.key unset to approve the client-supplied key as-is
  • Override the data and provide a separate stream payload
  • Stamp server-controlled metadata on the resulting publication โ€” tags, version, key mode, idempotency key, delta hint

Because the proxy is the single keying authority when enabled, map.client_key cannot be set on the same namespace โ€” Centrifugo rejects that combination at config-load. To get server-driven keying behind a proxy, derive the key inside the proxy and return it via result.key.

This makes the publish proxy the natural place to combine authorization with RBAC tag enrichment for client-originated publishes: clients cannot send tags themselves, so the proxy is the only path that can attach tags read by server-side publication tags filter.

config.json
{
"proxies": [
{
"name": "backend",
"endpoint": "http://localhost:3001",
"timeout": "3s"
}
],
"channel": {
"namespaces": [
{
"name": "game",
"subscription_type": "map",
"map": {
"mode": "persistent",
"publish_proxy_enabled": true,
"publish_proxy_name": "backend",
"remove_proxy_enabled": true,
"remove_proxy_name": "backend"
},
"allow_subscribe_for_client": true
}
]
}
}

When the proxy is configured for a namespace, the map.allow_publish_for_* / map.allow_remove_for_* flags are not checked โ€” the proxy is fully responsible for authorization.

Map publish proxy requestโ€‹

The proxy receives a JSON request with these fields:

FieldTypeDescription
clientstringunique client ID generated by Centrifugo for the connection
transportstringtransport name (e.g. websocket)
protocolstringprotocol type (json or protobuf)
encodingstringprotocol encoding (json or binary)
userstringthe connection's user ID from authentication
channelstringthe map channel the client is publishing to
keystringthe key sent by the client (may be empty if the client did not supply one)
dataJSONthe data sent by the client
b64datastringbase64-encoded data, used instead of data when binary proxy mode is enabled
metaJSONthe connection's attached meta (off by default, enable with "include_connection_meta": true)

Map publish proxy responseโ€‹

FieldTypeDescription
resultMapPublishResultthe result of the operation when allowed
errorErrorreject the operation with a custom error
disconnectDisconnectdisconnect the client
MapPublishResultโ€‹

All fields are optional. Any field left unset falls back to the value sent by the client (for key/data) or to the default behaviour. Returning a Result (even an empty one) means the publish is approved.

FieldTypeDescription
keystringOverride the key used for the publish. Useful for forcing keys to server-derived values (e.g. user ID, deal ID). Leave unset to approve the client-supplied key.
dataJSONReplace the publication data the client sent.
b64datastringBinary data encoded in base64, used instead of data in binary proxy mode.
tagsmap<string, string>Server-stamped publication tags. Clients cannot send tags themselves โ€” the proxy is the only path that can attach tags read by server-side publication tags filter for per-subscriber RBAC.
key_modestring"if_new" to publish only when the key does not yet exist, "if_exists" to publish only when it already exists. Useful for enforcing insert-only or update-only access patterns.
idempotency_keystringIdempotency key for safe retries โ€” duplicates within the broker's idempotent result TTL window are suppressed.
deltaboolEnable delta compression for this publication.
versionuint64Per-key version used by Centrifugo to drop non-actual publications.
version_epochstringScopes version โ€” use when version may be reused.

Map remove proxy requestโ€‹

FieldTypeDescription
clientstringunique client ID generated by Centrifugo for the connection
transportstringtransport name
protocolstringprotocol type (json or protobuf)
encodingstringprotocol encoding (json or binary)
userstringthe connection's user ID from authentication
channelstringthe map channel the client is removing from
keystringthe key the client wants to remove
metaJSONthe connection's attached meta (off by default, enable with "include_connection_meta": true)

Map remove proxy responseโ€‹

FieldTypeDescription
resultMapRemoveResultthe result of the operation when allowed
errorErrorreject the operation with a custom error
disconnectDisconnectdisconnect the client
MapRemoveResultโ€‹

All fields are optional. Any field left unset falls back to the value sent by the client (for key) or to the default behaviour. Returning a Result (even an empty one) means the remove is approved.

FieldTypeDescription
keystringOverride the key being removed. Leave unset to approve the client-supplied key.
tagsmap<string, string>Tags attached to the removal publication. When unset, the broker reads the removed entry's stored tags automatically. Set explicitly only to override.
idempotency_keystringIdempotency key for safe retries on removal.

Pagination and catch-up tuningโ€‹

The following options are configured per channel namespace inside the map block:

OptionTypeDefaultDescription
default_page_sizeinteger100Default entries per page when the client does not specify a page size
min_page_sizeinteger100Minimum entries per page for state/stream pagination
max_page_sizeinteger1000Maximum entries per page for state/stream pagination
live_transition_max_publication_limitintegermax_page_sizeMax stream publications to recover during live transition
subscribe_catch_up_timeoutduration"5s"Max time for state/stream catch-up before disconnecting

Server APIโ€‹

Centrifugo provides six API methods for map operations, available via both HTTP and gRPC:

map_publishโ€‹

Publish or update a key in a map channel.

curl -X POST http://localhost:8000/api/map_publish \
-H "Authorization: apikey YOUR_KEY" \
-d '{"channel": "scoreboard:main", "key": "player1", "data": {"score": 100}}'

Options:

  • key_mode โ€” "if_new" (only if key doesn't exist) or "if_exists" (only if key exists)
  • idempotency_key โ€” duplicate detection key
  • tags โ€” key-value metadata for filtering
  • version / version_epoch โ€” per-key version for ordering
  • delta โ€” enable delta compression

map_removeโ€‹

Remove a key from a map channel.

curl -X POST http://localhost:8000/api/map_remove \
-H "Authorization: apikey YOUR_KEY" \
-d '{"channel": "scoreboard:main", "key": "player1"}'

map_read_stateโ€‹

Read the current state with optional pagination.

curl -X POST http://localhost:8000/api/map_read_state \
-H "Authorization: apikey YOUR_KEY" \
-d '{"channel": "scoreboard:main", "limit": 100}'

Options: cursor (pagination), limit, key (filter to single key).

Redis map broker: page sizes may vary

State is stored in a Redis HASH and paginated with HSCAN, where COUNT is a hint, not a guarantee. Redis may return more entries than the requested limit on some pages, especially for small hashes stored in listpack encoding. Do not rely on exact page sizes for state reads.

map_read_streamโ€‹

Read the change stream (history).

curl -X POST http://localhost:8000/api/map_read_stream \
-H "Authorization: apikey YOUR_KEY" \
-d '{"channel": "scoreboard:main", "limit": 100}'

Options: since_offset / since_epoch (read from position), limit, reverse.

map_statsโ€‹

Get statistics about a map channel.

curl -X POST http://localhost:8000/api/map_stats \
-H "Authorization: apikey YOUR_KEY" \
-d '{"channel": "scoreboard:main"}'

Returns num_keys โ€” the number of entries in the channel's state.

map_clearโ€‹

Clear all state and stream data for a channel.

curl -X POST http://localhost:8000/api/map_clear \
-H "Authorization: apikey YOUR_KEY" \
-d '{"channel": "scoreboard:main"}'

Client SDK APIโ€‹

info

At this point only centrifuge-js SDK supports map subscriptions. Support for other SDKs is planned.

Creating a map subscriptionโ€‹

Use newMapSubscription instead of newSubscription:

const sub = client.newMapSubscription('cursors:room1', {});

Eventsโ€‹

Unlike regular stream subscriptions, where the application must handle publication events and deal with recovery flags and stream positions, map subscriptions expose dedicated sync and update events. These events completely hide the recovery protocol inside the SDK โ€” the application never needs to think about pagination, catch-up, or reconnect logic. It simply reacts to state snapshots and incremental changes.

sync โ€” emitted when the complete state is available (initial subscribe or full resync):

sub.on('sync', (ctx) => {
// ctx.entries is the full state โ€” array of { key, data } entries
// (a snapshot, so no removed keys are present)
renderFullState(ctx.entries);
});

update โ€” emitted when a single entry changes:

sub.on('update', (ctx) => {
// ctx.key, ctx.data, ctx.removed
if (ctx.removed) {
removeEntry(ctx.key);
} else {
upsertEntry(ctx.key, ctx.data);
}
});

Under the hood, the SDK manages state automatically: on initial subscribe it builds state from paginated reads, and on reconnect it attempts to catch up from the change stream (recoverable/persistent modes). If catch-up is not possible (e.g. too many changes accumulated), the SDK transparently falls back to a full state re-sync from the broker โ€” the application simply receives another sync event with the complete state.

Standard subscription events (publication, subscribing, subscribed, unsubscribed, error) also work on map subscriptions.

Publishingโ€‹

// Publish to a key (key may be empty if server assigns it via map.client_key)
await sub.publish('mykey', { x: 100, y: 200 });

// Remove a key
await sub.remove('mykey');

Optionsโ€‹

OptionDefaultDescription
limit100Page size for state/stream pagination
unrecoverableStrategy"from_scratch""from_scratch" or "fatal" โ€” handle unrecoverable position errors
deltaSet to "fossil" to enable delta compression (applied per-key โ€” deltas are computed between successive values of the same key, not across the entire map)

Examplesโ€‹

Cursor trackingโ€‹

A common pattern: each user publishes their cursor position, the server assigns the key to the client ID, and positions auto-expire after 60 seconds.

Server configuration:

config.json
{
"map_broker": {
"type": "memory"
},
"channel": {
"namespaces": [
{
"name": "cursors",
"subscription_type": "map",
"map": {
"mode": "ephemeral",
"key_ttl": "60s",
"remove_client_on_unsubscribe": true,
"allow_publish_for_subscriber": true,
"client_key": "client_id"
},
"publication_data_format": "json_object",
"allow_subscribe_for_client": true
}
]
}
}

Client code:

const sub = client.newMapSubscription('cursors:room1');

const cursors = new Map();

sub.on('sync', (ctx) => {
cursors.clear();
for (const entry of ctx.entries) {
cursors.set(entry.key, entry.data);
}
renderAll(cursors);
});

sub.on('update', (ctx) => {
if (ctx.removed) {
cursors.delete(ctx.key);
} else {
cursors.set(ctx.key, ctx.data);
}
renderAll(cursors);
});

sub.subscribe();

// Publish cursor position (key is auto-assigned to client ID by server)
document.addEventListener('mousemove', throttle((e) => {
sub.publish('', { x: e.clientX, y: e.clientY });
}, 50));

Persistent scoreboardโ€‹

A scoreboard with persistent entries, server-side publishing, and efficient recovery on reconnect.

Server configuration:

config.json
{
"map_broker": {
"type": "postgres",
"postgres": {
"dsn": "postgres://user:pass@localhost:5432/app?sslmode=disable"
}
},
"channel": {
"namespaces": [
{
"name": "scoreboard",
"subscription_type": "map",
"map": {
"mode": "persistent"
},
"allow_subscribe_for_client": true
}
]
}
}

Publishing from your backend (via server API):

curl -X POST http://localhost:8000/api/map_publish \
-H "Authorization: apikey YOUR_KEY" \
-d '{
"channel": "scoreboard:main",
"key": "player1",
"data": {"name": "Alice", "score": 1500}
}'

Client code:

const sub = client.newMapSubscription('scoreboard:main', {});

sub.on('sync', (ctx) => {
renderScoreboard(ctx.entries);
});

sub.on('update', (ctx) => {
if (ctx.removed) {
removeEntry(ctx.key);
} else {
upsertEntry(ctx.key, ctx.data);
}
});

sub.subscribe();

Demosโ€‹

A collection of interactive demos showcasing map subscriptions is available in the map_demo example. It includes 9 scenarios covering different map subscription features:

map demo

  • Sync Protocol Visualizer โ€” step through the STATE โ†’ STREAM โ†’ LIVE sync phases with interactive sequence diagrams and frame inspection
  • Ephemeral Cursors โ€” real-time cursor positions using ephemeral sync with auto-cleanup on disconnect
  • Game Lobby โ€” 2-player lobby with slot claiming, live updates, and automatic game start using recoverable sync
  • Inventory (CAS) โ€” compare-and-swap for safe concurrent updates with conflict handling
  • Stock Tickers โ€” real-time price feed with sector filtering using tags filter
  • Live Scoreboard (Delta) โ€” 6 concurrent football matches with fossil delta compression and live bandwidth stats
  • Sprint Board (PostgreSQL) โ€” Kanban board with drag-and-drop using native PostgreSQL cf_map_* functions for transactional publishing
  • Live Polls (PostgreSQL) โ€” server-driven polls with real-time voting, bot participants, and auto-rotation using cf_map_* functions

The demo runs with Docker Compose (PostgreSQL + Python backend + Nginx) and requires Centrifugo v6.8.0+ with centrifuge-js.

For the app-owned state pattern (app DB as source of truth + transactional publishing via the PostgreSQL stream broker + stream subscription getState), see the pg_stream_broker kitchen orders demo and the blog post Transactional publishing for stream subscriptions with PostgreSQL.