Module 4: Cache — The Read-Heavy Path

3. The cache-aside pattern

Description

You already know why Enlace needs a cache. Now for the how, and there's a pattern that dominates the industry for its simplicity and its robustness: cache-aside, also called lazy loading. The idea fits in one sentence: on each read, look at the cache first; if the data is there (a hit), respond from it and that's it; if it's not there (a miss), go to the database, fetch the data, store it in the cache for next time, and respond. The cache fills "on its own", lazily, with the data as someone requests it —never before—. Nobody preloads anything: the first visitor of each link pays the miss, and everyone who comes after collects the hit.

This lesson builds that pattern step by step over resolve(short_code), Enlace's read function. You'll see the flow drawn (the hit and miss diagram), you'll see it in code, and you'll run it to observe how the cache starts empty and populates with each request. Then we attack the other half, which many people forget: the write path. When someone creates a new link with shorten, does it get stored in the cache? The answer —which in cache-aside is "no, it's written only to the database"— has an underlying reason you'll understand, and it will prepare you for lesson 7, where the write path becomes the stage of the invalidation problem.

Connection to the module: lesson 2 justified the cache with numbers; this one turns it into a concrete, executable pattern. Lesson 4 answers what to do when the cache, which here fills up nonstop, runs out of RAM: eviction. Lesson 5 will measure the latency of the flow you build here (fast hit, slow miss). Lesson 7 will return to this lesson's write path to resolve what happens when a cached piece of data changes. Cache-aside is the skeleton on which the four lessons that follow are mounted.

The receptionist and the basement archive

Think of it this way. You work as a receptionist in a large building. People arrive and ask for some employee's office number. All the truth is in an enormous archive, in the basement: if you go there, you find anyone, but going down and up takes ten minutes. On your desk you have a little notebook where you jot down the numbers you've already looked up.

When someone arrives and asks "where is López?", your reflex is: first I look at the notebook. If López is already jotted down (because someone asked for him before), I read the number in two seconds and answer —that's a hit—. If he's not there, I go down to the basement, look up López in the archive, come back up, jot him down in the notebook for next time, and answer —that's a miss—. Notice the exact order and that the notebook fills on its own: you didn't preload it with the five hundred employees, it filled with the ones people actually asked about. The day they ask about someone new, you pay the trip to the basement once; from then on, that name is already in the notebook.

That's cache-aside, literally. The notebook is the cache (cache); the basement archive is the database (db); you, the receptionist, are the app server that orchestrates. The pattern is called "cache-aside" precisely because the cache is beside your flow, like an assistant: you decide when to look at it and when to populate it; the cache doesn't talk to the basement on its own. All the logic —"look at the notebook, if it's not there go down to the basement and jot it down"— lives in you, in the app's code. That's the signature of cache-aside, and what distinguishes it from other patterns.

Cache-aside: on each read, the app looks at the cache first. Hit → responds from the cache. Miss → reads the database, populates the cache with that data, and responds. The cache fills lazily, with the data someone actually requests, and all the orchestration lives in the app.

The read flow, drawn

Before the code, the diagram. There are two possible paths on each read —the hit and the miss— and it's good to see them separated because they're the anatomy of the pattern:

