Open Source
socket.io-valkey-adapter

Published: July 31, 2026 · by Srinu Desetti — built with the Stalwart Team for the Valkey Hackathon conducted by React Hyderabad

socket.io-valkey-adapter: The Horizontal-Scaling Layer for Socket.IO on Valkey

socket.io-valkey-adapter lets multiple Socket.IO server instances broadcast events to each other through Valkey Pub/Sub, so a message emitted on one instance reaches clients connected to every other instance. It is a verified line-equivalent port of the official @socket.io/redis-adapter (v8.3.0) — same proven architecture, rebranded and re-targeted for the Valkey ecosystem — and it is certified against both standalone Valkey 8 and a real 6-node Valkey Cluster.

I built this adapter with the Stalwart Team for the Valkey Hackathon conducted by React Hyderabad, and I maintain it as the top layer of the Valkey Node.js client stack.

Packagesocket.io-valkey-adapter (opens in a new tab) v0.1.1
LicenseMIT
Runtime dependencies3 (debug, notepack.io, uid2)
Tests137 passing · standalone + 6-node cluster
Node.js≥ 14
Sourcegithub.com/webdevelopersrinu/socket.io-valkey-adapter (opens in a new tab)

The Problem: A Socket.IO Server Is an Island

A single Socket.IO server holds its connected sockets in its own memory. Run three instances behind a load balancer and each instance knows only its third of the users: io.emit() on instance 1 reaches instance 1's clients and nobody else. Every real-time product — chat, dashboards, notifications, auctions — hits this wall on the first day of horizontal scaling.

The fix is a shared message bus. Every instance connects to the same Valkey; when one broadcasts, it publishes the packet to a channel all the others subscribe to. The channel name itself carries the routing information:

socket.io # / # room1 #
└───┬───┘   └┬┘ └─┬─┘
    │        │    └── target room
    │        └─────── namespace
    └──────────────── configurable prefix

Instances pattern-subscribe to socket.io#/#*, so one subscription covers the namespace and every room-targeted channel under it. A receiving instance that has no members of room1 recognizes that from the channel name alone and drops the message before paying to decode it.

Why This Is Harder Than It Looks

Broadcast is the easy half

Publishing a packet is one call. The subtleties are that the sender is also subscribed to its own channel — every message carries a 6-character instance id so echoes are dropped — and that packets can contain binary payloads, so the wire format is MessagePack, not JSON.

Cluster-wide questions need answers, not shouts

Operations like fetchSockets() ("who is online, across every instance?") cannot be fire-and-forget. The adapter runs a request/response protocol on top of Pub/Sub:

requesting instance      → publish REMOTE_FETCH, requestId "ab12x9"
                           (counts servers first — alone? return local
                            sockets, skip the network entirely)

every other instance     → onrequest(): publish response with its
                           local sockets on the response channel

requesting instance      → onresponse(): merges each answer as it
                           arrives; msgCount === serverCount → resolve
                           (5 s timeout if an instance never replies)

The pending request lives in a Map keyed by requestId, holding the promise's resolve function until the last answer (or the timeout) fires. No registry, no coordination service — just Pub/Sub and counting.

How do you count servers with no registry?

The trick that makes the protocol self-managing: every live instance subscribes to the request channel, and Valkey's PUBSUB NUMSUB reports how many subscribers a channel has. Subscriber count is the server count — always current, dead instances disappear automatically. On a cluster the adapter asks every node and sums the answers.

Why We Forked Instead of Reused

Valkey is the Linux Foundation fork of Redis, created after Redis changed its license in 2024. Its Pub/Sub commands are identical — so the adapter logic did not need rewriting, and deliberately was not rewritten. A file-by-file diff against upstream v8.3.0 confirms zero functional differences. What changed is provenance and targeting:

  • All Redis branding removed — a Valkey ecosystem package should not depend on or advertise Redis-named modules.
  • Targets iovalkey, the Valkey-native client, while accepting any compatible client through a runtime API check.
  • Tested against Valkey 8 — standalone and a real 6-node cluster (3 masters, 3 replicas) — in CI on every push.

The result: teams migrating off Redis keep the exact scaling architecture they trust, with a clean, Redis-free dependency tree and zero license risk.

Where It Sits in the Stack

The adapter replaces Socket.IO's default in-memory adapter — one instance per namespace. It deliberately does not manage sockets, rooms, or reconnection; it only replicates broadcasts and answers cluster-wide queries.

LayerResponsibility
Application codeio.to('room1').emit('chat', msg)
Socket.IO serverNamespaces, rooms, connected sockets
socket.io-valkey-adapterReplicates every broadcast across instances
Valkey Pub/SubStandalone or 6-node cluster
Peer Socket.IO instancesDeliver to their own local clients

API Surface

The public surface is two factory functions. Hand them a pub and a sub connection; Socket.IO does the rest.

server.js
const { Server } = require('socket.io')
const { createAdapter } = require('socket.io-valkey-adapter')
const Valkey = require('iovalkey')

const pubClient = new Valkey({ host: 'localhost', port: 6379 })
const subClient = pubClient.duplicate()  // subscriber mode needs its own connection

const io = new Server({ adapter: createAdapter(pubClient, subClient) })
io.listen(3000)
OptionDefaultPurpose
keysocket.ioPrefix for every Pub/Sub channel — isolate multiple deployments on one Valkey.
requestsTimeout5000How long to wait for other instances' answers before rejecting a cluster-wide query.
publishOnSpecificResponseChannelfalseSend answers to the requester's private channel instead of a shared one — less wasted traffic.
parsernotepack.ioPluggable encoder — swap MessagePack for anything with encode/decode.

