How Redis Works: In-Memory and Single-Threaded

Redis stands for REmote DIctionary Server, and its defining trait is right there in the name: it is a server-side, in-memory data structure store. Instead of writing data to disk on every operation like a traditional database, Redis keeps the entire dataset in RAM, which is why reads and writes complete in microseconds instead of milliseconds. The second defining trait is that the command that actually touches your data runs on a single thread, one command at a time, with no other command interleaving mid-execution. These two design decisions together explain almost everything distinctive about how Redis behaves — its speed, its atomicity guarantees, and its memory limits.

Overview: What “In-Memory” and “Single-Threaded” Mean

In-memory means every key and value Redis knows about lives in the server’s RAM, in data structures very similar to what you’d build in-process in any programming language: hash tables, linked lists, skip lists, and so on. A GET is a hash table lookup in memory, not a disk seek, which is why Redis can serve hundreds of thousands of operations per second on modest hardware. The tradeoff is that your dataset is bounded by available RAM, and by default, if the server process dies without persistence configured, everything in memory is lost. Redis addresses this with optional persistence: RDB snapshotting writes a compact point-in-time copy of the dataset to disk at intervals (fast to restore, but can lose the last few seconds of writes since the last snapshot), and AOF (append-only file) logs every write command as it happens (more durable, but a larger file that’s slower to replay on restart). Many production deployments enable both, and neither should be considered strictly superior — they trade durability against restore speed and disk overhead differently.

Single-threaded means the actual execution of your commands — reading a key, mutating a list, incrementing a counter — happens one at a time on one core, in the order Redis receives them, via an event loop. There’s no concept of two INCR calls on the same key racing each other inside Redis, because the second one simply cannot start executing until the first one has fully finished. This is the foundation of why individual Redis commands are atomic without you needing to add locks: the single thread is the lock. It’s important to be precise here, though — since Redis 6, network I/O (reading bytes off the socket and parsing the protocol, writing the reply back) can be handled by a small pool of additional I/O threads for extra throughput, but the actual command execution against the keyspace remains strictly single-threaded. The single-threaded model is also why Redis avoids the complexity and overhead of multi-threaded locking that databases like PostgreSQL or MySQL need internally, but it also means a single slow command — one that takes milliseconds to run because it touches millions of elements — blocks every other client on the server for that entire duration. There is no other core to pick up the slack.

Because RAM is finite, Redis also needs a policy for what happens when memory fills up. You can configure a maxmemory limit and a maxmemory-policy (such as noeviction, which rejects writes once the limit is hit, or allkeys-lru, which evicts the least-recently-used keys to make room). This eviction behavior only makes sense once you internalize that Redis data lives in RAM, not on disk with effectively unlimited capacity.

Syntax: Commands for Inspecting Redis Internals

There’s no single “how it works” command, but a handful of commands let you observe the in-memory, single-threaded behavior directly:

Command Purpose Time Complexity
TYPE keyname Returns the data type stored at a key (string, list, hash, set, zset, etc.) O(1)
OBJECT ENCODING keyname Returns the internal in-memory representation Redis chose for that value O(1)
DBSIZE Returns the number of keys in the currently selected database O(1)
MEMORY USAGE keyname Returns the number of bytes a key and its value occupy in RAM O(1) for simple values, O(N) for large aggregate types

These are read-only introspection commands — they don’t change data, they let you peek at how Redis is representing and accounting for what you’ve stored.

Examples

Example 1: Atomic increments without a race condition

Because command execution is single-threaded, INCR is safe to call from many concurrent clients at once — Redis will never apply two increments out of order or lose one, because each INCR runs to completion before the next command (from any client) begins.

SET pageviews:home 0
INCR pageviews:home
INCR pageviews:home
INCR pageviews:home
GET pageviews:home

Output:

OK
(integer) 1
(integer) 2
(integer) 3
"3"

