Custom Redis
A Redis-compatible server in plain NodeJS, built through the CodeCrafters “Build your own Redis” challenge. It speaks Redis's own protocol, so redis-cli can talk to it — with expiring keys, blocking lists and streams, transactions with WATCH, and master–replica replication.
01 / Overview
I'd used Redis for years as a cache and a queue without knowing what happens between the socket and the reply. So I built one. CodeCrafters splits the job into stages, each checked by tests that talk to your server through a real Redis client; how you structure it is up to you.
The result is about 1,900 lines of NodeJS using only its core modules — net for TCP, Buffer for bytes, AsyncLocalStorage for logging and crypto for ids. It handles 25 commands, every reply in RESP, the protocol real Redis speaks.
02 / Architecture
Every connection gets its own parser, and every parsed command goes through one table of handlers. A handler reads or changes the keyspace — a single in-memory object — and returns its reply already encoded as RESP, or nothing, when it will answer later.
A parser that expects TCP to be messy
TCP delivers bytes, not commands: one read can hold half a command, or five pipelined ones. So each connection adds what arrives to a buffer, and the parser takes whole values off the front, one at a time. When the next one is cut short, it stops and waits for more bytes rather than guess. Malformed input is logged and skipped, up to the next byte that can start a RESP value — in a loop, not by recursion, so a flood of junk can't overflow the stack.
Each command also keeps the exact bytes it was parsed from. Replication forwards those bytes as they are, and counts its progress in them.
One event loop, so every command is atomic
NodeJS runs one callback at a time, and no handler waits on anything: each reads, writes and replies in one go. No other client can get in between, so there's nothing to lock.
03 / Blocking
BLPOP and XREAD BLOCK wait for data without holding up anyone else. The handler puts a callback in a queue for the key and returns nothing, so no reply goes out yet. When RPUSH or XADD adds to that key, it schedules a check with setImmediate: the writer gets its own reply first, then the longest-waiting client gets the value. A client that gave a timeout has a timer, which takes its callback out of the queue and answers with a null.
04 / Transactions
MULTI puts a client in queueing mode: its commands answer QUEUED and wait in a queue of its own. EXEC runs them through the same router in one go — atomic, for the same reason single commands are. DISCARD drops them.
WATCH, with a timestamp
Every write stamps its key with the time it changed. WATCH notes those stamps, and EXEC compares them: if any key has changed since, it runs nothing and returns a null, for the client to try again. Optimistic — no locks held, and nobody waits.
05 / Replication
Started with --replicaof, a server connects to its master, introduces itself and asks to sync from scratch. The master answers FULLRESYNC with its replication id and offset, then sends a snapshot of its data as an RDB file. That arrives like a bulk string, but without the closing CRLF, so the parser has a mode for it.
From then on the master forwards each write to every replica as the bytes it received, and both sides count their offset in those bytes. The replica runs them through the same router as any client's commands — the master is just another connection to it, one that gets no replies.
WAIT
WAIT 2 500 asks: have at least two replicas caught up, within 500 ms? The master sends every replica REPLCONF GETACK *, counts those whose acknowledged offset matches its own, and replies as soon as enough have — or, when the time runs out, with however many had.
06 / Details
Expiry, on read
SET key value PX 100 stores the moment the key dies. GET checks it, and deletes the key once that moment has passed. There's no background sweep: an expired key goes when it's next read.
Logs per request
Each command runs inside an AsyncLocalStorage context holding a trace id and the client's id, so every log line it leads to carries them, without passing them around. Lines are buffered and written out in batches on setImmediate, which keeps console writes off a command's path.
07 / Commands
Keys & strings
- PING
- ECHO
- SET
- GET
- TYPE
- INCR
Lists
- RPUSH
- LPUSH
- LRANGE
- LLEN
- LPOP
- BLPOP
Streams
- XADD
- XRANGE
- XREAD
Transactions
- MULTI
- EXEC
- DISCARD
- WATCH
- UNWATCH
Replication
- INFO
- REPLCONF
- PSYNC
- WAIT
Persistence
- CONFIG GET
08 / Next
Where it stops, for now:
- Only
SETis forwarded to replicas so far; list, stream andINCRwrites aren't yet. - A replica always takes a full snapshot (an empty one, for now) — there's no partial resync from a backlog.
CONFIG GETknows where the RDB file lives, but loading keys from it is the next stage.- Expiry is on read only; real Redis also samples keys in the background.
09 / Highlights
- RESP
- TCP
- Event loop
- Blocking commands
- Transactions
- Optimistic locking
- Replication
- AsyncLocalStorage