Which language should you build Redis in? Lessons from rebuilding it 6 times
Which language should you build Redis in? I rebuilt it from scratch in six of them: Python, Go, Node.js, Rust, Ruby, and Elixir. Same architecture every time, ten incremental modules ending at master/replica replication, graceful shutdown, and a benchmark against real Redis. Every server speaks the official RESP wire protocol so any redis-cli on the planet can drive them. The codebases are public and the curriculum is on learnwithparam.com/build-your-own-redis.
Here is what each language taught me. If you are about to pick a language for a new networked stateful service, this is the comparison I wish I had when I started.
The architecture is invariant. The constant factors are not.
Every server does the same thing:
- Accept TCP connections
- Parse RESP (Redis's wire format)
- Dispatch commands to an in-memory key-value map
- Expire keys lazily on time deadlines
- Append every mutation to a log file
- Snapshot the map atomically to disk on demand
- Fan out pub/sub messages to subscribers
- Stream writes from a master to one or more replicas
- Drain cleanly on SIGTERM and persist before exit
- Benchmark against real Redis
The architectural diagram is identical across all six languages. What changes is how each runtime expresses concurrency, ownership, persistence, and shutdown.
flowchart LR
A[TCP listener] --> B[Per-connection task]
B --> C[RESP parser]
C --> D[Dispatch]
D --> E[Shared store]
D --> F[Pub sub feed]
D --> G[AOF log]
H[Snapshot timer] --> I[RDB file]
F --> J[Replicas]
Picking a language is picking a constant factor on this same shape, plus a developer-experience story for how the next engineer will read it.
The concurrency primitive is the language
Everything else flows from how the language handles many simultaneous connections. Here is the one-line summary per runtime:
| Language | Per connection | Shared state primitive |
|---|---|---|
| Python | OS thread (or selector in step 9) | dict guarded by threading.Lock |
| Go | Goroutine (~2 KB stack) | sync.RWMutex around a map |
| Node.js | Single event loop, async callbacks | Map (no lock needed; single-threaded) |
| Rust | tokio task (~64 bytes) | Arc<Mutex<HashMap>> |
| Ruby | OS thread + GIL | Hash + Mutex.synchronize |
| Elixir | BEAM process (~3 KB) | GenServer state, no locks |
Python and Ruby have OS threads, which means you pay the kernel cost of context switching and you bump into the GIL on CPU work. For an IO-bound server this is fine; for a Redis at scale it is the ceiling.
Go and Rust split the difference: lightweight tasks scheduled by the runtime over a small thread pool. You get the cheap-per-connection model with real parallelism on multi-core machines. The shape Go pushes you toward (goroutine per connection, channels for coordination, shared memory protected by mutexes) is exactly the share-by-communicating model documented in Effective Go. Rust gets you there via Tokio, the async runtime that owns the scheduler, the IO reactor, and the multi-producer channels.
Node.js commits to single-threaded async. The trade is no Mutex anywhere because there is exactly one thread, ever, executing JavaScript. Pub/sub fan-out is a synchronous for-loop over a Set. The price: a long CPU burst in any handler blocks every other request.
Elixir is the outlier. BEAM processes are not OS threads and not coroutines, they are isolated actors scheduled by the BEAM virtual machine, with their own heaps. A crash in one process literally cannot corrupt another. This sounds like a small thing until you start a service that has to stay up for years.
The protocol parser tells you about the language's type system
RESP is a length-prefixed binary protocol with four types: simple string, integer, bulk string, array. Every parser does the same job. What it looks like exposes the language.
Rust uses an enum sum type that the compiler verifies:
enum RespValue {
SimpleString(String),
Integer(i64),
BulkString(Option<String>),
Array(Vec<RespValue>),
}
Every match on this type that misses a variant is a compile error. You cannot ship a parser that crashes on a valid message.
Go has no sum type so it returns any (formerly interface{}) and the dispatch site does a type switch. Same correctness, no compile-time guarantee. Forget a case and the runtime falls through silently.
Node.js uses discriminated objects with a kind field. Convention, not enforcement.
Python uses duck typing and isinstance checks. The dataclass equivalents from typing.Union work but feel grafted on.
Ruby has nothing native; the parser returns [:bulk, "foo"] tagged tuples. Same shape as Lisp from forty years ago.
Elixir surprised me. Binary pattern matching is so good that the parser reads like a spec:
def parse(<<"$", rest::binary>>) do
case parse_line(rest) do
{hdr, rem} ->
n = String.to_integer(hdr)
case rem do
<<payload::binary-size(n), "\r\n", tail::binary>> -> {{:bulk, payload}, tail}
_ -> {:incomplete}
end
end
end
The match expression checks the leading dollar sign, captures the rest as a binary, validates the length, peels off the CRLF, and binds the tail. Bounds checking is free. The Elixir parser is half the LOC of any other sibling.
If you build protocols often, Elixir's binary syntax alone is reason to consider the BEAM.
Pub/sub fan-out is where the languages diverge the most
This is the module where the deltas explode. Every language has to deliver one message to N subscribers without blocking the publisher when one subscriber is slow.
Python (connection list under a lock, iterate, write) ships in about 80 lines.
Go uses a buffered channel per subscriber plus a write-pump goroutine. Slow subscribers get back-pressured at the channel boundary. About 60 lines.
Node.js uses a Map of channel to Set of connections, iterating with synchronous writes. The single-thread model means no locking, but a slow socket back-pressures the entire event loop until the OS write buffer fills. About 50 lines.
Rust uses tokio::sync::broadcast channels with a multi-producer-multi-consumer ring buffer. Subscribers get the message exactly once or a Lagged error if they are too slow. The select! reader/writer pattern interleaves reads and writes inside one task. About 100 lines, mostly the careful ownership story.
Ruby uses one Queue per subscriber plus a Thread that drains it. Same shape as Go's channel + goroutine. About 70 lines.
Elixir uses Registry and send/2. The pub/sub module is:
defmodule PubSub do
def start_link, do: Registry.start_link(keys: :duplicate, name: __MODULE__)
def subscribe(channel), do: Registry.register(__MODULE__, channel, nil)
def publish(channel, payload) do
pids = Registry.lookup(__MODULE__, channel) |> Enum.map(fn {pid, _} -> pid end)
Enum.each(pids, fn pid -> send(pid, {:pubsub_msg, channel, payload}) end)
length(pids)
end
end
That is the entire fan-out. The mailbox of each subscriber process IS the queue. Send is non-blocking. The receive loop in the subscriber drains it. The Registry handles auto-cleanup when a subscriber dies. About 10 lines.
Every other language is reinventing what OTP gave you for free.
Graceful shutdown is the production-readiness gap
Step 9 of every course is the module where amateur servers become production-shaped. The job is: catch SIGTERM, stop accepting new work, drain in-flight requests with a timeout, persist state, exit.
In Python this means signal.signal and a shutdown event passed to every handler thread.
In Go this is signal.NotifyContext plus context.Context plus sync.WaitGroup plus SetReadDeadline. Four primitives composing because each handles one piece.
In Node.js this is process.on('SIGTERM') plus a soft-shutdown flag checked in handlers.
In Rust this is tokio::signal::ctrl_c plus tokio::sync::watch for the fan-out signal plus a Semaphore for in-flight tracking.
In Ruby this is Signal.trap plus a Mutex plus a ConditionVariable plus a manual counter.
In Elixir this is Process.flag(:trap_exit, true) plus a terminate/2 callback. The OTP supervisor handles the rest. No semaphore, no counter, no fan-out signal. The supervision tree already knows about your children.
If you have ever spent a Friday debugging a shutdown bug in production, Elixir's shape is the one that will save you future Fridays.
So which one should you pick
Honest answers by use case.
You want raw throughput and predictable latency: Rust. The async tokio version on a single connection lands close to half of real Redis's per-op latency. Add zero-copy parsing with bytes::BytesMut and DashMap for the store and you close most of the remaining gap without changing architecture. You pay for it with ownership rules every reviewer has to understand.
You want most-of-the-throughput with the simplest mental model: Go. Goroutines plus channels plus sync.RWMutex is the friendliest "fast networked service" stack on the market. Production teams build Redis-shaped services in Go because the cost of training the next engineer is small.
You want the cleanest code and you can deploy on the BEAM: Elixir. The pub/sub module is one quarter the LOC of any other sibling. The supervision tree gives you graceful shutdown for free. The binary pattern matching makes protocol parsers a pleasure. The deployment story (mix release, distributed Erlang) is mature. The throughput is below Go but above everything else. If your service spends its life routing messages between long-lived connections, this is the language.
You want the fastest path from idea to shipped code, accept the perf hit: Node.js. The event loop is built-in, the Buffer API is good, the stdlib net module is enough, and you can put your business logic in front of it without any concurrency complexity. The single-thread ceiling means you scale horizontally rather than vertically.
You already write Python and you do not want to relearn how to think: Python with asyncio. The selector-based event loop step in our Python course shows you can land between Node and Go on throughput. You give up the concurrency simplicity of threads.
You write Ruby and you want to feel powerful again: Ruby plus stdlib. The course is the slowest sibling on the throughput benchmark, but the journey from "I write Rails handlers" to "I wrote a TCP server, an AOF, a pub/sub fan-out, and a graceful shutdown" is the most rewarding of the six. You learn things Rails hides.
What the cross-language exercise really teaches
You think you are building Redis. You are actually learning the architectural primitives that show up in every networked stateful service: streaming protocols, durable logs, atomic file writes, fan-out, lifecycle.
Once you have built it in one language, the second language costs a fraction of the time. By the third you are reading the architecture, not the syntax. By the sixth you understand why some languages feel inevitable for some problems and others feel like running uphill.
If you want to do this yourself, every course is free and every workshop repo is public:
- Build your own Redis in Python
- Build your own Redis in Go
- Build your own Redis in Node.js
- Build your own Redis in Rust
- Build your own Redis in Ruby
- Build your own Redis in Elixir
The series index lives at /build-your-own-redis with the comparison table and the curriculum walkthrough.
Pick a language. Pick a module. Start with step one and a TCP echo server. By the end of an afternoon you have something a senior engineer wrote, with your name on every line.
Continue Reading
Ready to go deeper?
Go beyond articles. Build production AI systems with hands-on workshops and our intensive AI Bootcamp.