skip to content

Caching with Redis

You will learn the Redis-specific mechanics of running a production cache: cache-aside built from Redis primitives, stampede defense, hot-key survival, and RESP3 client-side caching. Interviewers ask because 'we cache it in Redis' is where most system-design answers start — and where most real incidents happen.

part ofRedisoverview, primer and where to startread it →
on this pageshow

explore

questions

26

Walk through the read path of a cache-aside (lazy-loading) cache built on Redis: which commands run on a hit, which run on a miss, and what exactly gets written back?

level: juniorimportance: must knowfreq 75%

answer

  1. GET → nil → load → SET EX
  2. one atomic SET, never SET+EXPIRE
  3. miss is normal, not an error
  4. Redis error = treat as miss
  5. cache negatives with short TTL

basics

~20 s

Read with GET. Hit: return the cached value. Miss (nil): load from the system of record, serialize it, write it back with a single SET key value EX <ttl>, and return it. Readers fill the cache lazily; a miss is normal, not an error.

solid answer

~50 s

Cache-aside means the application, not Redis, owns the loading logic. 1. `GET cachekey`. A non-nil reply is a hit — deserialize and return. 2. A `nil` reply is a miss. Query the database, serialize the object, then `SET cachekey <payload> EX <ttl>` and return the value. Use the single-command form `SET ... EX` rather than `SET` followed by `EXPIRE`: two commands can be split by a crash or a failover, leaving an immortal key that never refreshes. Every cache entry should be written with a TTL so the cache is self-healing when invalidation is missed. Things I always add: treat Redis errors and timeouts as a miss and fall through to the database (the cache is an optimization, never the source of truth); cache negative results explicitly with a short TTL or a sentinel so "row does not exist" doesn't hammer the database; and keep serialization and key format decisions consistent across every service that reads the key.

code

text · 13 lines
text
# 1. try the cache
GET product:v1:915
(nil)

# 2. miss -> load from the database, serialize, write back atomically
SET product:v1:915 "{\"id\":915,\"name\":\"Kettle\",\"price\":2999}" EX 300
OK

# 3. next reader hits
GET product:v1:915
"{\"id\":915,\"name\":\"Kettle\",\"price\":2999}"
TTL product:v1:915
(integer) 287

go deeper

for a junior

Be able to state the three steps and the exact commands: GET, on nil load from the database, SET with EX. Know that a miss is normal.

for a middle

Add why SET EX is atomic, why every entry gets a TTL, and how negative caching and client timeouts protect the database.

for a senior

Frame it as an availability contract: cache errors degrade to misses, the database must survive the miss rate, and the cached unit should match the unit of change and consumption.

for a principal

Discuss the pattern's eventual-consistency contract, cold-start and cache-loss blast radius, and where cache-aside stops being the right shape versus materialized or write-through paths.

