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.
| Package | socket.io-valkey-adapter (opens in a new tab) v0.1.1 |
| License | MIT |
| Runtime dependencies | 3 (debug, notepack.io, uid2) |
| Tests | 137 passing · standalone + 6-node cluster |
| Node.js | ≥ 14 |
| Source | github.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 prefixInstances 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.
| Layer | Responsibility |
|---|---|
| Application code | io.to('room1').emit('chat', msg) |
| Socket.IO server | Namespaces, rooms, connected sockets |
| socket.io-valkey-adapter | Replicates every broadcast across instances |
| Valkey Pub/Sub | Standalone or 6-node cluster |
| Peer Socket.IO instances | Deliver 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.
| Option | Default | Purpose |
|---|---|---|
key | socket.io | Prefix for every Pub/Sub channel — isolate multiple deployments on one Valkey. |
requestsTimeout | 5000 | How long to wait for other instances' answers before rejecting a cluster-wide query. |
publishOnSpecificResponseChannel | false | Send answers to the requester's private channel instead of a shared one — less wasted traffic. |
parser | notepack.io | Pluggable 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.