Module 3 · Lesson 2 of 7
Cache-aside, step by step
The most common caching pattern, and what the key and value should be.
You've decided the band's event page needs a cache. The next question is mechanical but important: who puts things in the cache, who takes them out, and what exactly gets stored? The most common answer, and the one Gatepass will use, is called cache-aside.
The pattern in four lines
In cache-aside (also called lazy loading), the cache sits beside the database, not in front of it. Your application code talks to both. On every read it does the same four things:
- Build a key for what it wants, like
event:the-band-mumbai. - Ask the cache for that key. If the cache has it, return it. Done.
- If not, run the real query against the database.
- Store the result in the cache with an expiry time, then return it.
The cache never talks to the database and knows nothing about your tables. It's a dumb, fast dictionary. All the intelligence lives in your code, which is why this pattern works with any cache and any database.
Walk through a hit and a miss
Step through both paths below. Pay attention to the clock in the corner. A hit costs about a millisecond. A miss costs the database query plus two extra cache round trips: the failed GET and the SET afterwards.
Interactive
Cache-aside, one request at a time
Send a request and step through it. A miss fills the cache, so the next request is a hit. Expire the key to see the slow path again.
event:the-band-mumbai
Ready
0 msThe cache is empty. Send a request.
Watch which boxes the request touches, and how long it takes.
async function getEvent(id) {const key = `event:${id}`;const cached = await cache.get(key);if (cached) return JSON.parse(cached);const event = await db.query(EVENT_SQL, [id]);await cache.set(key, JSON.stringify(event), { EX: 60 });return event;}
Three things are worth noticing:
- The first request pays for everyone. A miss is slightly slower than having no cache at all, because of the extra round trips. That's fine when a key is read thousands of times. It's a waste when a key is read once, which is why caching rarely-read data does nothing for you.
- The cache fills itself. You never preload anything. Whatever people actually ask for ends up cached, and what nobody asks for never takes up memory.
- Every key starts cold. After a deploy that clears the cache, or after Redis restarts, every request is a miss until the popular keys are back. Lesson 3.6 shows what happens when that coincides with a traffic peak.
Cache-aside is lazy: the cache only ever holds what someone has already asked for.
Choosing the key
The key is how you find the value again, so it must include everything that changes the answer, and nothing that doesn't.
For the event page, the answer depends only on the event, so event:{id} is right. Now consider the "events near you" list on the home screen. It depends on the city, and maybe the page number. A key like events:list would serve Mumbai's list to someone in Pune. The correct key is something like events:city:mumbai:page:1.
The opposite mistake is just as common. If you put the user's id in a key whose value is the same for everyone, like event:{id}:user:{userId}, you store one copy per user. Your hit rate collapses, because each user's first view is a miss, and memory fills with duplicates.
A few conventions that save pain later:
- Use a prefix per kind of thing:
event:,user:,seatmap:. You can find, count and delete them as a group. - Add a version when the shape of the value changes:
v2:event:{id}. Deploying new code that readsv2:keys means old, differently shaped values are simply never read again. - Keep keys readable. You'll be typing them into a Redis console during an incident.
Check yourself
The event page shows prices in the user's currency (INR, USD or EUR). What should the cache key be?
Choosing the value
The simplest value is the exact thing you're about to return: the JSON for the event page. One GET, one response, no further work.
Sometimes it's better to cache smaller pieces. The event page shows the event details (change rarely), the ticket tiers (change a few times a day) and seats left (change every second during the sale). If you cache the whole page for 60 seconds, the seat count is up to 60 seconds wrong. If you cache the details and tiers separately, you can give each its own expiry and fetch seat counts live, or cache them for just 2 seconds.
Rules of thumb for values:
- Keep them small. A few KB is ideal. A 2 MB value takes real time to move over the network on every hit and pushes everything else out of memory.
- Store what you serve, not what you'd have to rebuild. Caching raw rows that you then join and format on every hit saves the database but not your CPU.
- Separate things that change at different speeds. Then each piece can have its own expiry.
Your turn
Design the cache key and expiry for these three Gatepass reads: the seat map layout for a venue, the list of tickets a user has booked, and the trending events in a city.
Stuck? For each one: what makes the answer different, and how stale can it be before someone notices?
When the cache is down
Redis will eventually restart, fail over, or become unreachable for a few seconds. In cache-aside your code decides what happens next, so decide on purpose.
The usual choice is to fail open: if the cache call errors or takes longer than a short timeout (say 50 ms), treat it as a miss and go to the database. Users get slower pages instead of errors. Two details matter:
- Use a short timeout on cache calls. A cache that hangs for 5 seconds is worse than one that's down, because every request waits 5 seconds before falling back.
- Remember what lesson 3.1 said. If your database is sized for the misses only, failing open sends it the full load. For short blips that's survivable. For a long outage at peak, you'll also need to shed load or serve a simpler page.
In the URL shortener drill at /drills/url-shortener you'll add exactly this kind of cache in front of the redirect lookup, then run a load test to see how far it moves the database's breaking point.
Key takeaways
- Cache-aside: check the cache, on a miss read the database, then write the result to the cache with an expiry. The app does all of it; the cache is a plain key-value store.
- A miss costs slightly more than no cache at all. The pattern pays off only for keys that are read many times.
- The key must contain everything that changes the answer and nothing else. Too little serves the wrong data; too much (like a user id) ruins the hit rate.
- Keep values small, store them ready to serve, and split data that changes at different speeds.
- Decide what happens when the cache is down. Usually fail open with a short timeout, knowing the database will feel it.
Finished reading?
Mark it complete to tick it off in the course.