I like Redis. I don’t like installing and starting it merely so a local test can keep a session hash alive for ten minutes. That tax was easy to ignore when I ran the suite occasionally; coding agents made it recur often enough to become irritating, then paid it again from scratch in every CI job.
I wanted a Bun test process to start a Redis-compatible dependency, use it and throw it away afterwards. No container, no daemon left running, and no changes to the application code merely to accommodate the test environment.
So I built what is now valkey2, a Redis- and Valkey-compatible TCP server in TypeScript, and pointed the ordinary node-redis package at it. Before my application sent a single command, the client issued one I had never asked for: HELLO 3. Capability, connection-state and metadata probes followed.
That traffic clarified where compatibility actually lived. The application used a small handful of Redis operations; the client expected the much larger conversation accumulated around them, including everything that happens before the application believes the conversation has begun.
The client writes the first requirements
A fresh Redis connection begins in RESP2. HELLO 3 upgrades that connection to RESP3, changing the shapes of later replies: HGETALL becomes a map instead of a flat field-value list, while ZSCORE becomes a double. Another connection to the same server may remain on RESP2, which means protocol version belongs to the connection rather than the server as a whole.
Then come the questions applications rarely issue themselves. INFO, COMMAND, CONFIG, CLIENT, MEMORY, LATENCY, SLOWLOG, ACL and CLUSTER let clients inspect capabilities and state before deciding how to proceed. valkey2 implements the behaviour local applications use and returns compatible shapes where a client probes for an administrative control plane the small server does not possess. Its CLUSTER answer is enough for negotiation; it does not conjure a distributed system behind the socket.
I tested that claim by starting the server inside the integration tests and using unmodified ioredis and node-redis packages against it. The test runs three ordinary application flows: a session hash with a TTL, a watched transfer between two balances, and pub/sub.
{
"session": {
"role": "admin",
"ttl": 600
},
"transfer": ["OK", "OK"],
"balances": {
"alice": "70",
"bob": "80"
},
"pubsub": ["user.login"]
}
The session was written and read back with its expiry. The two OK replies came from committing the transfer under WATCH and MULTI; user.login arrived through the normal subscribe path. Neither client received a private hint that the server on the other end was a Bun process.
A mock can make the application-level assertions pass without proving any of this. ioredis-mock replaces the client and never opens a socket. redis-memory-server gets wire compatibility by downloading a real Redis binary. Both are useful, but neither answered my question: how small can the local dependency become while the surrounding application remains completely ordinary?
One socket is one ordered conversation
Redis sends requests and replies over a single socket. If an asynchronous reply overtakes the one before it, the client receives perfectly valid bytes for the wrong request. Every command handler may be correct in isolation while the protocol is unusable.
Each connection therefore owns a queue which preserves issue order and flushes completed replies together. The parser has a fast path for the common array-of-bulk frame and a complete RESP parser behind it. Keys and values remain bytes where possible; arbitrary keys pass through a latin1 bijection because Redis data is not JavaScript text, however inviting TypeScript’s string types may be.
Effect composes the server’s entry point and configuration, while command dispatch has a synchronous fast path. A handler enters the per-connection Promise queue only when it actually returns asynchronous work; that preserves reply order without putting an async boundary around every ordinary command. The split is visible in dispatchSync and the socket loop. A representative two-vCPU run of the documented pipelined workload produced roughly 23,000 to 29,000 commands a second, numbers intended for comparison rather than bragging rights.
Where the substitute stops
The server now covers strings, hashes, lists, sets, sorted sets, streams, transactions, pub/sub and roughly 200 commands. Since the first version, it has acquired a waiter registry for blocking operations and its own CRC32-verified persistence format.
Replication, Sentinel, cluster sharding and failover remain reasons to run Redis or Valkey. A Bun process built for tests should not borrow the operational reputation of the system whose wire protocol it speaks.
New client versions can go through the same integration test. When unmodified clients negotiate, transact and subscribe without special treatment, the local job gets the Redis it needed and nothing else has to care.