## What cache-aside is Cache-aside (also called lazy loading) is the pattern where the **application code** orchestrates the cache. Redis is a dumb key-value store here: it does not know about your database and will never load anything by itself. The application asks Redis first, and only on a miss does it go to the system of record and then put the result back. The contrasting patterns — read-through and write-through — put the loading logic *inside* the cache layer, which requires a cache that can call your data source. Plain Redis cannot; a client library or a framework abstraction (for example a Spring `@Cacheable`-style layer) can make it *look* read-through, but underneath it is still emitting the same GET/SET pair. ## The concrete command sequence ``` GET product:v1:915 -> (nil) # miss <load row from Postgres, serialize to JSON> SET product:v1:915 "{...}" EX 300 # write back with TTL GET product:v1:915 -> "{...}" # subsequent hit ``` That is the whole mechanism. The interesting parts are the details around it. ## Why `SET ... EX` and not `SET` then `EXPIRE` `SET key value EX 300` is one command, so it is atomic: the key never exists without its expiry. If you write `SET key value` followed by `EXPIRE key 300`, the process can crash, the connection can drop, or a failover can happen between the two, and you are left with a key that never expires. Over months that produces a slowly growing population of immortal, stale entries that only `maxmemory` eviction will ever remove. `SET` has accepted `EX`/`PX` since Redis 2.6.12; there is no reason to use the two-command form. Related options worth knowing: `SET ... KEEPTTL` (6.0) overwrites the value while preserving the remaining TTL, and `GETEX key EX n` (6.2) reads and re-arms the expiry in one round trip if you want a sliding window. ## A miss is a normal outcome Juniors often treat `nil` as an error condition. It is not. On a cold start every read is a miss; after eviction or invalidation, misses reappear. What matters is that the miss path is correct and bounded: it must produce a value, write it back, and return it. Two consequences follow. First, **the database must be able to survive your miss rate**, because the miss path is just normal database load. Second, **an unbounded stream of misses for keys that will never exist** — the classic "look up a product ID that does not exist" probe — bypasses the cache entirely. The standard fix is to cache the negative result too: store a sentinel value (an empty marker) with a short TTL, so repeated lookups for a nonexistent row are answered from Redis. Be explicit about the sentinel so you can distinguish "cached: does not exist" from "not cached". ## Redis failure must not become application failure Because the cache is an optimization and the database is the truth, a Redis timeout, connection error, or `MOVED`-storm during a resharding event should be caught and treated as a miss. Set aggressive client timeouts (single-digit to low-double-digit milliseconds is typical for a cache) so that a struggling Redis does not become the latency of your request path. The inverse failure — the application refusing to serve because the cache is down — is a self-inflicted outage and is the single most common design mistake in this pattern. It is worth stress-testing the reverse case too: if Redis is emptied, every request becomes a database request at once. The read path above is what you exercise in that drill. ## What the value contains The cached payload should be whatever the caller actually needs, serialized once. Caching a half-assembled object that requires three more database queries to be usable defeats the point. Conversely, caching a giant aggregate that changes on any of twenty different writes makes invalidation nearly impossible. The unit of caching should match the unit of change and the unit of consumption. ## Freshness expectations Cache-aside is eventually consistent by construction. Between the moment a row changes and the moment the corresponding key is invalidated or expires, readers see stale data. The TTL is the backstop that bounds that staleness even when explicit invalidation is missed — a network blip, a crashed worker, a code path that forgot to invalidate. Design the read path assuming invalidation will sometimes be lost, because it will.

  • What should the application do when the Redis call itself times out?
    Treat it exactly like a miss: log it, increment an error metric, and serve from the database. The cache is an optimization and the database is the source of truth, so a cache failure must degrade latency, not availability. Keep client timeouts tight (often 10-50 ms) so a slow Redis cannot dominate request latency, and make sure the database has enough headroom to absorb the resulting miss traffic.
  • Why write SET key value EX 300 instead of SET followed by EXPIRE?
    SET with EX is a single atomic command, so the key can never exist without an expiry. With two commands, a crash, dropped connection, or failover between them leaves a key that lives forever and serves stale data until maxmemory eviction happens to pick it. Immortal keys accumulate silently and are painful to find later.
  • How does cache-aside differ from read-through caching?
    In cache-aside the application code performs the miss handling: it reads Redis, queries the database, and writes back. In read-through the cache layer itself knows how to load from the source, so callers only ever talk to the cache. Plain Redis has no loader, so read-through on Redis is really cache-aside hidden inside a client library or framework abstraction.

Like checking your desk drawer for a form before walking to the records room: if the drawer is empty you fetch the form, use it, and drop a copy in the drawer with a sticky note saying when to throw it out.

saying these in an interview costs you the question

  • Treating a nil reply as an error rather than the normal miss path.
  • Writing cache entries without any TTL, relying purely on explicit invalidation.
  • Using SET then EXPIRE as two separate commands.
  • Failing the request when Redis is unreachable instead of falling through to the database.
  • Letting nonexistent-key lookups bypass the cache forever because negative results are never cached.

context

open as a page

A record that your service caches in Redis is updated by the write path. Should the write path delete the cache key, overwrite it with a fresh TTL, or overwrite it while keeping the key's remaining time-to-live? Explain how each choice changes the worst-case staleness a reader can see.