flowchart TD
    Start([resolve short_code]) --> Ask{is it in cache?}
    Ask -->|YES · HIT| ReturnHit[return long_url<br/>from the cache · ~1 ms]
    Ask -->|NO · MISS| Query[read the database · ~50 ms]
    Query --> Found{does the link exist?}
    Found -->|NO| NotFound[return 404<br/>short_code doesn't exist]
    Found -->|YES| Populate[populate the cache:<br/>cache.set short_code, long_url]
    Populate --> ReturnMiss[return long_url]

Read it top to bottom. Every read starts at the "is it in cache?" diamond. If yes (left branch, the hit), it's over in one step: you return the long_url in ~1 ms without touching the database. If no (right branch, the miss), you make the expensive trip to the database, and there's an important fork that's sometimes forgotten: the link may not exist (an invented short_code, or an already-expired one) —in that case you return a 404 and cache nothing—; or the link does exist, and then you populate the cache before responding. The "populate" step is what makes the next read of the same short_code a hit. Without that step, each read would be a miss forever and the cache would be useless.

The pattern in code

Let's translate the diagram to resolve. In Enlace, resolving a short_code means: given "aX9kR2q", return its long_url. Here's the cache-aside version, with the cache and the database simulated as dictionaries so the pattern is seen without infrastructure noise:

# resolve.py — resolve() with cache-aside, cache and db simulated
class FakeDB:
    """The source of truth. Slow but complete. Counts how many times it's touched."""
    def __init__(self):
        self.store = {"aX9kR2q": "https://example.com/very-long-article"}
        self.hits = 0                       # how many times the DB was queried

    def get_link(self, short_code):
        self.hits += 1
        return self.store.get(short_code)   # None if it doesn't exist


class FakeCache:
    """The fast shelf in RAM. Starts empty."""
    def __init__(self):
        self.store = {}
        self.hits = 0
        self.misses = 0

    def get(self, key):
        if key in self.store:
            self.hits += 1
            return self.store[key]
        self.misses += 1
        return None

    def set(self, key, value):
        self.store[key] = value


def resolve(short_code, cache, db):
    """Cache-aside: look at the cache; if missing, read the DB and populate the cache."""
    long_url = cache.get(short_code)        # 1. look at the cache first
    if long_url is not None:                # 2. HIT: return and done
        return long_url

    long_url = db.get_link(short_code)      # 3. MISS: go to the source of truth
    if long_url is None:                    # 4. doesn't exist: don't cache, 404
        return None
    cache.set(short_code, long_url)         # 5. populate the cache for next time
    return long_url                         # 6. respond


# --- A session of three visits to the SAME link ---
cache, db = FakeCache(), FakeDB()
for visit in (1, 2, 3):
    url = resolve("aX9kR2q", cache, db)
    print(f"visit {visit}: {url}")

print(f"\ncache hits   = {cache.hits}")
print(f"cache misses = {cache.misses}")
print(f"DB queries   = {db.hits}")

Follow the six numbered lines of resolve: they're the whole pattern. Look at the cache (1); if there's something, return it (2); if not, go to the database (3); if it's not there either, it's a 404 and you don't cache (4); if it is, populate the cache (5) and respond (6). The block below simulates three visits to the same link to see the effect.

What to expect. With python resolve.py:

visit 1: https://example.com/very-long-article
visit 2: https://example.com/very-long-article
visit 3: https://example.com/very-long-article

cache hits   = 2
cache misses = 1
DB queries   = 1

Here's the whole pattern in three numbers. The three visits returned the same URL, but the database was queried only once. The first visit was a miss (empty cache → trip to the database → populate); the other two were hits (the cache already had the data). That's the cache-aside deal: the first visitor of each link pays the miss, and everyone else collects the hit. In Enlace, where a viral link receives thousands of visits, that "one pays, thousands save" is exactly the asymmetry that makes the hit ratio rise so much —a single trip to the database amortized across thousands of reads.

The fine detail: not caching the 404

Notice line 4, if long_url is None: return None, and that it does not call cache.set. It's a deliberate decision. When someone requests a short_code that doesn't exist —a bot trying random codes, an expired link—, the database returns "it's not there", and the temptation would be to not cache that result and be done. But there's a nuance: if you don't cache the "doesn't exist" results, each request for a nonexistent code hits the database again, and a bot trying millions of fake codes can saturate it —a miss that never becomes a hit—. The advanced solution is to cache the negatives too (store "this code doesn't exist" with a short TTL), called negative caching. Here, for simplicity, we don't cache them, but it's important that you know "what do I do with the misses that don't exist?" is a real design question, not an oversight. Lesson 7, with TTL, will give you the tool to do negative caching well.

The write path: why shorten doesn't touch the cache

Up to here, everything was reads. But Enlace also writes: shorten(long_url) creates a new short_code and stores the Link record. The natural question is: when you create a link, do you also put it in the cache? In pure cache-aside, the answer is no: shorten writes only to the database, and leaves the cache alone. The link will enter the cache later, the first time someone visits it (its first miss). Here's the write path:

# shorten.py — shorten() in cache-aside: writes ONLY to the DB
def shorten(long_url, cache, db):
    """Creates a short_code, stores it in the DB, and does NOT touch the cache."""
    short_code = db.create_link(long_url)   # only the source of truth
    return short_code                        # the cache will populate on the 1st read

Why not populate the cache on creation? For two reasons worth understanding, because they reveal the philosophy of cache-aside:

  1. Most created links are never visited much. Remember the 100:1: for every write there are a hundred reads, but those reads concentrate on a few hot links (the 80/20 rule of lesson 6). If you cached every link on creation, you'd fill the cache with millions of links no one will visit, wasting the RAM you need for the hot ones. Cache-aside is lazy on purpose: only what's actually requested enters the cache.

  2. It keeps the cache as a mirror of "what's read", not of "what exists". The source of truth of "which links exist" is the database. The cache is a mirror of "which links are being read now". Populating it on write would mix the two roles and put cold data into a space meant for hot data.

Populating the cache at write time is a valid strategy —it's called write-through, and we mention it below—, but for Enlace, with its enormous queue of links almost no one visits, cache-aside's lazy loading is the correct choice: it doesn't spend RAM on cold links.

Cache-aside vs. its cousins (a quick look)

Cache-aside isn't the only pattern; it's the most common, but it's good to place it among its neighbors to know why we chose it. The key difference is who talks to the database and when the cache is populated:

PatternWho orchestratesWhen the data enters the cacheFor Enlace
Cache-aside (lazy)The app looks at the cache and, on a miss, reads the database and populatesOn the first read (lazy)The choice: only caches what's actually read
Read-throughThe cache reads the database for you on a missOn the first read, but the cache does itSimilar, but requires a cache layer that knows how to talk to the database
Write-throughThe app writes to the cache, which writes to the databaseOn the write (on create/update)Wastes RAM: it would cache millions of cold links
Write-behind (write-back)The app writes to the cache, which writes to the database later, deferredOn the write, and persists laterRisky: if the cache dies before persisting, writes are lost

The distinction between cache-aside and read-through is subtle —in both, the data enters on the first read— and the difference is only who makes the trip to the database: in cache-aside your app code does it (that's why all the logic is in resolve); in read-through the cache layer does it underneath. For this module we use cache-aside because it's explicit: you see every step in your own code, which is exactly what's needed to learn it. Write-through and write-behind change the write path, and both would populate the cache on creation —which, as we saw, doesn't suit Enlace—.

Common mistakes

Forgetting to populate the cache on the miss. What happens: someone writes resolve so it looks at the cache and, on the miss, reads the database and returns the data… but forgets the cache.set. The code works —it always returns the correct URL— so nobody notices the bug in the tests. But the cache never fills: each read is a miss, the hit ratio is 0, and the cache saves absolutely nothing. Why it happens: the cache.set doesn't affect the correctness, only the performance, so a functional test doesn't catalyze it. How to detect it: measure the hit ratio under load; if it's ~0 with repeated traffic, you're not populating. How to fix it: the "populate" step (line 5 of the example) is as much part of the pattern as looking at the cache. A cache-aside without cache.set is a receptionist who goes down to the basement every time and never jots anything in the notebook.

Writing to the cache and forgetting the database (or vice versa). What happens: someone, on the write path, stores the new link in the cache but forgets to store it in the database. Everything goes fine until the cache restarts (or the link is evicted) and the data disappears forever, because it never touched the persistent source of truth. Why it happens: "I stored it" is confused with "I persisted it", and the volatile cache doesn't persist. How to detect it: ask yourself "if the cache turns off, does this link still exist?". If not, you didn't write it where you should have. How to fix it: in cache-aside, the write goes always to the database (the source of truth); the cache populates on its own on the read. Never write only to the cache a piece of data that must survive.

Caching every link on creation "to get ahead". What happens: someone decides that, since they're creating the link, they'll put it in the cache right away so the first visit is also a hit. It sounds efficient, but it fills the cache with millions of links no one will visit, and those cold links evict the hot ones that do matter —lowering the hit ratio instead of raising it—. Why it happens: it seems like a free "head start", but it isn't: RAM is finite and each cached cold link takes the place of a hot one. How to detect it: if your hit ratio drops after "optimizing" by preloading, you preloaded garbage. How to fix it: let the cache be lazy. In a system like Enlace, where few links concentrate the visits, caching only on demand (cache-aside) keeps the cache full of the hot stuff. Preloading only makes sense when you know a specific piece of data will be requested soon (for example, a newspaper's home page right after publishing it), not for everything by default.

Exercises

Exercise 1 — Trace the hits and misses. With the cache starting empty and using cache-aside, a user does this sequence of resolve over these short_codes: A, B, A, C, A, B. For each one, say whether it's a hit or a miss, and at the end count how many times the database was queried. Assume the three links exist.

See solution

Trace, with the cache starting empty:

#codein cache before?resulttouches the DB?
1AnoMISSyes (and populates A)
2BnoMISSyes (and populates B)
3AyesHITno
4CnoMISSyes (and populates C)
5AyesHITno
6ByesHITno

Database queries: 3 (those of A, B, and C, one per distinct link the first time it appears). Hits: 3. Misses: 3. Hit ratio = 3/6 = 50%.

The key lesson: in cache-aside, the database is queried once per distinct piece of data, not once per read. Six reads, but only three distinct links, so only three trips to the database. The more each link repeats (more visits per distinct link), the higher the hit ratio rises —and that's exactly what happens in Enlace, where a viral link repeats thousands of times.

Exercise 2 — The forgotten set bug. A colleague wrote this version of resolve. It works in the tests (always returns the correct URL), but in production the hit ratio is 0. Which line is missing and why doesn't the bug show up in a correctness test?

def resolve(short_code, cache, db):
    long_url = cache.get(short_code)
    if long_url is not None:
        return long_url
    long_url = db.get_link(short_code)
    if long_url is None:
        return None
    return long_url          # <-- returns, but...
See solution

It's missing populating the cache before returning, on the miss path. The missing line is cache.set(short_code, long_url) right before the final return long_url:

    if long_url is None:
        return None
    cache.set(short_code, long_url)   # <-- THE MISSING LINE
    return long_url

Why the bug doesn't show up in a correctness test: the function always returns the correct URL, with or without the set. On the miss it reads the database and returns the good data; it simply never stores it. A test that only checks "does resolve('aX9kR2q') return the correct URL?" passes green, because the correctness is perfect. What's broken is the performance: without the set, the cache never fills, each read is a miss, and the hit ratio stays at 0. This is a reminder that in cache-aside the "populate" step isn't optional or cosmetic: it's what turns the cache into a cache. And that there are bugs —performance ones— that are only seen by measuring under load, not with a functional test.

Exercise 3 — Cache-aside or write-through? For each situation, decide whether cache-aside (populate on read, lazy) or write-through (populate on write) is best and explain why in one sentence. (a) Enlace: 100M links created a month, but only a few go viral. (b) A newspaper that publishes its home page and knows a million people will request it in the next minutes. (c) A catalog where each created product is shown immediately on a heavily visited page.

See solution
  • (a) Enlace: cache-aside. Most of the 100M monthly links are barely visited; caching them all on creation (write-through) would fill the RAM with cold links that would evict the hot ones. Lazy loading puts into the cache only what's actually requested. It's the textbook case for cache-aside.
  • (b) Newspaper home page: write-through (or explicit preload). Here you know the just-created data will be requested a lot, immediately. Populating the cache on publishing avoids the first million readers competing for the single miss (a stampede on the database at startup). When the certainty of "this will be requested now" is high, getting ahead wins.
  • (c) Catalog with an immediately visible product: write-through makes sense. If each new product appears on a heavily viewed page, it's likely to be requested soon, so populating it on creation avoids the initial miss. It's an intermediate case: less extreme than the home page, but the "just created → soon requested" correlation tips the balance toward write-through.

The rule that distills: cache-aside when you don't know which data will be requested (and most isn't requested much, as in Enlace); write-through / preload when you know a just-created piece of data will be requested soon (home page, featured catalog). Enlace lives firmly in the first world, and that's why it's our default pattern throughout the module.

