Guide · Backend
Redis Internals Explained: Event Loop, RDB vs AOF and Eviction
24 min readUpdated By Saket Bhatnagar
Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.
In short
- Redis internals come down to one idea: a single main thread runs every command, one after another, so one slow command makes every other client wait.
- An event loop is the main thread's to-do cycle: ask the operating system which network connections have data, run those commands, send replies, repeat.
- Commands that touch millions of items freeze the server: KEYS, which lists matching keys, or DEL of a huge set. SCAN works in small steps, and UNLINK frees memory on a background thread.
- Redis keeps data in memory and saves it two ways: RDB, a snapshot file written by a child copy of the server, and AOF, a log of every write.
- Beginners can stop after the section on what blocks the server. Engineers get measured timings on Redis 8.10.2, the 16-key shortlist behind eviction, why UNLINK still frees small values inline, and what I/O threads really do.
What goes wrong when one slow command hits a busy Redis server?
Every other client stops getting answers until that command finishes. Redis internals explain why: a single thread runs all commands in order, so a 300 millisecond command adds up to 300 milliseconds of wait for everyone queued behind it.
Picture a common setup. A food delivery app in Bengaluru keeps login sessions, rate-limit counters and a set of today's order IDs in one Redis server. Every night at 11 pm a cleanup script runs, and for a moment the whole app stalls. The setup is made up, but every timing in this guide was measured on a real Redis 8.10.2 server.
The script does two innocent-looking things. It runs KEYS with a pattern to find old sessions, and it deletes yesterday's order set, which holds 3 million (30 lakh) members. On a 2-core test machine, the first froze the server for about a third of a second. The second froze it for about two seconds.
This guide follows that one server. You will see why those two commands hurt, how Redis saves data to disk without stopping, and how it decides what to throw away when memory runs out.
Which words do you need before learning Redis internals?
You need six words. Each one gets a plain meaning here, and the rest of the guide uses only these names.
- Command: one request a client sends, such as GET user:42 or SET user:42 online. Redis replies to each command.
- Thread: a line of work inside a program that the CPU can run. A program with one main thread does one thing at a time on that thread.
- Event loop: the main thread's endless cycle. It asks the operating system which connections have data, runs the commands that arrived, sends replies and starts again.
- Fork: a Linux call that clones a running process. The clone, called the child, starts with the same memory as the parent.
- fsync: a call that forces data the operating system is holding in memory onto the disk. Until then, a power cut can lose it.
- Eviction: deleting keys on purpose when Redis reaches its memory limit, so new writes can fit.
How do Redis internals work?
Redis internals work like a single, very fast counter clerk. One main thread takes commands from many connections, runs each one to completion on data held in memory, and replies, one command at a time.
Because the data is in memory and most commands touch one key, each command usually takes microseconds. That speed is why one thread can keep up with a large number of clients. It is also why one slow command is so visible.
Think of a railway ticket counter with one clerk. Passengers are clients, and their requests are commands. The clerk is the main thread, and the clerk serves one request at a time from start to finish.
A normal request, one ticket, takes seconds, so the queue moves quickly. One passenger asking for 500 tickets holds up everyone behind them. That is KEYS on a big database. Where the picture breaks: Redis keeps no single queue in arrival order. Each turn of the loop serves whichever connections have data ready, and helpers can do the paperwork around requests.
One turn of the Redis event loop
Drag to exploreNotice that only the main thread touches your data. Background threads only free memory, flush the AOF and close files after the main thread hands them a job.
Diagram connections:
- Many clients to epoll wait (bytes arrive)
- epoll wait to Read and parse (ready sockets)
- Read and parse to Execute one command (parsed commands)
- Execute one command to Keyspace in RAM (reads and writes)
- Execute one command to Write replies (reply buffers)
- Execute one command to Background threads (slow jobs handed off)
- Write replies to Many clients (responses)
- Many clients: Apps sending GET, SET, DEL over TCP
- epoll wait: Kernel says which sockets have data ready
- Read and parse: Main thread, or I/O threads if enabled
- Execute one command: Main thread only, runs to completion
- Keyspace in RAM: All keys and values live in memory
- Write replies: Main thread, or I/O threads if enabled
- Background threads: Lazy free, AOF fsync, closing files
On Linux, the waiting step uses epoll, a kernel feature that watches thousands of connections at once and reports which ones have data. Our 8.10.2 server reports this in INFO server as multiplexing_api:epoll.
What runs on which thread in Redis 8.10?
Command execution runs on one main thread, and a few helper threads do side jobs. The official latency guide puts it this way: Redis is mostly single threaded and serves all requests using a single thread.
On our test server we listed the threads of the running process. With default settings there were four: the main thread plus three background threads named bio_close_file, bio_aof and bio_lazy_free. Bio is short for background I/O.
| Thread | When it exists | What it does | Does it run your commands? |
|---|---|---|---|
| Main thread | Always | Runs the event loop and executes every command | Yes, all of them |
| bio_lazy_free | Always | Frees memory for UNLINK and the ASYNC options | No |
| bio_aof | Always | Calls fsync on the AOF file in the background | No |
| bio_close_file | Always | Closes old files so the main thread does not wait | No |
| io_thd_1 and up | Only when io-threads is 2 or more | Reads, parses and writes network data for its clients | No |
| Forked child | During BGSAVE or an AOF rewrite | A separate process that writes a snapshot to disk | No |
The useful rule is simple. For the core data types, anything a command does to your data, including searching, sorting and freeing memory, happens on the main thread. The exception is a command or setting that hands work off.
One scope note: Redis 8 also bundles a search and query engine whose release notes describe worker threads, set by search-workers. This guide covers the core data types only.
What actually blocks the Redis server?
Anything that keeps the main thread busy for a long time blocks every client. In practice that means commands that touch many items, deleting huge values, long scripts and the SAVE command.
KEYS and other commands that touch every item
KEYS walks every key in the database to test the pattern. Its documentation gives the cost as O(N), meaning the time grows in step with N, the number of keys. It also says not to use KEYS in regular application code.
We loaded 2,000,000 keys with DEBUG POPULATE, a testing command that creates dummy keys. KEYS with a narrow pattern matched only 111 keys, yet took 312 to 367 milliseconds across three runs. A PING sent 50 milliseconds after KEYS started waited 263 to 317 milliseconds for its reply.
The fix is SCAN, which walks the keyspace in small steps and returns a cursor, a bookmark for the next step. With COUNT 1000, SCAN found the same 111 keys in 2,000 calls. The slowest PING between those calls took 0.10 milliseconds.
SCAN has two catches. The docs say a key may come back more than once, so your code must handle duplicates. COUNT is only a hint, and its default is 10. The latency guide names SORT, LREM and SUNION as other commands that can be slow on big collections. Check each command's documented time complexity before using it on big data.
A big DEL versus UNLINK
Now the surprise. DEL of a set with 3,000,000 members took 2,069 and 2,383 milliseconds on our 8.10.2 build. On the Ubuntu package of Redis 7.0.15 on the same machine, it took about 920 to 950 milliseconds. The two builds use different memory allocators, so treat both numbers as rough.
DEL is slow because the main thread must free every member, one memory allocation at a time. UNLINK removes the key from the keyspace at once and hands the freeing to the bio_lazy_free thread. UNLINK of the same set returned in 0.32 milliseconds, and once in 16 milliseconds.
The memory really is freed, just later. After the DEL and the UNLINK, used_memory fell from 278 MB to 1.19 MB within 1.5 seconds, so the background thread had finished. FLUSHALL ASYNC works the same way: 0.21 milliseconds for 2 million keys, against 1,087 milliseconds for FLUSHALL SYNC.
| Operation | Time the main thread was busy | Non-blocking alternative | Its time |
|---|---|---|---|
| KEYS pattern on 2M keys | 312 to 367 ms | SCAN with COUNT 1000 | Under 0.1 ms per call |
| DEL of a 3M-member set | 2,069 to 2,383 ms (918 to 947 ms on 7.0.15) | UNLINK | 0.32 ms, once 16 ms |
| FLUSHALL SYNC of 2M keys | 1,087 ms | FLUSHALL ASYNC | 0.21 ms |
| SAVE of a 247 MB dataset | 4,237 ms | BGSAVE | 15 ms to reply |
| DEBUG SLEEP 1 | A PING waited 902 ms | None, it exists to test blocking | Not applicable |
So the answer to the prediction: the DEL hurt about six times more than KEYS. Deleting is not free when a value holds millions of pieces.
Deletes you did not ask for
Redis also deletes values by itself. It does so when a key expires, when eviction removes a key, and when SET or RENAME overwrites an old value. The 8.10.2 configuration file says all of these free memory in a blocking way by default.
Each case has a switch: lazyfree-lazy-expire, lazyfree-lazy-eviction, lazyfree-lazy-server-del and lazyfree-lazy-user-del. All of them were set to no on our server. Setting lazyfree-lazy-user-del to yes made a plain DEL of the big set return in 0.20 milliseconds.
Long scripts and SAVE
A Lua script, a small program that Redis runs inside the server, also holds the main thread. We lowered busy-reply-threshold from its default of 5,000 ms to 500 ms and ran a script that looped for three seconds. Once the script ran past the threshold, another client's PING got a BUSY error instead of PONG.
SAVE writes the snapshot file on the main thread itself. On our 247 MB dataset it took 4,237 milliseconds, and a PING sent during it waited 4,190 milliseconds. BGSAVE does the same work in a child process, covered next.
# Commands slower than 10 ms (the default) are logged here
redis-cli SLOWLOG GET 5
# Record any event over 100 ms, then read the latest spikes
redis-cli CONFIG SET latency-monitor-threshold 100
redis-cli LATENCY LATEST
# Find keys without blocking: repeat with the returned cursor until it is 0
redis-cli SCAN 0 MATCH 'session:*' COUNT 1000
# Delete a huge value without freezing the server
redis-cli UNLINK orders:yesterdayWhat just happened? On our server, SLOWLOG GET showed each KEYS call with its time in microseconds, such as 366338, which is 366 milliseconds. LATENCY LATEST showed an event named command with its latest and worst spike in milliseconds.
How does Redis save data to disk with RDB and AOF?
Redis offers two ways to save data. RDB writes a full snapshot of memory to one compact file every so often. AOF, the append-only file, logs every write command so they can be replayed after a restart.
With no config file, our 8.10.2 server had RDB snapshots on with the rules 3600 1 300 100 60 10000. That means save after one hour if at least one key changed, after five minutes if 100 changed, or after one minute if 10,000 changed. AOF was off.
RDB: a snapshot written by a child process
BGSAVE forks the server. The child process has the same memory as the parent at that instant, so it writes a consistent snapshot while the parent keeps serving clients. In ps, the child appears as redis-rdb-bgsave.
The catch is the data written after the last snapshot. The official persistence page says that with RDB you should be ready to lose the latest minutes of data if Redis stops without a clean shutdown.
AOF: a log of every write
With AOF on, every write command is appended to a file in the same text format clients send. We turned AOF on, ran SET hello world, and found exactly that command at the end of the log file.
How much you can lose depends on appendfsync, the setting for how often Redis calls fsync. The default, everysec, calls it once a second on the bio_aof thread, so a crash can lose about one second of writes. The value always is safest and slowest, and no leaves flushing to the operating system.
The log keeps growing, so Redis rewrites it now and then in a forked child. Since Redis 7.0, AOF is split into parts in a folder: a base file, incremental files and a manifest that lists them. On our server the base file began with the bytes REDIS0015, because it is stored in RDB format by default.
| Question | RDB snapshot | AOF log |
|---|---|---|
| On by default? | Yes, with save rules 3600 1 300 100 60 10000 | No, appendonly is no |
| What is on disk? | One compact file, dump.rdb | A folder with a base file, incremental logs and a manifest |
| Data you can lose | Writes since the last snapshot, often minutes | About one second with appendfsync everysec |
| Uses fork? | Every BGSAVE | Only for rewrites |
| Restart speed | Faster for big datasets, per the docs | Slower: loads the base, then replays the log |
| When both are on | Ignored at restart | Used at restart, as the more complete copy |
The docs suggest using both if you want safety close to a traditional database. If losing a few minutes is fine, RDB alone is enough. If Redis only holds a cache you can rebuild, you can turn both off.
What do fork and copy-on-write cost during BGSAVE?
Fork itself is quick but not free, and memory can grow while the child runs. Both costs come from copy-on-write, a trick where parent and child share memory pages until one of them changes a page.
A page is the 4 KB block the operating system uses to manage memory. After fork, the kernel marks all pages as shared. When the parent writes to a key, the kernel first copies that whole page for the parent, so the child still sees the old version.
What happens during BGSAVE
Drag to exploreThe parent keeps serving clients. Notice that every page the parent changes during the save is copied, which is the memory that rdb_last_cow_size reports.
Diagram connections:
- BGSAVE runs to fork() (start)
- fork() to Parent process (returns in parent)
- fork() to Child: redis-rdb-bgsave (returns in child)
- Parent process to Copy-on-write (writes to keys)
- Child: redis-rdb-bgsave to dump.rdb (streams snapshot)
- BGSAVE runs: Command or a save rule fires
- fork(): Kernel copies page tables, main thread waits
- Parent process: Replies OK and keeps running commands
- Child: redis-rdb-bgsave: Sees memory frozen at fork time
- Copy-on-write: Each changed 4 KB page gets copied once
- dump.rdb: Written to a temp file, then renamed
We measured both costs on 2 million keys using 247 MB. The fork took 10.7 milliseconds, shown in INFO stats as latest_fork_usec 10730, and BGSAVE replied in 15 milliseconds. The save itself took about four seconds.
With no writes during the save, rdb_last_cow_size was 0.79 MB. When a second client kept overwriting keys during the save, it rose to 53.8 MB. Those extra megabytes are pages the parent had to copy.
Fork time grows with memory size, because the kernel must copy the page table, the map from memory addresses to pages. The latency guide's example is a 24 GB instance needing a 48 MB page table. The persistence page warns that fork can stop Redis serving clients for milliseconds, or even a second on a very big dataset with a slow CPU.
The latency guide also says to disable transparent huge pages, a Linux feature that uses much bigger pages. With them on, a few thousand writes after fork can copy almost the whole process memory. Either way, a write-heavy server needs spare RAM during BGSAVE and AOF rewrites.
How does Redis choose which keys to evict at maxmemory?
Redis checks memory before it runs a command, and if usage is over maxmemory it removes keys using the maxmemory-policy you chose. Out of the box, maxmemory is 0, meaning no limit, and the policy is noeviction.
With noeviction, writes fail once the limit is hit. Our test SET was refused with an error saying the command is not allowed when used memory is above maxmemory. GET kept working. That is the right choice for data you cannot lose, and the wrong one for a cache.
| Policy | Which keys it may remove | How it picks |
|---|---|---|
| noeviction | None | Refuses writes that need memory |
| allkeys-lru, volatile-lru | All keys, or only keys with a TTL | Longest time since last read or write, approximated |
| allkeys-lfu, volatile-lfu | All keys, or only keys with a TTL | Lowest estimated access frequency |
| allkeys-lrm, volatile-lrm | All keys, or only keys with a TTL | Longest time since last write; added in Redis 8.6 |
| allkeys-random, volatile-random | All keys, or only keys with a TTL | Any key at random |
| volatile-ttl | Only keys with a TTL | The key closest to expiring |
One trap: a volatile policy with no TTL keys behaves like noeviction. Our volatile-lru test with no expiring keys refused writes with the same error.
Approximated LRU: sample a few, keep a shortlist
LRU means least recently used. A perfect LRU would need a list of every key in access order, which costs memory and time. Redis instead samples a few random keys and evicts the one idle the longest.
The source makes it more precise. Each round samples maxmemory-samples keys, 5 by default and at most 64. Good candidates go into a pool of 16 keys, sorted by idle time, and the most idle key in the pool is evicted. The docs mention the pool but not its size; it is EVPOOL_SIZE in evict.c, undocumented as of 24 October 2026, so pin your version.
How allkeys-lru frees memory before a write
Drag to exploreNotice that Redis never sorts all keys. It looks at a handful of random keys and keeps a 16-entry shortlist across rounds.
Diagram connections:
- Write command arrives to Over maxmemory?
- Over maxmemory? to Sample 5 random keys (Yes)
- Over maxmemory? to Run the command (No)
- Sample 5 random keys to Pool of 16 candidates (better candidates enter)
- Pool of 16 candidates to Delete most idle key (take the best)
- Delete most idle key to Sample 5 random keys (repeat until enough freed)
- Delete most idle key to Run the command (enough memory freed)
- Write command arrives: For example SET order:991 ...
- Over maxmemory?: Checked before running the command
- Sample 5 random keys: maxmemory-samples, default 5
- Pool of 16 candidates: Sorted by idle time, kept between rounds
- Delete most idle key: Freed inline unless lazyfree-lazy-eviction
- Run the command: Memory is now under the limit
Does sampling pick well? We wrote 100,000 keys, waited two seconds, read 10,000 of them again, then set a limit and wrote 50,000 new keys. With samples of 3, 5 and 10, all 10,000 recently read keys survived. About 44 percent of the untouched old keys were evicted. The gap between hot and cold keys was large here, so this test cannot show the small accuracy gain the docs describe for 10 samples.
Idle time comes from a clock with one-second resolution, so OBJECT IDLETIME reports whole seconds. Keys touched within the same second look equally fresh to LRU.
LFU: a tiny counter that grows slowly
LFU means least frequently used. Each key has a 24-bit field that LRU uses for its clock. Under LFU, 8 bits hold a counter from 0 to 255, and 16 bits hold the last access time in minutes.
The counter grows by chance, and the chance shrinks as the counter grows. With the default lfu-log-factor of 10, the docs say it saturates at around a million hits. New keys start at 5, not 0, so they are not evicted before they get a chance. The counter drops by one for every lfu-decay-time minutes (default 1) that the key sits unused.
You can watch it with OBJECT FREQ under an LFU policy. A new key showed 5. After 10, 100, 1,000 and 10,000 reads, separate keys showed 7, 9, 19 and 48. The numbers vary run to run, because each increment is random.
For the delivery app, allkeys-lfu suits rate-limit counters and hot sessions that are read again and again. allkeys-lru suits data that is popular only for a while, like today's orders.
What do I/O threads change in Redis 8?
I/O threads take network work off the main thread, but commands still run one at a time on the main thread. The io-threads setting is 1 by default, which means no extra threads.
The Redis 8.0 release brought a new I/O threading design. Its announcement explains the split: the main thread assigns each client to an I/O thread, which reads and parses its commands. The main thread runs them and builds replies, and the I/O thread writes the replies back.
We started a server with io-threads 2. It gained exactly one extra thread, named io_thd_1, because the main thread counts as the first. INFO threads, a section added in 8.0, listed io_thread_0 and io_thread_1 with their client, read and write counts.
You cannot change it on a running server. CONFIG SET io-threads 4 failed with can't set immutable config, so it must go in the config file before startup. The 8.10.2 config file suggests it only on machines with 4 or more cores, leaving one core spare.
I/O threads help when the main thread spends its time on network reads and writes for many clients. They do nothing for a slow KEYS or a big DEL, because those run on the main thread either way.
How do you check what is blocking your Redis server?
Use the server's own counters before guessing. These steps take a few minutes and work on any recent Redis.
Read the slow log
Run SLOWLOG GET 10. Each entry shows the command and its run time in microseconds. Anything above 10,000, the default threshold, is a candidate.
Turn on the latency monitor
Run CONFIG SET latency-monitor-threshold 100, then LATENCY LATEST after a stall. Events such as command or fork show their latest and worst spike in milliseconds.
Check fork and copy-on-write
Run INFO stats and INFO persistence. Look at latest_fork_usec, in microseconds, and rdb_last_cow_size or aof_last_cow_size, in bytes.
Check eviction and lazy free
INFO stats shows evicted_keys. INFO memory shows lazyfree_pending_objects, the objects still waiting for the background thread.
Replace the offenders
Swap KEYS for SCAN, DEL of big values for UNLINK, FLUSHALL for FLUSHALL ASYNC, and SAVE for BGSAVE. Then repeat step 1 to confirm.
How do you explain Redis internals in a system design interview?
Lead with the single-threaded execution model, then show you know what it costs and how to protect it. This frame fits in about two minutes.
State the model
One main thread runs an event loop and executes commands one at a time on in-memory data. No locks are needed, and most commands take microseconds.
Name the risk
Any command that touches many items blocks everyone: KEYS, big SORT or SUNION, a DEL of a huge value, long Lua scripts, SAVE.
Give the fixes
SCAN instead of KEYS, UNLINK and the lazyfree settings for big deletes, BGSAVE instead of SAVE, and SLOWLOG plus the latency monitor to catch offenders.
Choose durability
RDB snapshots for fast restarts and backups, AOF with everysec to lose about one second at most, or both. Mention fork and copy-on-write memory.
Size memory and eviction
Set maxmemory with headroom for fork. Use allkeys-lru or allkeys-lfu for caches, noeviction for data you cannot lose, and explain sampling.
Scale out last
I/O threads help network-heavy loads on multi-core machines. Beyond one main thread's CPU, shard keys across several Redis instances.
Questions people ask
- Is Redis single-threaded?
- Command execution is single-threaded: one main thread runs every command, one at a time. Redis also has background threads for freeing memory, AOF fsync and closing files. Optional I/O threads, redesigned in Redis 8.0, can read, parse and write network data. None of these helpers run your commands.
- Why is the KEYS command bad in production?
- KEYS checks every key in the database while the main thread waits, so all other clients wait too. On our test server it took over 300 milliseconds for 2 million keys. Use SCAN, which returns results in small steps with a cursor, or keep the keys you need in a set.
- What is the difference between DEL and UNLINK in Redis?
- Both remove keys. DEL frees the value's memory on the main thread, which can take seconds for a value with millions of members. UNLINK removes the key at once and frees memory on a background thread. In the 8.10.2 source, values made of 64 or fewer allocations are still freed inline, because that is faster.
- Should I use RDB or AOF for Redis persistence?
- Use both if losing more than about a second of writes would hurt. RDB gives compact snapshots for backups and fast restarts. AOF with appendfsync everysec limits loss to about one second. For a cache you can rebuild from the database, RDB alone or no persistence is reasonable.
- What is the default Redis eviction policy?
- The default is noeviction, and the default maxmemory is 0, meaning no limit, as checked on Redis 8.10.2 on 24 October 2026. Once you set a limit, noeviction rejects writes that need memory. For a cache, set allkeys-lru or allkeys-lfu instead.
- Do I/O threads make Redis commands run in parallel?
- No. I/O threads only read and parse requests and write replies for their clients. The main thread still executes every command one at a time. They help when network handling is the bottleneck on a machine with spare cores. They do not speed up a slow KEYS or a big DEL.
- Is Redis still open source?
- It depends on the version. Per the Redis licences page as of 24 October 2026, Redis 7.2 and earlier are BSD 3-clause, and 7.4 is RSALv2 or SSPLv1. From Redis 8.0 you may also choose AGPLv3, an OSI-approved open source licence. Valkey, a Linux Foundation project started in March 2024, continues the BSD code from Redis 7.2.4.
Sources and further reading
- Redis docs: Diagnosing latency issues (single thread, slow commands, fork, huge pages)
- Redis docs: Persistence (RDB, AOF, fsync policies, multi-part AOF)
- Redis docs: Key eviction (policies, approximated LRU, LFU, LRM)
- Redis docs: KEYS command
- Redis docs: SCAN command (guarantees and COUNT)
- Redis docs: UNLINK command
- Redis docs: INFO command (persistence, stats, threads fields)
- Redis blog: Redis 8.0-M03, the new I/O threading design
- Redis Open Source 8.0 release notes (I/O threading, licence change)
- Redis Open Source 8.10 release notes
- Redis source, 8.10.2 tag: src/evict.c (eviction pool and LFU)
- Redis source, 8.10.2 tag: src/lazyfree.c (LAZYFREE_THRESHOLD)
- Redis licences page (licence by version)
- Open Source Initiative: GNU AGPL v3 (approved licence)
- Linux Foundation: Valkey community launch, 28 March 2024