Redis and the Shape of a Cache

Two web servers, one counter. Both handle a request for the same article in the same millisecond, and both want to bump its view count by one.

Server A reads views, gets 100. Server B reads views, also gets 100. A adds one and writes back 101. B adds one and writes back 101. Two requests came in, the number moved by one, and you just lost a view. You will never find out which one, because nothing crashed and no log line looks wrong.

That is the read-modify-write race, and it is not exotic. Any time two processes read a shared value, change it in their own memory, and write it back, they can quietly clobber each other. Now watch it disappear:

127.0.0.1:6379> INCR views
(integer) 101
127.0.0.1:6379> INCR views
(integer) 102

One command. The number lives in Redis, the addition happens inside Redis, and it comes back 101 then 102, never a lost update, no matter how many servers are shouting at it at once.

That is the thing to understand about Redis before anything else. It is not a bucket you throw strings into and hope. It is a server built around data structures, where the interesting operations run on the server, one at a time. Once that clicks, you stop seeing “a cache” and start seeing a box that does counters, queues, sets, leaderboards, locks, and rate limiters, each correctly, without you writing the tricky concurrency yourself.

Not a cache. A data-structure server.

People meet Redis as “the fast key-value cache” and stop there. The keys are the boring part. The values are the point: each one is a real data structure with its own set of commands, and picking the right structure is most of the skill.

String
a blob of bytes, or a number
Counters, cached JSON or HTML, feature flags, a session token.
Hash
fields and values under one key
One object: a user record with name, email, and plan as fields.
List
ordered, push and pop at both ends
A simple job queue, or the last 100 events as a capped log.
Set
unique members, no order
Who liked this post, which tags exist, has this IP been seen.
Sorted set
members ranked by a score
A leaderboard, a priority queue, a sliding time window.
Stream
append-only log with consumer groups
An event log many workers read from without stepping on each other.
The core Redis types, each with one job it is genuinely good at. Pick the structure that matches the operation you need to be fast and atomic.

Let’s walk the ones you’ll reach for most.

Strings and counters

A string holds up to 512 MB of anything: text, a serialized JSON object, a rendered HTML fragment, raw bytes. SET and GET are the whole story until you remember the value can be a number.

SET page:home:html "<article>...</article>"
INCRBY downloads 5          # atomic add, returns the new total
DECR seats                  # atomic subtract
SET rate 42 EX 60           # set a value and let it live for 60 seconds

INCR, INCRBY, and DECR turn a plain string into a race-free counter. That is the counter from the intro. No lock, no read-then-write, no lost updates.

Hashes for objects

When your value is an object with several fields, a hash beats stuffing JSON into a string, because you can read and write one field without deserializing the whole thing.

HSET user:42 name "Ada" email "[email protected]" plan "pro"
HGET user:42 plan           # "pro"
HINCRBY user:42 logins 1    # bump one numeric field, atomically

Reach for a hash when different parts of your code touch different fields, or when one field is a counter you want to bump on its own.

Lists as queues

A list is a doubly linked sequence. Push and pop at either end are O(1), which makes it a natural queue: producers LPUSH onto the left, workers RPOP off the right.

LPUSH jobs "send-welcome:42"
RPOP jobs                   # a worker takes the next job
LLEN jobs                   # how deep is the backlog

The blocking variants are the useful trick. BRPOP jobs 5 waits up to five seconds for an item instead of returning nothing, so a worker can sleep until there is work rather than polling in a hot loop. It is not a full message broker, but for a lot of internal job queues it is all you need.

Sets for uniqueness

A set stores unique members with no duplicates and no order. The membership check SISMEMBER is O(1), which is exactly what you want for “have I seen this before”.

SADD post:99:likes user:42   # returns 1 if newly added, 0 if already there
SISMEMBER post:99:likes 42    # 1 or 0, in constant time
SCARD post:99:likes           # how many likes

The return value of SADD doubles as a dedupe signal: 1 means this is new, 0 means you have seen it. Sets also do intersection and union on the server (SINTER, SUNION), which is how “users who follow both A and B” gets computed without dragging two lists back to your app.

Sorted sets, the quiet workhorse