level: juniorimportance: must knowfreq 58%

basics

~20 s

Deleting is the safest default: the next reader reloads from the database. Overwriting with a fresh TTL restarts the clock, so a hot key's expiry never fires and a wrong value can live forever. Overwriting while keeping the remaining TTL keeps a hard age bound.

open as a page

When your application updates a record that is also cached in Redis, do you delete the cached entry or overwrite it with the new value — and in which order relative to the database write?

level: middleimportance: must knowfreq 64%

basics

~20 s

Delete the cached key rather than overwriting it, and delete only after the database transaction commits. Deletion is idempotent, so racing writers cannot leave a wrong value stuck until the TTL expires. The cost is one extra miss.

open as a page

In a Redis-backed cache, what is a "hot key", and why can a single popular key become a bottleneck even though a Redis node handles hundreds of thousands of operations per second?

level: middleimportance: must knowfreq 55%

basics

~20 s

A hot key is one key taking a hugely disproportionate share of traffic. Because a key's name determines exactly one shard, and that shard executes commands on one thread, the load cannot be spread by adding nodes — one key saturates one CPU core and one network link.

open as a page

What is a cache stampede (thundering herd) in a Redis-backed cache, and what exactly does the request flow look like in the moment a popular cached entry expires?

level: middleimportance: must knowfreq 60%

basics

~20 s

When a popular key expires, every concurrent request gets nil from GET and all of them recompute the same value against the database at once. The number of duplicate recomputes is roughly request rate times recompute latency, and the resulting slowdown makes the window longer.

open as a page

How do you choose the TTL for a value you are caching in Redis? Walk through what actually drives the number, rather than defaulting to five minutes.

level: middleimportance: must knowfreq 65%

basics

~20 s

Start from how stale the data may be for the business, then check it against origin load: with N cached keys and TTL T, expiries alone push roughly N/T refetches per second. Shorten for volatile or sensitive data, lengthen when you also invalidate on write.

open as a page

How would you find out which keys are receiving a disproportionate share of traffic on a production Redis instance, and what does `redis-cli --hotkeys` require in order to work at all?

level: seniorimportance: must knowfreq 45%

basics

~20 s

redis-cli --hotkeys scans the keyspace and reads each key's LFU access counter, so it only works when maxmemory-policy is an LFU policy (allkeys-lfu or volatile-lfu). Otherwise: OBJECT FREQ on suspects, brief MONITOR sampling, or counting keys in the application client.

open as a page

Describe how to guard an expensive cache recompute with a Redis mutex based on `SET lock:<key> <token> NX PX <ttl>`. What should a request that fails to acquire the lock do, and how is the lock released safely?

level: seniorimportance: must knowfreq 50%

basics

~20 s

On a miss, try SET lock:key <random token> NX PX <ttl>. The winner recomputes, writes the cache, then releases with a Lua script that deletes only if the stored value still equals its token. Losers serve stale, poll briefly, or degrade — they must not queue unbounded.

open as a page

A nightly warm-up job writes tens of thousands of Redis cache keys, all with the same TTL. Explain the failure this sets up and how randomising the TTL per key changes it.

level: seniorimportance: must knowfreq 50%

basics

~20 s

All the keys expire in the same second, so the origin sees one huge burst of misses and Redis a spike of expiry work. Add jitter — a random offset of roughly 10-25% of the base TTL per key — so expiries spread out over a window wider than the time to refill.

open as a page

A page render needs 200 objects that are individually cached in Redis. How do you fetch them without paying 200 round trips, and how do you handle the subset that comes back missing?

level: middleimportance: should knowfreq 50%

basics

~20 s

Use MGET with all 200 keys in one round trip. The reply is positional: nil in slot i means key i missed. Collect the missing ids, load them in one batched database query, then write them back — pipelined SETs or MSET plus expiries, since MSET cannot set TTLs.

open as a page

