System Design ·

How Redis Works: Single-Threaded Execution, Data Structures, and Persistence Mechanics From Command to Disk

A deep dive into Redis internals covering the single-threaded event loop, core data structure implementations (SDS, skip lists, radix trees), RDB and AOF persistence, replication, pub/sub, and memory eviction policies with TypeScript client examples.

How Redis Works: Single-Threaded Execution, Data Structures, and Persistence Mechanics From Command to Disk

Redis is fast in a way that seems to contradict itself. It runs on a single thread, never uses parallelism for command execution, and serializes every operation through one event loop. Yet it routinely sustains hundreds of thousands of operations per second on commodity hardware. Understanding why requires looking past the “it’s in-memory” explanation and into the actual mechanics: the event loop architecture, how each data structure is laid out in memory, and how persistence works without blocking the command path.

This article walks through each layer of Redis from the moment a command arrives on the network to the point where data either lives safely on disk or gets evicted. It covers the single-threaded execution model, the internal representation of strings, hashes, sorted sets, and streams, the RDB and AOF persistence strategies with their actual tradeoffs, replication mechanics, and the memory management policies that govern behavior under pressure. TypeScript examples using ioredis illustrate how these internals affect real client code.

The Single-Threaded Event Loop

Redis processes commands one at a time, sequentially, in a single thread. This is not a limitation: it is a deliberate design that eliminates the entire class of problems that come with concurrent mutation. No locks, no compare-and-swap, no contention. If two clients send INCR counter simultaneously, one command executes completely before the other starts. The result is always correct without any synchronization overhead.

The engine underneath is ae (Async Events), Redis’s own event library. It wraps epoll on Linux, kqueue on BSD, and falls back to select. The loop body is:

  1. Call epoll_wait to collect all file descriptors ready for read or write.
  2. Dispatch read events: parse the RESP (REdis Serialization Protocol) frame, execute the command against the in-memory data structures, write the response to the client’s output buffer.
  3. Dispatch write events: flush output buffers to sockets.
  4. Run time events (expiry checks, background job callbacks).

The key insight is that none of these steps involve I/O blocking. Socket reads and writes are non-blocking; if a write buffer is full, Redis registers a write event and returns to the loop. The actual command execution against data structures is pure CPU and memory work, which is fast enough that it rarely becomes the bottleneck before the network does.

Since Redis 6.0, I/O threads handle reading from sockets and writing responses on separate threads, but command execution still happens on the main thread. This is sometimes called “threaded I/O” and it improves throughput on high-core machines without changing the single-threaded execution guarantee for commands.

import Redis from "ioredis";

const redis = new Redis({ host: "localhost", port: 6379 });

// These three commands are pipelined: sent in one TCP write, parsed
// and executed sequentially on the single thread, responses sent back together.
const pipeline = redis.pipeline();
pipeline.set("visits", 0);
pipeline.incr("visits");
pipeline.get("visits");

const results = await pipeline.exec();
// results: [[null, "OK"], [null, 1], [null, "1"]]

Pipelining matters here because it amortizes the round-trip cost. The server still executes commands one at a time, but the client does not wait for each response before sending the next command.

Core Data Structures and Their Implementations

Strings: Simple Dynamic Strings (SDS)

Redis strings are not C strings. They use a custom type called SDS (Simple Dynamic Strings) defined in sds.h. Each SDS header stores the string length, the allocated capacity, and flags indicating the header size. This gives O(1) strlen, safe binary data (no null terminator requirement), and predictable allocation growth.

When a string value is a small integer (between -2^63 and 2^63-1), Redis stores it as an actual integer in the ptr field of the robj (Redis object) struct rather than allocating an SDS at all. For strings up to 44 bytes, Redis uses an “embstr” encoding where the robj and the SDS share a single allocation, reducing memory fragmentation.

// Redis encodes these differently internally:
await redis.set("counter", "42");      // stored as integer
await redis.set("name", "alice");      // embstr (< 44 bytes)
await redis.set("doc", "x".repeat(200)); // raw SDS

// You can inspect encoding with DEBUG OBJECT (development only)