A second factory, createShardedAdapter(pubClient, subClient, opts), uses Valkey sharded Pub/Sub (SPUBLISH/SSUBSCRIBE). Its subscriptionMode option — static, dynamic (default), or dynamic-private — controls whether each public room gets its own channel, so instances receive traffic only for rooms they actually host.

Engineering Notes — What's Under the Hood

These are the design decisions worth knowing before reviewing the code. All are inherited from years of production tuning in the upstream project; all are verifiable in lib/.

Echo suppression by instance id

Each adapter instance generates a random 6-character uid at startup and stamps it on every published message. Since a publisher is also a subscriber of its own channels, the first check on any incoming message is "is this mine?" — its own broadcasts were already delivered locally before publishing. (constructor · onmessage())

Room-targeted channels skip work on the receiving side

A broadcast to exactly one room publishes to socket.io#/#room1# rather than the namespace channel. Every instance still receives it (pattern subscription), but an instance with no members of that room rejects it from the channel name alone — before decoding the MessagePack payload. (broadcast() · onmessage() · hasRoom())

Server counting via PUBSUB NUMSUB

Aggregating queries must know how many answers to expect. Rather than a membership registry with heartbeats, the adapter counts subscribers on the request channel — a number Valkey maintains for free. Cluster mode fans the question out to every node and sums. (serverCount() · util.PUBSUB())

First-byte protocol sniffing

Control messages travel as JSON; anything that may carry binary travels as MessagePack. The receiver picks a decoder by looking at one byte: 0x7b — the { character — means JSON. One reserved byte, two wire formats, zero configuration. (onrequest() · onresponse())

Pending requests as a Map of resolvers

A cluster-wide query stores its promise's resolve in a Map keyed by requestId, together with the expected answer count and an accumulator. Network events complete the promise later; a timeout rejects it and cleans the entry if an instance dies mid-question. (this.requests · fetchSockets() · onresponse())

Dual client-API support, detected at runtime

Event-based clients (iovalkey: psubscribe + pmessageBuffer events) and callback-based clients (pSubscribe(channel, cb)) are both supported; one typeof check at construction selects the wiring. No configuration flag, no separate builds. (constructor · util.SSUBSCRIBE())

Dynamic per-room channels in the sharded adapter

In dynamic mode the sharded adapter listens to Socket.IO's create-room/delete-room events and subscribes or unsubscribes a dedicated channel per public room on the fly — so a broadcast to a small room touches only the instances hosting it. (sharded-adapter.ts · shouldUseASeparateNamespace())

Scope Boundaries

Scope discipline is the design. The adapter moves packets between live instances — it does not store messages, manage user sessions, or persist history. Persistence belongs to the application's database layer; guaranteed transport-level delivery belongs to the planned streams-based sibling package.

Team credit — socket.io-valkey-adapter was built by the Stalwart Team for the Valkey Hackathon conducted by React Hyderabad. The foundation layers it builds on — valkey-errors and valkey-parser — are my solo projects.

What's Next?

This completes the stack tour: shared errors at the bottom, RESP decoding in the middle, cross-instance broadcast at the top. On the roadmap: a streams-based sibling adapter that logs events with XADD for guaranteed transport-level delivery, and RESP3 support in the parser.

← Back to valkey-parser — the wire-protocol decoder

Back to the Open Source overview →


Frequently Asked Questions

Does socket.io-valkey-adapter guarantee message delivery?

No — Pub/Sub is fire-and-forget by design. If an instance's Valkey connection blips for two seconds, messages published in that window are gone for that instance. Applications needing catch-up store messages in their database; applications needing transport-level guarantees are the target of the roadmap's streams adapter, which logs events with XADD and lets instances replay what they missed.

Does it work with Valkey Cluster?

Yes — certified. The full test suite runs against a real 6-node cluster (3 masters, 3 replicas) in CI. Regular Pub/Sub propagates cluster-wide, and serverCount() correctly sums subscribers across nodes.

Does the sharded adapter work on a cluster?

Not yet — blocked upstream, documented honestly. Slot-routed SSUBSCRIBE needs one subscriber connection per node; iovalkey does not ship that yet (ioredis added it as shardedSubscribers in v5.6.0, after the fork). The suite is skipped with the reason in code and README; on clusters, use the regular adapter today.

Which Valkey clients does it accept?

Any ioredis-compatible client — iovalkey is the tested reference — plus callback-style clients, detected at runtime. The client is a peer, not a bundled dependency: you construct and own the connections.

What happens if Valkey goes down entirely?

Each instance keeps serving its own connected clients; cross-instance broadcasts stop until the connection recovers. A clustered Valkey narrows this window to a replica-failover blip. The adapter also warns at startup if your clients have no error handler — the most common deployment mistake.

Why MessagePack instead of JSON?

Socket.IO payloads can contain Buffers — binary attachments, acknowledgment values — which JSON cannot represent. MessagePack handles them natively and is more compact. Pure-JSON deployments can swap it out with the parser option; the custom-parser configuration is part of the test suite.

How is this different from serverSideEmit()?

emit() targets clients; serverSideEmit() targets the other server instances — useful for cache invalidation or coordinated jobs. Both ride the same request channel; the latter supports acknowledgments from every peer with the same counting-and-timeout machinery.

How faithful is the port, really?

Verifiably faithful: a diff against upstream v8.3.0 shows only renames (RedisAdapter → ValkeyAdapter), documentation links, and comment wording. Every condition, loop, and wire format is unchanged — the point being that years of production hardening carry over intact.