What drives your choice of serialization format for values stored in a Redis cache, and when would you store an object as a Redis hash instead of one serialized string?

level: middleimportance: should knowfreq 45%

basics

~20 s

Pick a format that is fast to encode/decode, compact, and tolerant of field additions — JSON for debuggability, a binary format like Protobuf/MessagePack for size and speed. Use a string when you always read the whole object; use a hash when you read or update individual fields with HGET/HSET.

open as a page

Redis has a CLIENT TRACKING feature used with the RESP3 protocol. What problem does it solve, and how does an application learn that a value it has cached locally is no longer valid?

level: middleimportance: should knowfreq 38%

basics

~20 s

It lets the application keep a copy of hot values in its own process memory and have Redis tell it when they change. After CLIENT TRACKING ON over RESP3, the server records which keys the connection read and pushes an 'invalidate' message listing keys the client must drop when they are modified, expired, or evicted.

open as a page

A service runs on 40 application instances, each with 200 request-handling threads, in front of a Redis cache. Compare in-process request coalescing (singleflight) with a Redis-based recompute mutex for preventing duplicate work on a cache miss — and explain why you might use both.

level: middleimportance: should knowfreq 35%

basics

~20 s

Singleflight collapses concurrent misses for the same key inside one process, so 200 threads become 1 recompute per instance — free, instant, no network. It cannot see other instances, so 40 remain. A Redis lock collapses across instances to about one. Layer them: singleflight first, Redis lock second.

open as a page

Compare a fixed expiry (TTL set once when the value is written to Redis) with a sliding TTL that is extended on every cache hit. When is each the right choice, and what does the sliding version cost?

level: middleimportance: should knowfreq 45%

basics

~20 s

Fixed TTL bounds staleness: the entry dies at a known time no matter how often it is read. Sliding TTL (EXPIRE or GETEX on read) keeps popular entries alive but lets a continuously read key stay stale forever. Use sliding for idle timeouts, fixed for data freshness.

open as a page

How do you design the key names for a Redis cache so you can change the shape of the stored value, or drop a whole category of entries, without scanning or flushing the keyspace?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Use structured, prefixed keys with an embedded version: app:entity:v3:id. Bumping the version makes every old key unreachable and it dies on its own TTL, so a deploy that changes the serialized shape can never read an old-format value. For bulk drops, embed a generation counter you can increment.

open as a page

In a Redis cache-aside setup, a slow read that missed can write its already-outdated value into the cache after a concurrent update has invalidated that key. How do you keep the stale value out, and where does SET with the NX flag help?

level: seniorimportance: should knowfreq 36%

basics

~20 s

The reader loaded the old row before the write committed, then wrote it back after the invalidation. Mitigations: keep TTLs short so damage is bounded; delete the key again after a short delay; write back with SET NX so a reader cannot clobber an entry a writer has already refreshed; or make the write-back conditional on a version stored with the value.

open as a page

Redis's CLIENT TRACKING supports a default per-key mode and a BCAST mode with key prefixes. Compare them, and explain what determines which one you would enable.

level: seniorimportance: should knowfreq 28%

basics

~20 s

Default mode: the server remembers which keys each connection read and invalidates precisely — accurate, but it costs a bounded server-side table that can overflow. BCAST: the server remembers nothing per key and instead broadcasts invalidations for registered key prefixes — zero per-key memory, but clients receive messages for keys they never cached.

open as a page

With Redis-assisted client-side caching, what can leave an application serving a value that Redis has already changed, and what must the client do when its tracking connection drops and reconnects?

level: seniorimportance: should knowfreq 26%

basics

~20 s

Invalidation is best-effort and asynchronous: there is a delivery window, a race where the value is cached after its invalidation was already sent, and total loss of messages while the connection is down. On reconnect the client must flush its entire local cache, since it cannot know what changed meanwhile. A local TTL and size cap are mandatory backstops.

open as a page

