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: int for values that are just integers (stored as an actual number, not text), embstr for short strings up to 44 bytes (allocated in a single memory chunk together with the object header, which is faster to allocate and free), and raw for anything longer, backed by Redis's own SDS (Simple Dynamic String) structure rather than a C-style null-terminated string, which is what lets APPEND be 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 a quicklist, 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 real hashtable for 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 uses listpack; anything larger becomes a full hashtable.
  • 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 like ZRANGE) and a hash table mapping member → score for O(1) point lookups like ZSCORE. 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 BGSAVE or 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 always because 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.

Frequently Asked Questions

Is Redis really single-threaded? Command execution is single-threaded by design, which is what makes simple commands atomic without locks. Since Redis 6.0, reading and writing socket bytes can be offloaded to a small pool of I/O threads, but the thread that actually executes your commands against the data is still exactly one.
What happens to my data if Redis crashes right after a write, with no persistence configured? It's gone. With persistence disabled, Redis is purely in-memory, and a crash or restart returns to an empty dataset. This is often fine for a pure cache, but it means Redis alone is not a database for data you can't afford to lose.
Why did OBJECT ENCODING show listpack for my small sorted set but skiplist for a bigger one? Redis automatically switches a sorted set's internal representation once it exceeds configurable thresholds (zset-max-listpack-entries and a byte-size limit). Small sets stay in a compact, linearly-scanned listpack; larger ones switch to a skip list plus hash table so range queries and point lookups both stay fast at scale.
Should I run Redis with only AOF, only RDB, or both? Redis's own guidance is to run both if you want durability comparable to a traditional database: RDB for fast, compact backups and fast restarts, AOF (with the default everysec fsync policy) for a tight recovery point. RDB alone risks losing everything since the last snapshot; AOF alone forgoes RDB's fast-restart and backup advantages.
Does single-threaded command execution mean Redis can't use multiple CPU cores at all? The instance can still use extra cores for background work — the fork()'d child process during RDB snapshots or AOF rewrites runs on its own, and optional I/O threads since Redis 6.0 handle socket reads and writes. The one thing that stays confined to a single core is the actual execution of your commands against the data.