← Blog

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:

  1. RDB — snapshot the whole dataset periodically.
  2. 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

  1. Redis is a protocol, not a black box. The "magic" is framing bytes on a socket plus a hash map.
  2. Concurrency is one guard away — when the shape of the data is simple.
  3. Durability is just "write it down before you forget." AOF is a 5-line idea that powers half the data world.
  4. redis-cli talking 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