Aug 25, 2026 · Go · Redis · systems
tiny-redis: I built a Redis server to understand what I was actually using
Go · RESP2 · TCP · concurrency · AOF persistence
Most of us treat Redis like a magic key-value box. You SET something, a network round trip later you can GET it. It's fast, it just works, and you move on.
I wanted to know what "fast and works" actually means. So I built a minimal Redis-compatible server in Go — tiny-redis — from scratch. No Redis source in front of me, no client library. Just the wire protocol spec and a TCP socket.
Here's what building it taught me.
The first surprise: Redis is just a wire protocol
Redis isn't a library you import — it's a network protocol your client talks over. That's it. The server reads bytes, does stuff, writes bytes back.
The protocol is called RESP (Redis Serialization Protocol). Version 2 is a delight because it's human-readable. Every message is a little framed exchange:
*2\r\n
$4\r\n
ECHO\r\n
$3\r\n
hey\r\n
*2— an array of 2 elements$4\r\nECHO\r\n— a bulk string, length 4$3\r\nhey\r\n— a bulk string "hey"
Parse it and you've got a command. Implement the five basic types — simple string, error, integer, bulk string, array — and the serializer writes itself:
func (v Value) marshal() []byte {
switch v.typ {
case "array": // *<len>\r\n + each element
case "bulk": // $<len>\r\n<bytes>\r\n
case "simple": // +<bytes>\r\n
}
}
Reading and writing this by hand is the moment "Redis is just a box" dies. It's a byte-framing format on a TCP connection. Understand RESP and you can talk to Redis from anything that can open a socket.
A TCP server, connection by connection
The heart of it is a plain TCP listener:
listener, _ := net.Listen("tcp", ":6379")
for {
conn, _ := listener.Accept()
go handleConnection(conn) // one goroutine per client
}
One goroutine per connection — that's the Go model. Each client gets its own stream; you read frames, dispatch, write responses, in a loop. No threads to manage, the runtime does it.
The command dispatch
Every handler shares one signature:
func(args []Value) Value
So all six commands look identical to the dispatcher:
case "ping": return ping(args)
case "set": return set(args)
case "get": return get(store, args)
case "hset": return hset(args)
case "hget": return hget(store, args)
case "hgetall":return hgetall(args)
Symmetric RTT: client sends SET name tiny-redis, server returns +OK\r\n. Small, uniform, boring — exactly what a protocol should be.
Concurrency without chaos
Multiple clients are firing at once, all mutating the same store. The guard is one primitive:
mu sync.RWMutex
- RWMutex lets many readers share the lock.
- A writer (a
SET) gets it exclusively.
Reads (GET, HGET) don't block each other; writes serialize. That's the whole concurrency story for a toy server — and it's the same sync.RWMutex pattern real services reach for when they need locked shared state without a database.
Durability: the append-only file
The part that made me respect Redis. Two options:
- RDB — snapshot the whole dataset periodically.
- AOF — append every write to a log file so you can replay it on boot.
tiny-redis does AOF. Every mutating command is appended to db.aof before it's applied:
// on every write
file.WriteString(command + "\n")
If the server dies, the next boot reads the file line by line and re-executes every command — the dataset comes back exactly as it was. It's embarrassingly simple, and it's the exact idea behind a write-ahead log, the thing that keeps Postgres, MySQL, and Kafka from losing your writes.
The honest limits
I built this to learn, and that means being honest about what it's not:
- No expiry/TTL — no
EXPIRE, keys live forever - No RDB snapshots — AOF only
- No transactions, pub/sub, clustering — those are a whole extra server each
- AOF replay is synchronous on boot — fine at this scale, not at Redis scale
That's the deal with building-under-the-hood projects: you don't ship a Redis competitor, you ship understanding.
What it taught me
- Redis is a protocol, not a black box. The "magic" is framing bytes on a socket plus a hash map.
- Concurrency is one guard away — when the shape of the data is simple.
- Durability is just "write it down before you forget." AOF is a 5-line idea that powers half the data world.
redis-clitalking to your own server is a dopamine hit. Watching a real client parse your hand-rolled protocol is the best test there is.
Why "from scratch"
The pull to grab a framework, a library, an off-the-shelf engine is enormous — it's faster and it works. But "works" and "understands" are different words. Every distributed thing I touch at work or in the next project is just more framed bytes and more logs to replay. Building tiny-redis made me see them.
Next on the bench: a distributed job queue (gopherq) — PostgreSQL FOR UPDATE SKIP LOCKED, worker pools, and Redis. Same instinct, bigger surface.
Built in Go · tiny-redis on GitHub