A sorted set is a set where every member carries a floating-point score, and members are kept ordered by that score. This one structure powers a surprising range of things because “ordered by a number” describes so many problems.

ZADD board 1500 "ada" 1499 "linus" 1502 "grace"
ZREVRANGE board 0 2 WITHSCORES   # top 3, highest score first
ZRANK board "ada"                # where does ada sit in the ranking

Score is a game rating and you have a leaderboard. Score is a timestamp and you have a time-ordered log you can slice by range, which is the trick behind a proper rate limiter (more on that below). Score is a priority and you have a priority queue. Same structure, three products.

Type a few commands against a tiny in-memory Redis and watch the state and the TTL countdown react. This runs entirely in your browser, no server involved:

interactiveA tiny Redis-ish command line

One thread, and why that is a feature

Redis executes commands on a single thread. One command runs start to finish before the next one begins. No two commands ever interleave.

That sounds like a bottleneck and turns out to be the opposite of a problem. Because commands cannot overlap, every single command is atomic for free. INCR reads, adds, and writes with nothing able to sneak in between. SADD either adds the member or it does not. There is no half-applied state and no lock to manage, which is why the counter from the intro just works.

If you have read the JavaScript event loop, this will feel familiar. Redis runs its own event loop: one thread using epoll or kqueue to watch thousands of sockets, grabbing whichever client has data ready and running its command to completion. Same shape as the concurrency model you already know from Node.

client AINCR viewsclient BINCR viewsserialized queueINCR (A)INCR (B)one command at a timeviews431. A runs: 41 to 422. B runs: 42 to 43both counted, final 43
Two clients fire INCR at the same instant. Redis lines them up and runs them one at a time, so both increments land and the counter is exactly right.

That guarantee only covers a single command. The moment your logic needs several commands to happen together, you are back in race territory, and Redis gives you three tools to climb out.

Pipelines are about speed, not atomicity. Normally each command is a round trip: send, wait, receive. Pipelining fires many commands back to back without waiting for each reply, so a hundred writes cost one round trip instead of a hundred. The commands still interleave with other clients’ commands; you have just stopped paying network latency per command.

Transactions with MULTI / EXEC queue a batch and run it as one uninterrupted unit. Nothing from another client slips in between. Note the sharp edge: this is not a SQL transaction. There is no rollback. If the third command in the batch is nonsense, the first two still ran. It gives you isolation, not the all-or-nothing atomicity you get from database transactions.

Lua scripts (and the newer Functions) are the real answer for “read something, decide, then write”. Redis runs your whole script atomically on the server, so you can branch on a value and act on that decision with zero chance of another client changing it underneath you.

-- decrement stock only if there is stock left; all of this is atomic
local stock = tonumber(redis.call("GET", KEYS[1]))
if stock and stock > 0 then
  return redis.call("DECR", KEYS[1])
else
  return -1
end

When memory runs out

Redis keeps everything in RAM. RAM is finite. So the first operational question is: what happens when you hit the ceiling?

You control that with two settings: maxmemory (the ceiling) and maxmemory-policy (what to do when you touch it). The default policy in open-source Redis is noeviction, which means once you are full, writes start failing with an out-of-memory error while reads keep working. For a cache, that default is usually wrong. You want it to make room.

memory full at maxmemory, policy: allkeys-lruused memory = maxmemorykey:homeidle 2skey:cart:88idle 6skey:sess:5idle 40skey:feedidle 140sevicted (idlest)incoming: SET key:new … needs roomLRU is approximate: it samples ~5 keys and evicts the idlest of the sample
At the memory ceiling under allkeys-lru, Redis samples a handful of keys and evicts the one idle the longest, freeing room for the incoming write.

The policies split on two axes. First, which keys are fair game: allkeys-* can evict anything, while volatile-* only touches keys that have a TTL set. Second, how to pick a victim: -lru drops the least recently used, -lfu drops the least frequently used, -random picks at random, and volatile-ttl drops whatever expires soonest. The common good default for a pure cache is allkeys-lru. If a chunk of your keys must never be evicted, give only the disposable ones a TTL and use volatile-lru.