Each INCR reads the current integer, adds one, and writes it back — all as one indivisible step on the single thread. No matter how many clients send INCR pageviews:home at the same instant, Redis processes them strictly one after another, so the final count is always correct. This is exactly the guarantee a naive “read the value in my app, add one, write it back” approach cannot offer.

Example 2: Seeing the in-memory encoding Redis chooses

Redis doesn’t store every string the same way in RAM — it picks the most compact internal encoding it can based on the value’s size and shape. You can see this with OBJECT ENCODING.

SET user:1001:age 29
OBJECT ENCODING user:1001:age
SET user:1001:name "Grace Hopper"
OBJECT ENCODING user:1001:name
SET user:1001:bio "A very long biography string that exceeds forty four characters in length easily"
OBJECT ENCODING user:1001:bio

Output:

OK
"int"
OK
"embstr"
OK
"raw"

The value 29 fits in a native integer, so Redis stores it as int, skipping string overhead entirely. "Grace Hopper" is a short string (44 bytes or fewer), so it gets the compact embstr (embedded string) encoding, allocated in a single memory block alongside its object header. The biography string is longer than 44 bytes, so Redis switches to the more flexible but slightly heavier raw encoding, which allocates the string separately and can be modified in place (for example via APPEND) without reallocating the whole object. These choices are invisible to your application code — GET returns the same thing either way — but they’re a direct, observable consequence of Redis being an in-memory store that cares about RAM efficiency down to the byte.

Example 3: Type safety is enforced on every single-threaded operation

Every key has exactly one data type at a time, and Redis checks that type before executing a command against it. This check happens as part of the same atomic, single-threaded command execution — there’s no window where a type mismatch could slip through.

SET session:abc123 "active"
LPUSH session:abc123 "extra"

Output:

OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value

session:abc123 was created as a string with SET. Calling LPUSH, a list command, against it fails immediately with a WRONGTYPE error rather than silently corrupting the value or converting it. This is deliberate: Redis’s data structures are strict about what commands apply to them, and the single-threaded model means this check-then-act is never racy.

How It Works Step by Step: The Life of a Command

When a client sends a command like SET user:1001:age 30, here’s what happens inside Redis:

  1. The command arrives over TCP and is read off the socket. In Redis 6+, this byte-level socket read can be handled by one of a small pool of I/O threads if io-threads is configured, purely to parallelize network handling under very high connection counts.
  2. The raw bytes are parsed according to the RESP (REdis Serialization Protocol) wire format into a command name and its arguments.
  3. The parsed command is handed to the single main thread’s event loop, which executes it against the in-memory keyspace — looking up or creating the hash table entry for user:1001:age and writing the new value. This step is where atomicity comes from: nothing else runs on that thread until this step finishes.
  4. If persistence is enabled, the write is appended to the AOF buffer (to be flushed to disk according to the configured fsync policy) and/or marked as dirty for the next RDB snapshot.
  5. If the key had replicas or the command matches active pub/sub subscriptions, the write is propagated accordingly.
  6. A reply (OK for a successful SET) is queued to be written back to the client, again optionally via an I/O thread.

The RDB and AOF-rewrite processes deserve a special mention: they use the operating system’s fork() to spin off a child process that has its own copy-on-write view of memory, so a full snapshot can be written to disk without blocking the main thread for the whole duration. The fork() call itself causes a brief pause proportional to the size of the process’s memory page tables, but the actual snapshot writing happens off the critical path.

Common Mistakes

Mistake 1: Assuming more CPU cores will make Redis commands faster. Because command execution is single-threaded, adding cores to a single Redis instance doesn’t speed up any individual command — it only helps I/O-thread throughput and lets you run more independent Redis instances (or use Redis Cluster) on the same box. If you need more raw command throughput than one core can give you, the fix is horizontal: shard across multiple Redis instances or use Redis Cluster, not vertical scaling of one instance.