Hashes: Listpack to Hash Table

Small hashes (fewer than 128 fields, values under 64 bytes by default) use a listpack: a flat byte array where each entry is a consecutive triple of (encoding, data, backlen). There are no pointers, so iteration is cache-friendly and memory usage is minimal.

Once either threshold is exceeded, Redis converts the listpack to a proper hash table. The hash table uses separate chaining with linked lists for collision handling and incremental rehashing. When the load factor exceeds 1.0 (or 0.1 during BGSAVE to reduce copy-on-write pressure), Redis allocates a new table of double the size and migrates a few buckets on every command that touches the hash until the migration is complete. This is the “progressive rehashing” that prevents O(n) pauses during resize.

const key = "user:1001";

// Stored as listpack while field count stays under 128
await redis.hset(key, {
  name: "alice",
  email: "alice@example.com",
  plan: "pro",
  created: "2026-01-15",
});

const user = await redis.hgetall(key);
console.log(user.name); // "alice"

Sorted Sets: Skip Lists Plus a Hash Table

Sorted sets are the most interesting internal structure. Each sorted set is backed by two data structures simultaneously: a hash table mapping member to score, and a skip list ordering members by score.

A skip list is a probabilistic data structure that achieves O(log n) search, insert, and delete by maintaining multiple layers of linked lists. The bottom layer contains all elements in sorted order. Each higher layer is a randomly sampled subset of the layer below, acting as an express lane. The maximum level in Redis is 64, and each node is promoted to level L with probability 0.25^L.

The hash table provides O(1) score lookup by member. The skip list provides O(log n) range queries by score or rank. Commands like ZRANGEBYSCORE, ZRANK, and ZRANGEBYLEX all operate on the skip list.

// Leaderboard with score-based range queries
const board = "game:leaderboard";

await redis.zadd(board, 9820, "alice");
await redis.zadd(board, 8450, "bob");
await redis.zadd(board, 11200, "carol");

// Top 3 by score descending (uses skip list traversal)
const top3 = await redis.zrevrangebyscore(board, "+inf", "-inf", "LIMIT", 0, 3, "WITHSCORES");
// ["carol", "11200", "alice", "9820", "bob", "8450"]

// ZRANK is O(log n): walks skip list levels to count rank
const rank = await redis.zrevrank(board, "alice");
console.log(rank); // 1

Streams: Radix Trees of Listpacks

Redis Streams store entries in a radix tree (a compressed prefix trie) indexed by the entry ID, which is a millisecond timestamp combined with a sequence number (e.g., 1716134400000-0). Each radix tree node stores a listpack containing multiple entries, amortizing the per-entry overhead.

This design gives efficient range reads (XRANGE), consumer group tracking, and partial acknowledgement semantics, all while keeping memory usage compact for time-series workloads.

const stream = "events:clicks";

// Add entries (auto-generated IDs use server timestamp)
await redis.xadd(stream, "*", "url", "/pricing", "uid", "u_9987");
await redis.xadd(stream, "*", "url", "/docs", "uid", "u_1122");

// Create consumer group for at-least-once delivery
await redis.xgroup("CREATE", stream, "analytics-workers", "$", "MKSTREAM");

// Read up to 10 undelivered messages
const messages = await redis.xreadgroup(
  "GROUP",
  "analytics-workers",
  "worker-1",
  "COUNT",
  10,
  "STREAMS",
  stream,
  ">"
);

if (messages) {
  for (const [, entries] of messages) {
    for (const [id, fields] of entries) {
      console.log(id, fields);
      await redis.xack(stream, "analytics-workers", id);
    }
  }
}

Persistence: RDB vs AOF

RDB Snapshots

RDB (Redis Database) creates a point-in-time binary snapshot of all keys. When BGSAVE runs (triggered manually, by save config rules, or before shutdown), Redis calls fork(). The child process iterates all data structures and writes a compact binary format to a temp file, then atomically renames it over the existing .rdb file. The parent continues serving commands.

The fork is cheap because the OS uses copy-on-write (CoW). Pages are shared until either process modifies them, at which point the OS copies the page. The cost of BGSAVE is roughly proportional to how many pages the parent modifies during the snapshot window: low write rate means low CoW overhead.