One honest detail: the LRU here is approximate. True LRU would track exact access order for every key, which costs memory Redis would rather spend on your data. Instead it samples a handful of keys (five by default) and evicts the idlest of the sample. In practice it lands very close to true LRU, and you can raise the sample count if you need it tighter.

The line that actually matters

Eviction forces a question you must answer honestly: is this data allowed to vanish?

A cache is data you can lose. Every entry is a copy of something you can recompute or refetch from the real database. If Redis evicts it, the next request is a little slower and then repopulates it. No harm done.

A system of record is data you cannot lose. If it disappears, it is gone, and someone’s order or payment or account is gone with it.

Redis can persist to disk, and it is worth knowing how, but persistence does not turn a cache into a database. There are two mechanisms. RDB takes point-in-time snapshots: a forked child writes a compact dump file on a schedule. Restarts are fast and the files are small, but a crash loses everything since the last snapshot. AOF (append-only file) logs every write command and replays the log on restart. With the default appendfsync everysec you lose at most about one second of writes. You can run both.

Even with AOF on the strictest setting, treat Redis as your fast layer, not your ledger. Replication to replicas is asynchronous, so a failover can drop writes the primary already acknowledged. The whole design optimizes for speed and lets you trade a sliver of durability for it. That is the right trade for a cache and the wrong one for the record of what your users actually did.

Locks are harder than they look

A tempting use: I have several servers and I want only one of them to run a job at a time. Redis can do a lock, and the basic version is genuinely useful, but this is a topic where confident code is often quietly broken.

The building block is SET with NX (set only if the key does not exist) and an expiry so a crashed holder cannot deadlock everyone forever.

# acquire: set only if absent, auto-release after 30s, value is a random token
SET lock:order:42 "b3f9a1c8-..." NX PX 30000

If that returns OK, you hold the lock. If it returns nil, someone else does. The random token matters for release. You must not just DEL the key, because your 30-second window might have expired and someone else might now hold the lock, and a blind delete would free their lock. So you check the token first, atomically, with a small script:

-- release only if the token still matches: it is our lock, not someone else's
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
else
  return 0
end

Rate limiting with a sorted set

Here is the sorted set earning its keep. A sliding-window rate limiter needs to answer “how many requests has this user made in the last 60 seconds”, and a sorted set scored by timestamp answers it exactly.

Store one sorted set per user. Each request adds a member scored by the current time in milliseconds. Before counting, you drop everything older than the window. Then the size of the set is your request count in that window.

limit: 5 requests per 60s, key = rl:user:7 (sorted set)window: last 60 secondsnow - 60snowolder, trimmeddroppednewZREMRANGEBYSCORE rl:user:7 0 (now-60s) drop old entriesZCARD rl:user:7 = 4 requests still in the window4 < 5, so allow, then ZADD rl:user:7 now now
One sorted set per user, scored by timestamp. Trim everything left of the window, count what remains, and if it is under the limit, add this request.

Those steps must run together, or two concurrent requests could both count 4 and both be allowed past a limit of 5. Wrap them in MULTI/EXEC or a Lua script so the count and the add cannot be split. In a Node service using the node-redis client:

const now = Date.now();
const windowMs = 60_000;
const key = `rl:${userId}`;

const results = await redis.multi()
  .zRemRangeByScore(key, 0, now - windowMs)      // trim entries older than the window
  .zCard(key)                                    // count what remains (before adding)
  .zAdd(key, { score: now, value: String(now) }) // record this request
  .expire(key, 60)                               // let the key clean itself up
  .exec();

const countInWindow = results[1];
if (countInWindow >= 5) {
  // over the limit: reject with 429
}

This is a genuinely good limiter, precise to the millisecond with no fixed-window edge effects. The full menu of algorithms, what to key on, and how to respond lives in rate limiting and abuse control. The point here is narrower: the right data structure made a hard problem into four lines.

Pub/sub and streams, briefly

Two more features worth knowing exist, so you reach for them at the right moment.

Pub/sub is fire-and-forget messaging. A client SUBSCRIBEs to a channel, another PUBLISHes to it, and every current subscriber gets the message. There is no memory: if nobody is listening when you publish, the message is gone, and a subscriber that reconnects a second later missed it. That makes pub/sub perfect for ephemeral fan-out, like pushing a live update to every server holding a WebSocket connection, and wrong for anything you must not drop.