Summary and next step

In this lesson you built the pattern that dominates caching: cache-aside. On each read, the app looks at the cache first; on a hit it responds from there (~1 ms); on a miss it reads the database (~50 ms), populates the cache with that data, and responds. The cache fills lazily, with what's actually requested, and all the orchestration lives in the app's code —the receptionist who looks at the notebook before going down to the basement and jots down what they bring—. You saw it in resolve and you ran it: three visits to the same link, a single query to the database.

You also attacked the write path: in cache-aside, shorten writes only to the database and doesn't touch the cache, because most created links are barely visited and preloading them would waste RAM. And you placed the pattern among its cousins: read-through (the cache makes the trip), write-through (populate on write), write-behind (persist deferred), each useful in its terrain, none as suited to Enlace as lazy loading.

Before moving on you should be able to: recite the cache-aside flow (look at the cache → hit returns / miss reads the database, populates, and returns); explain why the "populate" step isn't optional; say why shorten doesn't cache on creation in Enlace; and distinguish cache-aside from write-through in one sentence.

What comes next is a problem today's pattern creates unintentionally: the cache-aside cache fills and fills, nonstop, with each new link someone visits. But RAM is finite. What happens when it runs out? In lesson 4 you'll see the eviction policies —what gets thrown out when the cache fills up—, with LRU at the front, and you'll run a simulation that shows, step by step, what it evicts and how many reads to the database it avoids.

Resources

  • Caching Strategies — cache-aside, read-through, write-through (AWS) — AWS's official explanation of this lesson's patterns, with the same names and the same tradeoffs. It confirms why cache-aside (lazy loading) is the default for most read-heavy systems.
  • Cache-Aside pattern — Microsoft Azure Architecture Center — a precise step-by-step description of the pattern, with the read flow and the considerations (what to do on a miss, when to populate). A good second angle on the same skeleton we built in resolve.
  • Redis — GET and SET — the two commands underneath cache.get and cache.set in our code. Seeing the real shape of the operations helps remember that the cache is a key-value dictionary and nothing more complicated than that.