Skip to content
← All writing

I rebuilt Redis to stop treating systems as magic

A week of TCP sockets, partial reads, event loops, and the oddly satisfying moment a tiny key-value server finally behaved.

October 12, 2026 · 9 min read
A monochrome desk scene illustrating an in-memory database, terminal, and data flow.

The first version was almost embarrassingly small

I started with one goal: send bytes to a server and get the right bytes back. No persistence, no fancy data types, and definitely no claim that I was about to replace Redis. Just a C++ process listening on a TCP socket and a client asking it to remember a value.

That tiny milestone changed how I looked at systems software. Tools like Redis feel obvious from the outside—SET, GET, done—but the clean interface hides a pile of decisions about framing, partial reads, memory, time, and fairness between clients.

Rebuilding a familiar tool is useful because every abstraction you normally borrow becomes a decision you have to make.

TCP gives you a stream, not a message

My first incorrect assumption was that one write from the client would become one read on the server. TCP makes no such promise. A request can arrive in pieces, several requests can arrive together, and the network is perfectly happy to pause between any two bytes.

I replaced hopeful parsing with a small length-prefixed binary protocol. Each frame carries enough information for the server to know when a complete request has arrived. The connection keeps unread bytes until the next event instead of pretending the packet boundary is meaningful.

struct FrameHeader {
  uint32_t payload_size;
  uint8_t operation;
};

bool complete_frame(const Buffer& input) {
  if (input.size() < sizeof(FrameHeader)) return false;
  auto header = decode_header(input);
  return input.size() >= sizeof(FrameHeader) + header.payload_size;
}

This was not glamorous work, but it was the point where the project stopped being a map with sockets and started becoming a server.

One thread, many impatient clients

The next question was concurrency. A thread per connection would have been easier to explain, but I wanted to understand event-driven I/O. I used poll() to keep one thread responsible for many client sockets.

Every connection has input and output buffers. Readable sockets feed the parser. Writable sockets drain pending responses. Slow clients do not get to freeze everyone else, and a response that cannot be written immediately stays queued for the next writable event.

while (running) {
  auto ready = poller.wait(timeout_ms);
  for (const auto& event : ready) {
    if (event.readable) read_from(event.fd);
    if (event.writable) write_to(event.fd);
  }
  expire_idle_connections();
  expire_keys();
}

The hardest bugs lived between those lines: a buffer erased too early, a closed file descriptor still present in the poll set, or a response written only halfway. Each one made the server feel random until I learned to treat state transitions as the real program.

Time is another data structure

Once GET, SET, and DEL worked, I added connection deadlines and key expiry. A naive expiration loop would scan the entire keyspace. Instead, I stored expiry times in a min-heap so the nearest deadline stays at the top.

That design creates a subtle problem: a key can receive a new TTL while an older heap entry still exists. Rather than performing expensive arbitrary removals, I let old entries remain and verify their version when they reach the top. Stale work becomes cheap to ignore.

It is a small pattern, but it appears everywhere: sometimes deleting obsolete state immediately is more expensive than recognizing it later.

What actually changed for me

The finished server is deliberately limited. It does not have Redis persistence, replication, cluster behavior, or the years of engineering behind the real project. What it gave me was more valuable than a long feature list: a mental model.

I now see a server as a loop that repeatedly answers four questions:

  • Which connections can make progress?
  • What incomplete state must survive until the next event?
  • Which deadlines have become actionable?
  • How do we prevent one client from owning the loop?

Before this project, “event-driven server” was vocabulary. After chasing a partial write through three buffers at 1 a.m., it became something I could reason about.

That is why I like building small versions of big systems. The result may be tiny, but the abstractions never feel magical again.