Streams are the durable counterpart. A stream is an append-only log (XADD to append, XREAD to read) that keeps its entries around. The real power is consumer groups: several workers read from one stream, each entry is handed to exactly one worker in the group, and unacknowledged entries can be redelivered if a worker dies mid-task. That is a proper work queue with at-least-once delivery, closer to Kafka in spirit than to pub/sub. If a dropped message means a dropped order, you want a stream, not pub/sub.

Pitfalls that bite in production

The failure modes are consistent across teams. Here are the ones that actually page people.

KEYS in production. KEYS user:* looks innocent and is a loaded gun. It scans the entire keyspace in one shot, on the single thread, blocking every other client until it finishes. On a database with millions of keys that is a multi-second freeze for everyone. Use SCAN, which walks the keyspace incrementally with a cursor and never blocks.

The giant key. A single key holding a 5 GB value, or a hash with ten million fields, poisons everything. Every operation on it is slow, it makes memory usage lumpy and hard to evict cleanly, and even deleting it blocks the thread while Redis frees all that memory (use UNLINK instead of DEL to free it in the background). Keep values bounded. Shard a huge collection across many keys instead of one monster.

Unbounded memory with no TTL. Write keys forever with no expiry and no maxmemory policy, and you have built a slow-motion outage. Memory climbs until the host runs out, and with the default noeviction policy your writes start failing at the worst possible time. Set a maxmemory, set a policy, and put a TTL on anything cacheable.

Connection churn. Opening a TCP connection to Redis, authenticating, and tearing it down for every operation is far more expensive than the command itself, and a burst of it can exhaust connections on both sides. Use a client that keeps a connection pool of long-lived connections. This bites hardest in serverless, where each invocation is tempted to open its own connection and a traffic spike becomes a connection storm; front it with a pool or a proxy built for that shape.

Redis or Valkey?

If you went looking for Redis recently you met a fork in the road, so here is the plain version without the drama.

For most of its life Redis shipped under the permissive BSD license. In March 2024 the company relicensed it to a pair of source-available licenses (RSALv2 and SSPLv1) that are not OSI open source, aimed at stopping large cloud providers from reselling it as a managed service. In response the community, under the Linux Foundation, forked the last BSD version (Redis 7.2.4) into Valkey, backed by AWS, Google Cloud, Oracle, and others.

Then it partly unwound. Redis’s original creator rejoined the company in late 2024, and Redis 8 (GA May 2025) added the OSI-approved AGPLv3 as a third license option, so current Redis is open source again, just under a copyleft license rather than the old permissive one. Valkey kept going on BSD, reached version 9 in October 2025, and became the default redis package in several Linux distributions.

Summary

  • Redis is an in-memory data-structure server, not just a key-value cache. The skill is picking the structure whose operations match your problem.
  • The core types: strings (and atomic counters), hashes (objects), lists (queues), sets (uniqueness), sorted sets (ranking, priority, time windows), and streams (durable logs). Any key can carry a TTL.
  • Commands run on a single thread, one at a time, which makes each command atomic for free. INCR is a race-free counter with no lock.
  • For multi-step work: pipelines cut round trips, MULTI/EXEC isolates a batch, and Lua scripts run read-decide-write atomically. Only Lua handles a write that depends on a read.
  • Under memory pressure, eviction policies (allkeys-lru is the usual default) drop keys. Decide honestly whether data may be lost: a cache may, a source of truth may not. RDB and AOF add durability but do not make Redis your ledger.
  • Distributed locks work for best-effort exclusion (SET NX PX plus a token and a Lua release) but do not give real correctness under process pauses; use fencing tokens, consensus, or idempotency when a double-write truly matters.
  • A sorted set scored by timestamp is a precise sliding-window rate limiter. Pub/sub is lossy fan-out; streams are durable work queues.
  • Avoid the classics: KEYS in production (use SCAN), giant keys, no TTL plus no eviction policy, and per-operation connection churn.
  • Redis (open source again under AGPLv3 since v8) and Valkey (the BSD fork, now at v9) speak the same protocol. Your code runs on either.