Skip to content
Dev Dump

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.


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.


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 += delta
function 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\n
function 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.


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 → PONG
redis-cli -p 7379 PING hello → "hello"
redis-cli -p 7379 PING a b → ERR wrong number of arguments

redis-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:

SyscallWhat 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 you
const 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'));

epoll FD registration and event dispatch

serverFD and all clientFDs register with the epoll instance; epoll_wait blocks and returns only the ready FDs each tick.

With this, two clients can connect simultaneously and both get responses — the single-client bottleneck is gone.


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.

store.js
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 expired
function 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`);
}

Redis store structure and expiry paths

The store maps keys to entries with an absolute expiresAt; GET lazily checks on read; active cleanup samples every second.

Output:

SET k v → +OK
GET k → "v"
TTL k → :-1 (no expiry)
SET k v EX 10
TTL k → :9 ... :6
GET k → $-1 (expired, lazy-deleted)
TTL k → :-2

Step 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 does
function 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.


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:

PolicyWhat gets evicted
noevictionNo eviction — new writes return an error
allkeys-lruGlobally least recently used
allkeys-lfuGlobally least frequently used
volatile-lru/lfuLRU/LFU but only among keys that have TTL set
allkeys-randomAny random key — zero bookkeeping
volatile-randomRandom, TTL-keys only
volatile-ttlKey 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.