In May, while writing valkey-bun’s integration tests, I pointed the unmodified node-redis package from npm at my server. The first command it sent was one I never issued: HELLO 3, which negotiates an upgrade to RESP3. Capability, connection-state, and metadata probes followed before any command from my application went over the wire.

valkey-bun is a Redis- and Valkey-compatible TCP server written in TypeScript for Bun. I built it to remove a recurring setup cost. Developing against Redis required an installation and running daemon on my laptop, followed by another installation in every CI run before the first test. That became much more noticeable when agents started running the suite repeatedly. I wanted a Redis-compatible server for local jobs, CI runs, and agent experiments where another container or daemon was excessive.

Once the probes had answers, I started the server in-process and pointed ioredis and node-redis at it. They ran three application-shaped flows: a session hash with a TTL, a watched two-key transfer, and pub/sub. Here’s the transcript.

{
  "session": {
    "role": "admin",
    "ttl": 600
  },
  "transfer": [
    "OK",
    "OK"
  ],
  "balances": {
    "alice": "70",
    "bob": "80"
  },
  "pubsub": [
    "user.login"
  ]
}

ioredis wrote the session hash, set the expiry, and read the role and TTL back. The two OKs are node-redis committing the two-key transfer under WATCH and MULTI, and the user.login event arrived through the normal subscribe path. Nothing in that run knew the server was a small Bun process.

You’re probably thinking a mock gets you this far. It does. ioredis-mock fakes the client and never touches a socket; redis-memory-server downloads a real binary to get one. The difference shows up in the traffic your application never sends.

The questions no application asked

What node-redis did on that first connection is standard client behaviour before and between application commands. A fresh connection starts on RESP2. HELLO 3 upgrades only that connection, and reply shapes change with it. HGETALL becomes a map instead of a flat field-value list, while ZSCORE becomes a double. Two clients on one server can speak different protocol versions at the same time. valkey-bun implements full behaviour for commands applications use and compatibility stubs where clients probe for shape: INFO, COMMAND, CONFIG, CLIENT, MEMORY, LATENCY, SLOWLOG, ACL, and CLUSTER. No control plane sits behind those stubs; they provide enough shape for a client to continue.

The stubs went in while I was writing the integration tests that run ioredis, node-redis, and a sample app against the same server the transcript came from. I didn’t keep the output of the connects that put them there; the list above is what those sessions left behind.

A painterly collage of a client box and a server vessel shaking hands with interlocking striped ribbons, one ribbon folded on the floor.
The handshake, ribbon by ribbon.

One socket, one conversation

Redis is a conversation over one socket. If a later reply jumps ahead, the client receives valid-looking bytes in the wrong order. The protocol is then broken even when every handler is correct in isolation. A per-connection queue keeps asynchronous replies in order. Replies are buffered by connection and flushed in one write after the data drains. The rest of the server loop stays close to Bun’s TCP API. A fast parser handles the common array-of-bulk frame without intermediate allocations, with the full RESP parser as a fallback. The store keeps keys and values as bytes where possible. Arbitrary keys make a round trip through a latin1 bijection, where byte n maps to codepoint n, because Redis keys and values are byte strings rather than JavaScript text.

valkey-bun is built on Effect, and my first instinct was to wrap every command handler in an Effect or Promise. Measurement showed that the wrapping capped throughput. I did not keep the number, only the decision. The hot loop therefore stays synchronous while Effect handles the surrounding work. The architecture notes record the constraint: the inner loop cannot afford a microtask per command. On a 2 vCPU box, the benchmark sends a production-shaped command mix through a pipelined ioredis client at about 23,000 to 29,000 commands a second. The documentation advises treating those numbers as relative.

What you’d still need Redis for

The transcript covers only part of the surface. The server supports strings, hashes, lists, sets, sorted sets, streams, transactions, pub/sub, and about 200 commands in all. When I ran the test in May, it lacked the features that make Redis an operations system: RDB, AOF, replication, Sentinel, cluster sharding, and failover. Blocking commands were local approximations. The repository has since added persistence. RDB snapshots use valkey-bun’s own CRC32-verified format, which redis-check-rdb cannot read, and the server refuses a checksum mismatch on boot. The AOF records each write as the exact RESP frame sent by the client, rewrites relative TTLs to absolute PEXPIREAT values, and drops a partial crash tail while loading.

Blocking is now implemented through a waiter registry keyed by database and key. It parks BLPOP until a mutation touches a waited key, then wakes waiters in FIFO order. Each waiter consumes from the store, so one push satisfies exactly the appropriate number. The pending reply sits in the same per-connection queue as every other reply, preserving command order for a blocked client. Replication, Sentinel, cluster sharding, and failover remain reasons to use Redis. valkey-bun’s multicore mode uses SO_REUSEPORT with per-worker state rather than sharding.

Installation is bun install plus two runtime dependencies: [email protected] for the control plane and fengari for the Lua behind EVAL. The boot line in the getting-started document reads ready in 3.10ms. Start the server, point an existing client at localhost, and see whether it notices.

Chris Chabot · May 2026