RDB files are self-contained and fast to load at startup. The tradeoff is durability: if Redis crashes between snapshots, you lose all writes since the last successful BGSAVE.

AOF (Append-Only File)

AOF logs every write command as it is executed, appending it to a file in RESP format. Three fsync policies control durability:

  • always: fsync after every write. Maximum durability, significant write throughput reduction.
  • everysec (default): fsync once per second on a background thread. At most one second of data loss on crash.
  • no: let the OS decide. Fastest, but potentially minutes of data loss.

AOF files grow unboundedly, so Redis periodically rewrites them. BGREWRITEAOF forks a child that writes a minimal AOF representing the current state (equivalent to a series of SET/HSET/etc commands). During rewrite, new commands are buffered in memory and appended to the new AOF after the child finishes.

// You can trigger persistence manually from the client
await redis.bgsave();          // start RDB snapshot
await redis.bgrewriteaof();    // start AOF rewrite

// Check persistence status
const info = await redis.info("persistence");
// Parse the INFO response to check rdb_bgsave_in_progress, aof_rewrite_in_progress
const lines = info.split("\r\n");
const status = Object.fromEntries(
  lines
    .filter((l) => l.includes(":"))
    .map((l) => l.split(":") as [string, string])
);
console.log(status["rdb_last_bgsave_status"]);    // "ok"
console.log(status["aof_current_size"]);           // bytes

Running both RDB and AOF is the recommended production setup: AOF provides durability, RDB provides fast restart.

Replication: Async Leader-Follower with Partial Resync

Redis replication is asynchronous. The leader (primary) does not wait for replicas to acknowledge writes before responding to clients. This means replicas can be momentarily behind, and a crash during that window means data loss on failover.

When a replica connects for the first time, the primary runs BGSAVE, sends the resulting RDB file to the replica, then streams buffered writes. On reconnect after a brief disconnect, Redis uses partial resync: the primary maintains a replication backlog (a ring buffer, 1MB by default). If the replica’s replication offset is still within the backlog, only the missing commands are sent. If not, a full resync (new BGSAVE) is required.

Each instance has a replication ID (a random 40-character hex string). After a failover, the new primary gets a new replication ID. Replicas that were connected to the old primary can still do partial resync if the offset is within the new primary’s secondary replication ID window, which is preserved for exactly this scenario.

// Check replication topology from client
const replicationInfo = await redis.info("replication");
// role:master
// connected_slaves:2
// slave0:ip=10.0.0.2,port=6379,state=online,offset=83291,lag=0
// master_replid:a3b8f2c1d4e5...
// master_repl_offset:83291
// repl_backlog_size:1048576

Pub/Sub vs Streams

Redis pub/sub is fire-and-forget at the broker level. Messages published to a channel are delivered to all current subscribers and discarded. If a subscriber is offline, it misses the message. There is no persistence, no consumer group concept, no replay.

Streams solve these problems. Entries persist in the radix tree, consumer groups track which entries each logical consumer has processed, and XPENDING shows unacknowledged messages per consumer. Use pub/sub for ephemeral fan-out (live notifications, cache invalidation signals). Use Streams for durable message queues, event sourcing, or audit logs.

Memory Management and Eviction

Redis holds everything in memory. When maxmemory is reached, Redis applies the configured eviction policy before accepting new writes:

  • noeviction: return errors on write commands (safe for session stores where you must not lose data).
  • allkeys-lru: evict the least-recently-used key across all keys. General-purpose caching default.
  • volatile-lru: LRU eviction restricted to keys with a TTL set. Lets you protect non-expiring data.
  • allkeys-lfu: evict the least-frequently-used key. Better for skewed access patterns.
  • volatile-ttl: evict the key with the shortest remaining TTL first.
  • allkeys-random / volatile-random: random eviction within the target set.

Redis implements LRU approximately, not exactly. It samples a configurable number of keys (maxmemory-samples, default 5) and evicts the least-recently-used among the sample. This avoids maintaining a true LRU list, which would require a pointer update on every read.