One Redis key is read about 50,000 times per second while being rewritten only occasionally, and it is saturating the node that owns it. You are considering adding read replicas of that Redis master so reads can be served from them. Work through the capacity arithmetic: what does each added replica actually buy you for that one key, what would change if the same key were write-hot instead of read-hot, and how does the cost per unit of relief compare with splitting the entry into several suffixed Redis keys or putting a sub-second in-process cache in front of Redis?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Each replica is a separate process with its own core and network link, so N replicas roughly multiply read capacity for that one key. Check egress first: value size times reads per second. A write-hot key gains nothing and adds master work. A near cache is far cheaper.

open as a page

Explain how splitting one heavily-read cache entry into several Redis keys with different suffixes (for example `product:123:0` through `product:123:9`) relieves load, and what that technique costs you.

level: seniorimportance: should knowfreq 35%

basics

~20 s

You store N identical copies under different key names. Because the names hash to different slots, the copies land on different cluster nodes, and each reader picks one at random — so read traffic divides by N. You pay N times the memory, an N-key write fan-out that is not atomic, and a window where copies disagree.

open as a page

Explain probabilistic early expiration (the XFetch algorithm) as a cache-refresh strategy: what extra data must be stored alongside the cached value, and why does it avoid needing a lock at all?

level: seniorimportance: should knowfreq 25%

basics

~20 s

Store the value with its recompute cost (delta) and a logical expiry time. On each read, refresh early if now - delta * beta * ln(random()) is past the expiry. Probability of refreshing rises near expiry and with cost, so usually one reader refreshes before anyone ever sees a miss.

open as a page

A service is flooded with lookups for identifiers that do not exist in the database, and every one of them falls through Redis to the origin. How would you handle this with cache entries and TTLs, and what risks does that introduce?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Cache the miss: store an explicit not-found marker under the same key with a much shorter TTL than positive entries. Risks: the marker must be distinguishable from 'nothing cached', it must be deleted when the entity is created, and attacker-generated keys can flood memory.

open as a page

You decide to put a short-lived in-process cache in front of Redis for a handful of extremely hot cache entries. How do you choose the TTL and the scope, and what failure modes does that introduce?

level: principalimportance: should knowfreq 30%

basics

~20 s

Cache only measured-hot, read-mostly entries in a bounded in-process map with a TTL of about a second. That collapses each instance's Redis traffic for the key to one GET per second, at the price of staleness up to local TTL plus Redis TTL, and instances briefly disagreeing with each other.

open as a page

Once a Redis key's TTL elapses, no client can read its value any more — GET returns nil. Given that, how do you serve a slightly stale cached value to most callers while exactly one worker recomputes the fresh one, and how do you decide how much staleness to allow?

level: principalimportance: should knowfreq 30%

basics

~20 s

Separate physical from logical expiry: give the Redis key a TTL longer than its freshness deadline and store that deadline inside the value. GET then always returns something; when the value is past its logical deadline, one caller wins a SET NX PX lock and rebuilds while everyone else returns the stale copy immediately.

open as a page

Redis's CLIENT TRACKING command accepts OPTIN and OPTOUT modes, paired with a CLIENT CACHING yes|no command. What do they control and why would you want that control?

level: middleimportance: nice to knowfreq 24%

basics

~20 s

They select which read keys the server bothers to track. OPTIN tracks nothing unless the client sends CLIENT CACHING YES immediately before a read; OPTOUT tracks everything except after CLIENT CACHING NO. Both shrink the server's tracking table and the invalidation traffic to only the keys the client actually caches locally.

open as a page

You are deciding whether a fleet of application instances should hold process-local copies of Redis values kept fresh by Redis's CLIENT TRACKING, rather than reading Redis on every request. How do you make that call and bound the risk?

level: principalimportance: nice to knowfreq 18%

basics

~20 s

Adopt it only where the read/write ratio is very high, the working set fits comfortably in process memory, and the data tolerates a millisecond-scale staleness window plus rare loss. Bound the risk with a local TTL, a hard size cap, flush-on-reconnect, per-class allowlists, and metrics on local hit ratio and invalidation volume — and start with one prefix, not the whole keyspace.

open as a page