July 15, 2026

Caching strategies: cache-aside, write-through, and write-behind, and when each one bites you

Listen to the summary
0:00 / 0:00
Caching strategies, cover graphic for erkshitiz.com.np

Every caching tutorial I’ve read starts the same way: put Redis in front of the database, check the cache first, fall back to the database on a miss, done. That’s cache-aside, and it’s a fine default, but it’s one point in a design space with real alternatives, each with a different failure mode in production. I’ve now been paged for bugs specific to all three of the patterns in this post’s title, and the fix each time wasn’t “add more caching,” it was picking the pattern that actually matched what the data needed.

Cache-aside: the one everyone reaches for first

Cache-aside, sometimes called lazy loading, puts the application in charge of both directions. On a read, check the cache; on a hit, return it; on a miss, read the database, write the result into the cache, then return it. On a write, update the database and either invalidate or update the cache entry.

func GetUser(ctx context.Context, id string) (*User, error) {
    if cached, ok := cache.Get(ctx, "user:"+id); ok {
        return cached.(*User), nil
    }

    user, err := db.GetUser(ctx, id)
    if err != nil {
        return nil, err
    }

    cache.Set(ctx, "user:"+id, user, 5*time.Minute)
    return user, nil
}

This is genuinely the right default for read-heavy data that tolerates a little staleness, because the cache only ever holds what’s actually been requested, nothing gets warmed speculatively, and a cache outage degrades to “every request hits the database” instead of failing outright. That degrade-gracefully property is worth more than it sounds like on paper. I’ve had Redis fall over during a bad deploy and watched cache-aside quietly turn every request into a database read instead of a 500, which bought enough time to roll back without the incident ever reaching a status page.

The bug that’s specific to this pattern is the thundering herd, also called cache stampede. A popular key expires, and if traffic is high enough, dozens or hundreds of requests can see the miss in the same few milliseconds, and every single one of them goes to the database to rebuild the same value at the same time. I watched this take down a product page during a flash sale: the page’s cache entry expired at a completely ordinary moment, but because that moment happened to land during a traffic spike, several hundred requests missed together, all hit the same expensive query, and the database’s connection pool exhausted in under a second. The cache didn’t fail. The cache did exactly what it was designed to do. The failure was that nothing coordinated the rebuild.

The fix is to make only one request in the herd actually rebuild the value, and have the rest wait on that one rebuild instead of racing it. Go’s singleflight package solves exactly this:

var g singleflight.Group

func GetUser(ctx context.Context, id string) (*User, error) {
    if cached, ok := cache.Get(ctx, "user:"+id); ok {
        return cached.(*User), nil
    }

    v, err, _ := g.Do("user:"+id, func() (interface{}, error) {
        user, err := db.GetUser(ctx, id)
        if err != nil {
            return nil, err
        }
        cache.Set(ctx, "user:"+id, user, 5*time.Minute)
        return user, nil
    })
    if err != nil {
        return nil, err
    }
    return v.(*User), nil
}

Every concurrent caller for the same key collapses into one in-flight database call, and everyone gets that call’s result once it returns. The other half of the fix, which I only added after the incident, is jittering the TTL, 5*time.Minute + rand.Intn(30)*time.Second instead of a flat five minutes, so that entries set around the same original time don’t all expire in the same instant later. Neither fix is exotic. Both are easy to forget until the traffic spike that finds the gap.

Write-through: paying the latency up front so reads never lie

Write-through moves the cache write inside the write path itself. Every write goes to the cache and the database together, synchronously, before the request is considered done. Reads always hit a cache that’s never behind the database, because nothing that reaches the database skips writing to the cache too.

The trade is latency, paid on every write, in exchange for a guarantee cache-aside can’t make: the cache is never stale relative to the database, because there’s no window between “database updated” and “cache updated” for a reader to land in. That guarantee matters for data where a stale read is actually a correctness bug rather than a minor inconvenience, an account balance or an inventory count you’re about to sell against, not a blog post’s view count.

func UpdateInventory(ctx context.Context, sku string, qty int) error {
    if err := db.UpdateInventory(ctx, sku, qty); err != nil {
        return err
    }
    // If this fails after the DB write already succeeded, the cache
    // is now stale until the next write or an explicit invalidation.
    return cache.Set(ctx, "inventory:"+sku, qty, 0)
}

That comment in the code is the actual production bug I’ve hit with write-through: it removes the read-side staleness window, but it doesn’t remove every staleness window, because the two writes still aren’t atomic. If the database commit succeeds and the cache write then fails or times out, the state that was supposed to be impossible, a cache behind the database, exists anyway, just from the write side instead of the read side. The fix isn’t a retry loop bolted onto the cache write, it’s accepting that write-through gives you “much less staleness, far less often” rather than “provably zero,” and building a background reconciliation job for whatever fraction of writes fall through the cracks, if that fraction actually matters for your data.

Write-behind: fast writes, deferred risk

Write-behind, also called write-back, inverts write-through completely. The write lands in the cache and returns immediately; the write to the actual database happens later, asynchronously, usually batched. It’s the fastest possible write path, and it’s the pattern behind things like OS page caches and a lot of high-throughput metrics or logging pipelines, anywhere you’re willing to trade some durability for write throughput that a synchronous database write couldn’t sustain.

The trade is durability, not staleness. If the process holding the not-yet-flushed writes crashes, or the cache node restarts before the batch drains to the database, those writes are gone, permanently, because the only place they ever existed was memory that just disappeared. I’ve seen this pattern used correctly for view counters and rate-limit windows, values where losing a few seconds of updates on a rare crash is an acceptable cost against a real throughput requirement, and used incorrectly for anything that looks like money or an order, where “we lost the last thirty seconds of writes” is not a sentence you get to say to a customer.

The mitigation, if you need write-behind’s throughput but can’t tolerate silent loss, is a durable queue in front of the batching layer, writes go to something like Kafka or a WAL first, get acknowledged from there, and the cache-then-database flush reads off that durable log instead of holding the only copy in volatile memory. At that point it’s less “write-behind” and more “asynchronous write pipeline with a durability guarantee,” which is really the honest description of what you actually need whenever someone asks for write-behind’s speed without meaning to accept its risk.

Picking between them without defaulting on autopilot

The question that actually decides it, in my experience, is: what does a stale or lost read or write cost, in real terms, for this specific piece of data?

Cache-aside is the right default for read-heavy data where staleness for a few minutes is genuinely fine, view counts, profile data, product descriptions, anything a user would shrug at if it were thirty seconds behind reality. Write-through earns its latency cost for data where a stale read is a correctness bug, not just an inconvenience, balances, stock levels, permissions, anything you’re about to make a decision against. Write-behind earns its risk for write-heavy, loss-tolerant data where throughput is the actual constraint and a durable queue is added underneath the moment “loss-tolerant” turns out not to be true.

None of the three is strictly better than the others. Every incident I’ve actually been paged for came from using the right pattern for the wrong kind of data, cache-aside on a balance, write-behind on an order, not from picking the wrong library or the wrong TTL number.