// Set key with TTL to participate in volatile-* eviction policies
await redis.set("session:abc123", JSON.stringify({ userId: 42 }), "EX", 3600);

// Set maxmemory policy from client (or via redis.conf)
await redis.config("SET", "maxmemory-policy", "allkeys-lru");
await redis.config("SET", "maxmemory", "512mb");

// Monitor memory usage
const memInfo = await redis.info("memory");
// used_memory_human:241.56M
// maxmemory_human:512.00M
// mem_fragmentation_ratio:1.23

A fragmentation ratio above 1.5 means the allocator (jemalloc by default) is holding freed memory it has not returned to the OS. MEMORY PURGE triggers jemalloc’s arena purge to reclaim it.

Tradeoffs: Redis Deployment Patterns

PatternDurabilityThroughputComplexityFailoverUse Case
Standalone, no persistenceNoneHighestLowestManual restartEphemeral cache, dev
Standalone + AOF everysec~1s lossHighLowManual restartSession store, rate limiting
Standalone + RDB + AOFMinimal lossHighLowManual restartGeneral purpose
Primary + 1 replicaReplica lagHighMediumManual or SentinelRead scaling, warm standby
Redis Sentinel (3 nodes)Replica lagHighMediumAutomatic (~30s)HA without clustering
Redis Cluster (6+ nodes)Replica lagHighestHighAutomatic per shardHorizontal scale, >100GB
Redis with WAIT commandSynchronousLowerLow overheadSafe failoverFinancial writes

The WAIT command blocks the client until N replicas have acknowledged the write or a timeout elapses. It turns async replication into synchronous for individual commands where losing the write is not acceptable.

// Write and wait for at least 1 replica to acknowledge, timeout 500ms
await redis.set("payment:tx:8821", JSON.stringify({ amount: 5000, status: "pending" }));
const replicaCount = await redis.wait(1, 500);
if (replicaCount < 1) {
  // No replica confirmed — log and handle accordingly
  console.warn("Replica acknowledgement timed out for critical write");
}

Production Considerations

Keyspace notifications can fire pub/sub messages on key events (expiry, eviction, write). Enable selectively: notify-keyspace-events Ex for expiry events only. Enabling all events at high throughput adds measurable overhead on the main thread.

SCAN over KEYS: never use KEYS * in production. It blocks the event loop for the full duration of the scan. SCAN iterates with a cursor, returning a subset per call, and yields between iterations. It may return duplicates and does not guarantee complete coverage if the keyspace resizes mid-scan, but it does not block.

// Safe keyspace iteration
async function scanAll(pattern: string): Promise<string[]> {
  const keys: string[] = [];
  let cursor = "0";
  do {
    const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100);
    cursor = nextCursor;
    keys.push(...batch);
  } while (cursor !== "0");
  return keys;
}

Slow log: set slowlog-log-slower-than to 10000 (microseconds) and review SLOWLOG GET regularly. Commands that show up here are candidates for restructuring: large SMEMBERS calls, unindexed sorted set scans, or scripts doing too much work per invocation.

Lua scripts and MULTI/EXEC: both provide atomicity on the single thread. Lua scripts run to completion with no interleaving. MULTI/EXEC queues commands and executes them as a batch without interleaving from other clients, but individual commands can still fail (there is no rollback). Choose Lua when the logic depends on values read during the same atomic operation.

Connection pooling: each Redis connection holds a TCP socket and associated buffers. At high concurrency, opening a new connection per request is expensive. Use a pool sized to your concurrency profile. ioredis handles this with its cluster and sentinel clients, but for standalone instances you manage the pool explicitly or rely on a proxy like Envoy or Twemproxy.

The event loop model makes Redis predictable in ways that multithreaded systems are not. Latency spikes almost always trace back to one of three causes: a blocking command holding the loop (large LRANGE, SMEMBERS, or a Lua script), excessive copy-on-write pressure during BGSAVE from a high write rate, or memory fragmentation forcing the allocator to search for suitable blocks. Knowing the internals makes each of these diagnosable from the INFO output alone.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.