Mistake 2: Running expensive O(N) commands like KEYS * in production. KEYS scans the entire keyspace in one shot and, because the single thread can’t do anything else while it runs, it blocks every other client’s commands for the whole scan — on a dataset with millions of keys, that can mean seconds of total unresponsiveness.

KEYS user:*

Use SCAN instead. It’s cursor-based: each call does a small, bounded amount of work and returns a cursor to resume from, so it never blocks the server for more than a fraction of a millisecond at a time.

SCAN 0 MATCH user:* COUNT 100

Mistake 3: Doing a GET-then-SET in application code and assuming it’s atomic. If your app reads a counter with GET, adds one in your own code, and writes it back with SET, two concurrent clients can both read the same starting value and both write back the same incremented result — one increment is silently lost, even though each individual Redis command was atomic. The two commands together are not atomic, because another client’s command can run in between them.

SET orders:count 10

Instead of reading, incrementing in your app, then setting, use Redis’s own atomic increment so the whole read-modify-write happens as a single command on the single thread:

INCR orders:count

Mistake 4: Expecting EXPIRE on a key that doesn’t exist to do something. EXPIRE returns 0 and is silently a no-op if the key isn’t there — it does not create the key or raise an error.

EXPIRE session:doesnotexist 60

Output:

(integer) 0

Always check the integer reply (1 means the TTL was set, 0 means the key didn’t exist) if your logic depends on the expiration actually being applied.

Best Practices

  • Prefer atomic single commands (INCR, INCRBY, GETSET, SETNX) over a GET-then-compute-then-SET pattern in your application code.
  • Never run KEYS * against a production dataset of meaningful size — use SCAN, which is non-blocking and cursor-based.
  • Watch the size of any single value you write — a list, hash, or set with millions of elements makes even normally-cheap commands like LLEN feel instant but makes commands like LRANGE key 0 -1 or SMEMBERS expensive, and expensive means it blocks the single thread for everyone.
  • Set a maxmemory limit and an explicit maxmemory-policy appropriate to your use case (for example allkeys-lru for a pure cache) rather than letting Redis run until the OS starts refusing allocations.
  • Choose RDB, AOF, or both based on your durability needs — a pure cache that can be rebuilt from a source of truth may need neither, while a primary system of record should almost always enable AOF.
  • Use OBJECT ENCODING and MEMORY USAGE when investigating unexpected memory growth — they show you exactly how Redis is representing your data internally.
  • Remember Redis is not a relational database substitute: it has no ad-hoc query language for joins or arbitrary filtering, so design your key layout and data types around your actual access patterns up front.

Practice Exercises

Exercise 1: Create a key inventory:sku42 holding the integer 100. Using only atomic commands (no application-side read-modify-write), simulate three concurrent sales by decrementing it by 1 each time, then confirm the final value is 97.

Exercise 2: Set a string key with a short value and inspect its encoding with OBJECT ENCODING. Then APPEND enough characters to push it past 44 bytes and check the encoding again — predict what it will change to before you run the command.

Exercise 3: Create a hash key with HSET, then try running a string command like GET against that same key. Observe the error, and explain in your own words why the single-threaded model guarantees you’ll never see a partially-applied result instead of a clean error.

Summary

  • Redis stores its entire dataset in RAM, which is why reads and writes are extremely fast compared to disk-backed databases.
  • Command execution against the keyspace is strictly single-threaded, which is why individual commands are atomic with no extra locking needed.
  • Since Redis 6, I/O threads can parallelize socket reads/writes, but never the actual command execution itself.
  • A single slow, large O(N) command blocks every other client, because there’s no second thread to pick up the slack — this is why KEYS is dangerous and SCAN is the safe alternative.
  • RDB snapshots and AOF logs are the two persistence mechanisms, trading restore speed and durability against each other; many setups use both.
  • Because RAM is finite, configure maxmemory and a maxmemory-policy deliberately rather than letting Redis run out of memory unmanaged.
  • A read-modify-write done as two separate commands (like GET then SET) is not atomic even though each command individually is — use single atomic commands like INCR instead.