skip to content

Cache-Aside with Redis Primitives

You will learn the concrete command flow behind cache-aside — miss, load, SET EX, invalidate — and the key-naming and serialization decisions around it. Interviewers ask for this implementation detail to separate people who have shipped a cache from people who have only drawn one.

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

questions

6

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

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

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

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