Backend Systems · Caching
Cache-Aside and Read-Through: Who Owns the Miss
How responsibility for a cache miss is divided between application code and the cache itself, what each arrangement costs in round trips, and how they fail differently when a write races a read.
A cache is defined less by what it stores than by what happens when the requested key is not in it. The hit path is nearly identical in every design: hash the key, find the bucket, compare, return. The miss path is where the architectural decisions live, because a miss forces three separate systems — application, cache, and source of truth — into an ordering, and any ordering has failure windows.
Two arrangements dominate. In cache-aside, the application queries the cache, and on a miss it queries the source itself and then populates the cache. In read-through, the application asks the cache for the value and the cache is configured with a loader that it calls on a miss. The difference sounds like a matter of taste. It is not: it changes which process holds the lock during a load, which process sees the error, and how many round trips a cold read costs.
The miss path as a state machine
Any key in a cache is in one of three states as seen by a reader: present, known-absent (a negative entry with its own expiry), and unknown. The unknown state is the expensive one. A cache that returns only “present” or “absent” collapses the last two, so the reader cannot tell whether the source has been consulted recently.
That matters because the unknown state implies an obligation: someone must consult the source before answering. Which component accepts that obligation determines the shape of the whole read path. A plain GET against memcached or Redis returns a miss and nothing more; the protocol has no loader hook, so the obligation falls to the client. In-process caches in the style of Caffeine or Guava accept a mapping function and take the obligation themselves. The wire protocol is the dividing line: remote caches with a data-only protocol push responsibility up the stack.
Cache-aside: the application sequences three systems
The application performs the sequence explicitly. On a hit the cost is one cache round trip. On a miss the cost is one cache round trip, one source round trip, and one cache write, plus whatever serialization and deserialization happens at each hop. The code is short and its behaviour is fully visible:
func GetUser(ctx context.Context, id string) (*User, error) {
if raw, ok, err := cache.Get(ctx, "user:"+id); err == nil && ok {
return decodeUser(raw)
}
// Miss or cache error: both fall through to the source.
u, err := db.LoadUser(ctx, id)
if err != nil {
return nil, err
}
// Best effort: a failed populate does not fail the read.
_ = cache.Set(ctx, "user:"+id, encodeUser(u), 5*time.Minute)
return u, nil
}
Three properties follow from this shape. First, cache unavailability is a policy decision the application makes explicitly, and the usual policy is to treat a cache error as a miss so that the source absorbs the load. Second, the loader runs in the application, so it can use the application’s database pool, its retry policy, and its request context, including cancellation when the client disconnects. Third, the populating write is a separate operation, which means it can be skipped, batched, or made conditional.
The last point is where cache-aside earns its keep in practice. A request handler that discovers it is loading a large object for a batch of related keys can coalesce the populate into a single multi-key SET. A handler that is already inside a database transaction can delay the populate until after commit, avoiding the case where a cache entry is written for a row whose transaction later rolls back. Read-through cannot express either of those because the loader sees one key at a time and knows nothing about transactions.
The cost is duplication. Every code path that reads the entity must implement the same sequence, and the sequences drift. One path forgets the populate, another uses a different TTL, a third populates before commit. Those inconsistencies are invisible until the cache is cold or partially warm, which is precisely when they are hardest to diagnose.
Read-through: the cache calls the loader
In read-through, the cache is given a function that maps a key to a value, and the application only calls get. On a miss the cache invokes the loader, stores the result, and returns it. The application has no separate populate step, so there is no way to forget it and no way to populate with a different TTL than the one configured.
Two mechanical consequences follow. The loader executes in the cache’s process, or in the library’s goroutine or thread, which means it must be safe to call concurrently and must not depend on per-request state such as a trace context or a transaction handle. And the cache now needs an error channel: a loader that fails has to return an error to the caller rather than a miss, since a miss would be indistinguishable from an absent key. The Get call therefore has to distinguish three outcomes internally — hit, loaded, load-failed — even though it exposes two.
The reward is that the cache can make a guarantee the application cannot easily make for itself: at most one loader invocation per key at a time. Caffeine’s get(key, mappingFunction) documents exactly this — concurrent callers for an absent key block on a single computation, and the rest receive its result. Implementing the same thing around a remote cache requires an explicit in-process single-flight table, which is a lock, a map, and a policy for what happens when the first loader is slow. The mechanics of that pattern are the subject of request coalescing and probabilistic early expiration.
Read-through also hides the cache from the read path’s error accounting: a loader timeout becomes a cache error, and the caller has one place to decide whether to degrade.
Write paths, which do not have to match
The read arrangement and the write arrangement are independent choices, and conflating them causes most of the confusion around these terms.
A write-through cache writes to the source and to the cache in the same operation, so a subsequent read hits. A write-behind cache acknowledges the write after updating only the cache and flushes to the source asynchronously, which converts source latency into a window of potential loss. Write-around skips the cache on write and lets the entry arrive by miss path or not at all. Cache-aside most often pairs with write-around — write the source, then delete or let the entry expire — because populating on write requires the application to know how to serialize the entity into its cached form on every mutation.
Whatever the write path, the ordering constraint is the same: the source must be durable before a reader can observe the new value in cache, or a crash leaves the cache holding a value that never existed. The specific failure windows of delete-on-write and update-on-write are covered in invalidation by deletion and versioned keys.
Where the two designs diverge under concurrency
Consider a read miss and a concurrent write to the same key. With cache-aside the sequence is: reader misses, reader loads the old value from the source, writer commits a new value and deletes the cache entry, reader writes its stale copy into the cache. The stale entry then survives for a full TTL. The interleaving is not exotic — it needs only that the reader’s load and populate straddle the writer’s commit and delete — and it is the reason some systems use a lease or a version stamp rather than a bare SET.
Read-through has the same interleaving, but the cache controls the region where it can happen. A loader invocation can be cancelled or invalidated by a concurrent write if the cache exposes an invalidation that joins the in-flight computation. Remote caches with a data-only protocol cannot do this, which is why the pattern in practice is cache-aside plus a delete, plus an occasional version token in the key.
A second divergence is what happens when the source is slow. In cache-aside, a slow source occupies application workers and downstream connection-pool slots; if the pool is exhausted, the request fails before it ever reaches the cache again. In read-through, the wait occupies the cache’s loader threads, so a slow source can stall unrelated keys that the same loader pool serves — a head-of-line problem that does not exist when each request has its own worker.
What the choice actually decides
Cache-aside decides that the application owns the source contract: batching, transactions, cancellation, and error policy all remain in code the application already controls. Read-through decides that the cache owns miss coordination: one loader per key, uniform TTLs, one place to plug in metrics for load latency and load failure.
The tell is the loader’s dependencies. If populating an entry needs a transaction, a request context, or knowledge of several keys at once, cache-aside is the only arrangement that can express it. If the loader is a single-row lookup with no ambient state, read-through removes an entire class of omission bugs, and it composes with local caches that already provide per-key computation guarantees.
References
- Scaling Memcache at Facebook — Nishtala et al., USENIX NSDI 2013
- memcached protocol documentation — memcached project
- Cache-Aside pattern — Microsoft Azure Architecture Center