Building Redis from Scratch
Building a Redis-compatible server teaches the internals directly. Each step adds one capability — by the end you have GET/SET/TTL, expiry, and eviction working. Code is JavaScript (Node.js) throughout.
Step 0 — Blocking TCP echo server
Section titled “Step 0 — Blocking TCP echo server”The starting point: a raw TCP server that echoes whatever bytes it receives. This reveals two problems.
// The conceptual BLOCKING model (like how Go/C servers work):// while (true) {// const conn = server.accept() // halts — second client CAN'T connect until first disconnects// while (true) {// const data = conn.read() // halts — waits for data from this one client// conn.write(data) // echo back// }// }
// In Node.js the net module already handles this non-blockingly via libuv:const net = require('net');
const server = net.createServer((socket) => { socket.on('data', (buf) => socket.write(buf)); // echo back whatever arrives});server.listen(7379);Problem 1: In a true blocking model, server.accept() is only reached after the first client disconnects — a second client hangs indefinitely.
Problem 2: Point redis-cli at this echo server and watch what it sends before any command: *1\r\n$7\r\nCOMMAND\r\n. The client already speaks RESP — the server needs a decoder.
Step 1 — RESP decoder
Section titled “Step 1 — RESP decoder”The key design: decodeOne returns both the value and how many bytes it consumed (delta). The caller advances offset += delta to parse back-to-back values in the same buffer with no extra allocations.
// decodeOne(buf, offset) → { value, delta }// delta = bytes consumed, lets caller advance: offset += deltafunction decodeOne(buf, offset = 0) { const type = String.fromCharCode(buf[offset]); switch (type) { case '+': return readSimpleString(buf, offset + 1); case '-': return readError(buf, offset + 1); case ':': return readInt(buf, offset + 1); case '$': return readBulkString(buf, offset + 1); case '*': return readArray(buf, offset + 1); default: throw new Error(`Unknown RESP type: ${type}`); }}
// reads "123\r\n" → { value: 123, delta: 5 }function readLength(buf, offset) { let n = 0, pos = offset; while (buf[pos] !== 0x0d) { // 0x0d = \r n = n * 10 + (buf[pos] - 48); // '0' = ASCII 48 pos++; } return { value: n, delta: pos - offset + 2 }; // +2 skips \r\n}
// $N\r\n<N bytes>\r\nfunction readBulkString(buf, offset) { const { value: n, delta } = readLength(buf, offset); const start = offset + delta; // skip $N\r\n prefix return { value: buf.toString('utf8', start, start + n), delta: delta + n + 2 // +2 skips trailing \r\n };}
// *N\r\n followed by N RESP values (recursive — nested arrays work for free)function readArray(buf, offset) { const { value: count, delta } = readLength(buf, offset); let pos = offset + delta; const arr = []; for (let i = 0; i < count; i++) { const { value, delta: d } = decodeOne(buf, pos); arr.push(value); pos += d; } return { value: arr, delta: pos - offset };}Example: *3\r\n$3\r\nSET\r\n$1\r\nk\r\n$1\r\nv\r\n → ['SET', 'k', 'v'] in one pass. readArray calls decodeOne recursively, so nested arrays come for free.
Step 2 — First command: PING
Section titled “Step 2 — First command: PING”Decode incoming bytes into a { cmd, args } object and dispatch to per-command handlers.
// parseCommand: Buffer → { cmd: 'PING', args: ['hello'] }function parseCommand(buf) { const { value: tokens } = decodeOne(buf); // ['PING', 'hello'] return { cmd: tokens[0].toUpperCase(), args: tokens.slice(1) };}
function handleCommand({ cmd, args }, socket) { switch (cmd) { case 'PING': return handlePING(args, socket); case 'SET': return handleSET(args, socket); case 'GET': return handleGET(args, socket); case 'TTL': return handleTTL(args, socket); case 'DEL': return handleDEL(args, socket); case 'EXPIRE': return handleEXPIRE(args, socket); default: socket.write(`-ERR unknown command '${cmd}'\r\n`); }}
function handlePING(args, socket) { if (args.length === 0) socket.write('+PONG\r\n'); // → +PONG else if (args.length === 1) socket.write(encodeBulk(args[0])); // → $5\r\nhello\r\n else socket.write("-ERR wrong number of arguments for 'ping'\r\n");}Output compared to real Redis:
redis-cli -p 7379 PING → PONGredis-cli -p 7379 PING hello → "hello"redis-cli -p 7379 PING a b → ERR wrong number of argumentsredis-benchmark -n 10000 -t ping_mbulk -c 1 scores ~11,000 req/s — within noise of real Redis at the same single-client concurrency.
Step 3 — Event loop (libuv = epoll under the hood)
Section titled “Step 3 — Event loop (libuv = epoll under the hood)”In the blocking model, the server is stuck reading one socket and can’t accept new clients. The fix is IO multiplexing: ask the OS which sockets have data, then only read those.
Node.js delegates this to libuv, which uses epoll (Linux), kqueue (macOS), or IOCP (Windows) under the hood. You never call epoll directly — but here’s what libuv is doing on your behalf:
| Syscall | What it does |
|---|---|
epoll_create1() | Creates an epoll instance when the Node process starts |
epoll_ctl(ADD, fd, EPOLLIN) | Registers a socket FD — called when you do server.listen() or a client connects |
epoll_wait(events, -1) | Blocks at the bottom of each event loop tick until ≥1 FD is ready |
// This IS the event loop in Node.js — libuv handles the epoll calls for youconst net = require('net');
const server = net.createServer((socket) => { // libuv: epoll_ctl(ADD, clientFD, EPOLLIN) — registered automatically on connection socket.on('data', (buf) => { // libuv called epoll_wait, OS reported this socket has data, libuv fired 'data' const cmd = parseCommand(buf); handleCommand(cmd, socket); }); socket.on('end', () => socket.destroy());});
// libuv: socket() → bind() → listen() → epoll_ctl(ADD, serverFD)server.listen(7379, () => console.log('Listening on :7379'));With this, two clients can connect simultaneously and both get responses — the single-client bottleneck is gone.
Step 4 — GET, SET, TTL
Section titled “Step 4 — GET, SET, TTL”store is a plain Map. Each entry stores a value and an absolute epoch-millisecond timestamp for expiry — not a duration, so GET doesn’t need to remember when the key was created.
const store = new Map(); // Map<string, { value, expiresAt }>
function newEntry(value, durationMs = -1) { return { value, expiresAt: durationMs > 0 ? Date.now() + durationMs : -1 // -1 means "never expires" };}// SET key value [EX seconds]function handleSET(args, socket) { let durationMs = -1; for (let i = 2; i < args.length - 1; i++) { if (args[i].toUpperCase() === 'EX') { durationMs = Number(args[i + 1]) * 1000; // seconds → ms } } store.set(args[0], newEntry(args[1], durationMs)); socket.write('+OK\r\n');}
// GET key — passive (lazy) expiry: check on read, delete if expiredfunction handleGET(args, socket) { const entry = store.get(args[0]); if (!entry) { socket.write('$-1\r\n'); return; }
if (entry.expiresAt !== -1 && entry.expiresAt < Date.now()) { store.delete(args[0]); // lazy delete — don't serve a stale value socket.write('$-1\r\n'); return; } socket.write(encodeBulk(entry.value));}
// TTL key → :N (seconds left) | :-1 (no expiry) | :-2 (absent or expired)function handleTTL(args, socket) { const entry = store.get(args[0]); if (!entry) { socket.write(':-2\r\n'); return; } if (entry.expiresAt === -1) { socket.write(':-1\r\n'); return; } const ttl = Math.floor((entry.expiresAt - Date.now()) / 1000); socket.write(ttl <= 0 ? ':-2\r\n' : `:${ttl}\r\n`);}Output:
SET k v → +OKGET k → "v"TTL k → :-1 (no expiry)SET k v EX 10TTL k → :9 ... :6GET k → $-1 (expired, lazy-deleted)TTL k → :-2Step 5 — DEL, EXPIRE, and active cleanup
Section titled “Step 5 — DEL, EXPIRE, and active cleanup”// DEL key [key ...] → :N (count actually deleted)function handleDEL(args, socket) { let count = 0; for (const k of args) if (store.delete(k)) count++; socket.write(`:${count}\r\n`);}
// EXPIRE key seconds → :1 (expiry set) | :0 (key not found)function handleEXPIRE(args, socket) { const entry = store.get(args[0]); if (!entry) { socket.write(':0\r\n'); return; } entry.expiresAt = Date.now() + Number(args[1]) * 1000; socket.write(':1\r\n');}Lazy expiry clears keys when they’re accessed, but never-read keys accumulate in memory. Active cleanup runs every ~1 second via setInterval:
// Probabilistic active expiry — mirrors what real Redis doesfunction activeExpire() { // Collect all keys that have an expiry set, then take a random sample of 20 const expiring = [...store.entries()].filter(([, e]) => e.expiresAt !== -1); const sample = expiring.length > 20 ? expiring.sort(() => Math.random() - 0.5).slice(0, 20) : expiring;
if (sample.length === 0) return;
let expired = 0; for (const [key, entry] of sample) { if (entry.expiresAt < Date.now()) { store.delete(key); expired++; } }
// If >25% of the sample was expired, the full population is likely dirty — repeat if (expired / sample.length > 0.25) activeExpire();}
setInterval(activeExpire, 1000);The 25% threshold is the insight: a random sample’s expired fraction ≈ the whole population’s expired fraction. If the sample looks dirty, keep cleaning. No extra thread, no extra data structures.
Step 6 — Eviction
Section titled “Step 6 — Eviction”When the store hits its key limit (in production: a memory byte limit), new writes would cause an out-of-memory crash without eviction. The strategy is a pluggable function called inside setKey:
const KEY_LIMIT = 5; // stand-in for maxmemory bytes in production
function setKey(key, entry) { if (store.size >= KEY_LIMIT) evict(); // evict BEFORE inserting store.set(key, entry);}Simple-first (FIFO placeholder):
function evictFirst() { const oldestKey = store.keys().next().value; // Map preserves insertion order store.delete(oldestKey);}Approximated LRU — sample N random entries, evict the one with the oldest lastAccessed:
// entry also tracks: lastAccessed (updated on every GET)function evictLRU(sampleSize = 5) { const candidates = [...store.entries()] .sort(() => Math.random() - 0.5) .slice(0, sampleSize);
const [lruKey] = candidates.reduce((min, cur) => cur[1].lastAccessed < min[1].lastAccessed ? cur : min ); store.delete(lruKey);}Real Redis uses approximated LRU (≥3.0) — no doubly-linked list across all keys, just a lastAccessed timestamp per key + sampling. Trades exactness for zero pointer overhead on millions of keys.
All eviction policies Redis supports:
| Policy | What gets evicted |
|---|---|
noeviction | No eviction — new writes return an error |
allkeys-lru | Globally least recently used |
allkeys-lfu | Globally least frequently used |
volatile-lru/lfu | LRU/LFU but only among keys that have TTL set |
allkeys-random | Any random key — zero bookkeeping |
volatile-random | Random, TTL-keys only |
volatile-ttl | Key with the shortest remaining TTL |
LFU with Morris counter (≥4.0): a plain counter needs 4 bytes/key. A Morris counter approximates large values in 1 byte — it increments with probability 1/currentValue, so increments get rarer as the count grows, saturating around 1M. The counter decays logarithmically every minute so historically-hot-but-now-cold keys eventually become eviction candidates.