← All crafts Craft 01 / 01

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.

Project
Redis-compatible server
Stack
NodeJS · core modules only
Period
Jun 2026
Source
GitHub ↗
Redis

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

Clientsredis-cli · testsRESPConnectionone per clientbytes, in any piecesRESP parserbytes → whole commandscommand + its bytesCommand router25 handlers in a tableMULTITransaction queueper client, till EXECwaitWaiting clientsBLPOP · XREAD · WAITKeyspaceone in-memory objectwrites, as bytesReplicassame bytes, same order
The request path — a parser per connection turns bytes into whole commands, one table routes them, and each runs to completion on the one event loop.

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

Client ABLPOP jobs 0Client BBLPOP jobs 5Waiting on jobsA first, then BClient CRPUSH jobs task-1List jobstask-1notifyReply to Ajobs, task-1timeoutReply to Bnull — timed out
Blocking without blocking — a waiting client is a callback in a queue for its key; a push hands the value to the longest-waiting one, and a timer lets the others give up.

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

Replica--replicaof <host> <port>Masterport 6379HandshakePING+PONGREPLCONF listening-port 6380+OKREPLCONF capa psync2+OKFull syncPSYNC ? -1+FULLRESYNC <replication id> 0$<length> + RDB snapshot, no CRLFWritesSET key value — the bytes the master gotWAITREPLCONF GETACK *REPLCONF ACK <offset>
Replication — the replica introduces itself and takes a full snapshot, then follows the master's writes byte for byte; WAIT asks each replica how far it has got.

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 SET is forwarded to replicas so far; list, stream and INCR writes aren't yet.
  • A replica always takes a full snapshot (an empty one, for now) — there's no partial resync from a backlog.
  • CONFIG GET knows 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