What you'll learn
Quick Answer
Redis runs command execution on a single thread using an event loop built on OS-level I/O multiplexing (epoll on Linux), which is why every simple command is effectively atomic and why one slow command can stall every client. Internally, each data type has more than one possible encoding — a small list is stored differently from a huge one — chosen automatically for memory efficiency. For durability, Redis offers RDB point-in-time snapshots, an AOF append-only command log, or both together.
This Isn't About Cache-Aside or Redis vs. Memcached
If you've used Redis, you've probably used it as a cache: GET, SET, an expiry, done. That usage pattern — cache-aside, TTLs, invalidation — is its own topic and worth understanding on its own terms, separately from what's covered here. This post is about a different question: what is redis-server actually doing between the moment a command arrives and the moment a reply goes back out, and what does it do to survive being killed.
Three things make Redis worth understanding at this level rather than treating it as a black box. First, its concurrency model (a single thread executing commands) has sharp edges that only bite in production, under load, with a command you didn't think was "slow." Second, its internal data structures aren't one-size-fits-all — the same ZADD command results in a completely different in-memory layout depending on how big the sorted set gets. Third, its two persistence mechanisms make fundamentally different tradeoffs between performance and durability, and picking the wrong one (or the wrong config within one) is a genuine way to lose data you thought was safe.
One Thread, One Command at a Time
Redis's command execution is, by design, single-threaded: one core runs one command to completion before starting the next. There's no locking between commands because there's no concurrent execution to lock against — this is precisely why INCR, LPUSH, or a ZADD plus a ZINCRBY in a transaction are atomic without you doing anything special. A second client's command simply cannot interleave with the one currently running.
This works because Redis is almost never CPU-bound. The main thread runs an event loop built on your OS's I/O multiplexing facility — epoll on Linux, kqueue on BSD/macOS — which lets one thread monitor thousands of open client sockets at once and get notified only when one of them actually has data ready. No thread is spun up per connection, and no thread sits blocked waiting on a socket that has nothing to say. When a socket becomes readable, the loop reads the command, executes it against the in-memory data structures, writes the reply, and moves to the next ready socket.
The sharp edge: because it's one thread, a single command that takes a long time to execute blocks every other client for that entire duration. Running KEYS * against a database with ten million keys, or a SORT on a huge unsorted list, doesn't just make that one client wait — it stalls every client connected to that Redis instance until the command finishes. This is why Redis ships SCAN as a cursor-based alternative to KEYS, and why command complexity documentation (the O(N) and O(log N) notes in the Redis docs) isn't academic trivia, it's an operational warning.
Since Redis 6.0, an optional threaded I/O mode can offload the reading bytes off the socket and writing reply bytes back parts of the work to a small pool of additional threads, which helps when the bottleneck is network I/O on high-throughput workloads. This does not change the core guarantee: command execution itself — the part that actually touches your data — still happens on the single main thread, one command at a time.
How Each Data Type Is Actually Stored
"Redis has strings, lists, hashes, sets, and sorted sets" is the beginner-level answer. What actually happens is that each type has more than one possible internal encoding, and Redis switches between them automatically based on size, favoring compact memory layouts for small collections and switching to structures with better algorithmic guarantees once a collection grows past a threshold. You can see a key's current encoding yourself with OBJECT ENCODING keyname. As documented by Redis itself, the real encodings are:
- Strings:
intfor values that are just integers (stored as an actual number, not text),embstrfor short strings up to 44 bytes (allocated in a single memory chunk together with the object header, which is faster to allocate and free), andrawfor anything longer, backed by Redis's own SDS (Simple Dynamic String) structure rather than a C-style null-terminated string, which is what letsAPPENDbe efficient and lets binary-safe values contain null bytes. - Lists: a small list is a
listpack— a flat, contiguous, memory-efficient sequence of entries. Once it grows past the configured threshold, it becomes aquicklist, which is a doubly linked list where each node is itself a small listpack, combining fast push/pop at both ends with reasonable memory density. - Hashes: small hashes use
listpack(flat key-value pairs, scanned linearly); larger ones switch to a realhashtablefor O(1) field lookups. - Sets: a set containing only small integers uses
intset, a sorted array of integers — extremely compact. A small set of mixed values useslistpack; anything larger becomes a fullhashtable. - Sorted sets: small ones use
listpack, kept sorted by score, scanned linearly for range queries. Once a sorted set grows past the threshold, Redis maintains two structures for the same data simultaneously: a skip list (a linked structure with multiple "express lane" levels, giving O(log n) score-ordered range queries likeZRANGE) and a hash table mapping member → score for O(1) point lookups likeZSCORE. Every write updates both.
The thresholds that decide when a structure converts from the compact encoding to the bigger one are configurable — hash-max-listpack-entries, zset-max-listpack-entries, and their byte-size counterparts are the actual config directive names — and once a structure converts to the bigger encoding, it does not convert back down even if you delete entries until the key itself is rewritten.
RDB: Point-in-Time Snapshots via fork() and Copy-on-Write
RDB persistence writes the entire dataset to a single compact file, dump.rdb, at configured intervals. The actual directive, straight from Redis's own docs, looks like this in redis.conf:
save 60 1000
which means: automatically write a full snapshot if at least 1000 keys changed within the last 60 seconds. You can also trigger one manually with SAVE (blocking) or BGSAVE (non-blocking).
The mechanism behind "non-blocking" is the interesting part. When a snapshot is due, Redis calls the POSIX fork() system call, which creates a child process that is, at the instant of forking, an exact copy of the parent's memory. The child writes that memory out to a temporary RDB file and then atomically renames it into place; the parent process meanwhile keeps serving client commands completely undisturbed. This only works efficiently because of copy-on-write: the operating system doesn't actually duplicate the parent's memory pages on fork, it just marks them shared between parent and child; a page is only physically copied at the moment either process writes to it. So a fork of a 20 GB Redis instance doesn't require 20 GB of extra memory unless nearly every page gets modified during the snapshot.
The real cost is the fork itself, not the writing: on a very large dataset, or under memory pressure, the fork can take long enough to noticeably stall the main thread — because forking a big process is genuinely expensive even before any copy-on-write triggers. The other honest limitation of RDB alone: you only ever get back the data as of the last completed snapshot. If Redis crashes 45 seconds after the last save with a save 60 1000 policy, those 45 seconds of writes are gone.
AOF: Logging Every Write Instead
Append-only file (AOF) persistence takes a different approach: instead of periodic full snapshots, it logs every write command as it happens, then replays that log from the start on server restart to rebuild the dataset. Enabling it is one directive:
appendonly yes
How often that log actually gets flushed to disk is controlled by appendfsync, with three real options straight from Redis's documentation:
appendfsync always— fsync after every single write. Safest, and slow enough that it's rarely used in production.appendfsync everysec— fsync once per second in a background thread. This is Redis's documented default and its suggested policy: fast, and you lose at most about one second of writes if the process dies.appendfsync no— never fsync explicitly; let the OS decide when to flush. Fastest, least durable.
An always-growing log is a problem on its own — incrementing one counter a hundred times produces a hundred log entries for a value you could reconstruct with one. Redis solves this with AOF rewrite (manually via BGREWRITEAOF, or automatically), which uses the same fork-plus-copy-on-write trick as RDB: a child process writes the minimum set of commands needed to reproduce the current dataset into a fresh file, while the parent keeps appending live writes elsewhere so nothing is lost mid-rewrite, and the two are atomically merged once the child finishes. Since Redis 7.0, this got more robust: AOF is now split into a base file plus one or more incremental files tracked by a manifest, rather than one single ever-rewritten file, which avoids the specific failure modes (double-writes during rewrite, memory spikes buffering live writes) that affected the pre-7.0 single-file design.
The practical guidance Redis itself gives: if you want durability comparable to a traditional relational database, run both RDB and AOF together. RDB gives you fast, compact backups and fast restarts on large datasets; AOF gives you the tighter recovery-point guarantee. On restart, if both are present, Redis loads from AOF, since it's guaranteed to be the more complete of the two.
Trying This Yourself
In good conscience: the sandbox this article was verified in does not have redis-server or redis-cli installed, and there was no way to spin up a real instance to capture live output for this section. Everything about the event loop model and the persistence directives above was cross-checked directly against Redis's own current official documentation rather than recited from memory. If you want to see it yourself, install Redis locally (or run docker run -p 6379:6379 redis) and this is real, correct syntax to try:
# a list, small enough to stay a listpack
RPUSH mylist "a" "b" "c"
LRANGE mylist 0 -1
OBJECT ENCODING mylist # -> "listpack"
# a sorted set, small enough to stay a listpack too
ZADD leaderboard 100 "alice" 250 "bob" 80 "carol"
ZRANGE leaderboard 0 -1 WITHSCORES
OBJECT ENCODING leaderboard # -> "listpack"
# push past the threshold (default zset-max-listpack-entries is 128)
# and re-check the encoding -> it will flip to "skiplist"
The OBJECT ENCODING command is the actual, documented way to confirm everything in the data-structures section above on a live instance — it's not a debugging trick, it's the officially supported way to introspect this.
Where These Internals Actually Bite You
This isn't trivia. A few concrete ways the internals above show up as real production incidents:
- Someone runs
KEYS user:*in a hot path "just to check something" against a database with millions of keys, and every other request to that Redis instance stalls for the entire duration, because of the single-threaded execution model — not a bug, exactly the documented behavior. - A
BGSAVEor AOF rewrite on a large, high-write-rate dataset causes a memory spike, because copy-on-write ends up duplicating a large fraction of pages during the fork's lifetime — undersized memory headroom on the host turns a routine snapshot into an OOM kill. - A sorted set that quietly grew past the listpack threshold months ago now costs noticeably more memory per entry than the team's original capacity planning assumed, because nobody accounted for the automatic conversion to skiplist+hashtable.
- A team runs AOF with
appendfsync alwaysbecause it sounds safest, then can't understand why write throughput cratered — that setting is a real, documented, severe latency cost, not a free durability upgrade.
