
A product page is requested thousands of times per minute, while the product changes only a few times per day. Sending every request to the primary database wastes capacity and makes a traffic spike a database incident. Cache-aside is the common pattern that keeps the database authoritative while allowing the application to serve repeated reads from a fast cache.
This is the production version of the caching fundamentals discussion. The focus here is not “add Redis,” but exactly who reads, populates, invalidates, and recovers when the cache is empty or unavailable.
Table of Contents
Open Table of Contents
- What Is the Cache-Aside Pattern?
- The Read Flow
- The Write and Invalidation Flow
- The Stale-Repopulation Race
- Cache Keys, TTLs, and Negative Results
- Cache Stampedes and Failure Handling
- A TypeScript Example
- When Cache-Aside Fits
- Production Checklist
- Interview Questions
- Conclusion
- References
- YouTube Videos
What Is the Cache-Aside Pattern?
In cache-aside, the application manages the cache explicitly. It first asks the cache for a value. On a hit, it returns that value. On a miss, it loads the source of truth, stores a copy in the cache with a TTL, and returns the source result. The cache never fetches from the database by itself.
That ownership is the main trade-off. The application can choose exactly which data is worth caching and how fresh it must be, but it must also get invalidation and failure behavior right. A cache is optional acceleration, not the authoritative record. If it is restarted or evicts a key, the service must still work by reading the database.
flowchart TD
A[Request] --> B{Cache has key?}
B -->|Hit| C[Return cached value]
B -->|Miss| D[Read source of truth]
D --> E[Set cache value with TTL]
E --> F[Return source value]
The Read Flow
Use a stable, namespaced key such as product:v1:1234. The version and resource scope make it possible to change serialization or invalidate a whole key family without colliding with another service.
- Build the cache key from the resource identity and every response-shaping input, such as locale or tenant.
- Read the key from the cache.
- On a hit, deserialize and return it. Record a hit metric.
- On a miss, load the database or upstream source. Record a miss metric.
- If a value exists, serialize it and set a bounded TTL.
- Return the source result even if the cache set fails.
The final rule matters. A cache outage should normally degrade latency and database load, not turn a readable product catalog into a full outage. That requires timeout budgets, circuit breaking, and enough database capacity for a controlled cache bypass.
Read-your-writes is a product promise
Cache-aside can serve a value that was correct when cached but is now stale. For public catalog details, a 60-second delay may be fine. For a user who just changed their address, it is confusing. After a write, either invalidate the relevant key before returning success, update it deliberately, or pin that user’s immediate follow-up read to the source of truth. State the freshness promise before choosing the TTL.
The Write and Invalidation Flow
For cache-aside, the usual write path is write to the database first, then delete the affected cache key. The next read misses and repopulates from the new authoritative value.
sequenceDiagram
participant A as Application
participant D as Database
participant C as Cache
A->>D: Commit product update
D-->>A: Commit succeeds
A->>C: Delete product:v1:1234
A-->>A: Return success
Note over A,C: Next read reloads current value
Deleting rather than immediately rewriting is often safer because a write can affect derived views, lists, permissions, and related resources. It avoids creating a new cache entry from a partially assembled object. It is not atomic across the database and cache: if the database commit succeeds but deletion fails, the old value remains until TTL expiry. Mitigate that gap with a finite TTL, retryable invalidation, an outbox/event for complex fan-out, and monitoring. Do not pretend a best-effort DEL provides transactional consistency.
For a simple entity, an update-after-commit can be correct and keeps the cache warm. Use it only when the code has the full canonical representation and can handle cache failure safely. The write-through versus write-back caching guide compares that choice with other write strategies.
The Stale-Repopulation Race
Database-first invalidation has a second, concurrent failure window even when the DELETE succeeds. Reader A can miss the cache and read version 1 from the database. Before Reader A stores that result, Writer B commits version 2 and deletes the key. If Reader A now stores its earlier version 1 result, it recreates a stale entry after the invalidation.
This race does not make cache-aside unusable; it defines the freshness limit. Keep a finite TTL so the stale value expires, and use a versioned payload with a compare-and-set or conditional write when the cache supports it. For high-value data, publish an invalidation event after the authoritative commit and make consumers retry it. A timed second delete is sometimes used, but it is only a heuristic: document its delay and do not mistake it for cross-store atomicity. Read-after-write or correctness-critical paths should still use the source of truth or a stronger consistency mechanism.
Cache Keys, TTLs, and Negative Results
A cache key is part of the correctness boundary. Include tenant, authorization scope, locale, selected fields, and pagination cursor when they change a response. Never use a broad profile:42 key for a response that varies by viewer permission. That is a data-leak bug, not merely a cache bug.
TTL is also a business decision. A short TTL limits stale data but increases misses; a long TTL boosts hit ratio but makes invalidation failures more visible. Add modest random jitter to hot-key TTLs so many keys do not expire in the same second. Track actual age-at-read if freshness is important.
For a frequently requested missing record, cache a short-lived negative result. This prevents repeated database lookups for a nonexistent ID. Keep the negative TTL shorter than a normal value if the record might soon be created, and distinguish a real “not found” from a temporary source failure. Never cache a failed dependency response as though it were absence.
Cache Stampedes and Failure Handling
A cache stampede happens when a hot key expires and many requests miss simultaneously. All of them load the database, recreating the spike caching was meant to absorb. Start with these defenses:
- Add TTL jitter for groups of keys that otherwise expire together.
- Coalesce in-flight loads so one request refreshes a key while peers wait briefly or receive a stale value.
- Use a short distributed lock only when its failure modes are understood; the lock must not become a new dependency outage.
- Serve stale-while-revalidate data only when the product explicitly allows it.
- Pre-warm a small known set of hot keys after deploys or planned expirations.
The cache itself can fail, evict aggressively, or become slow. Set a small cache timeout, emit separate cache and database metrics, and decide whether the source can absorb bypass traffic. A cache hit ratio alone is not enough: watch p95 cache latency, command errors, eviction rate, memory pressure, hot-key concentration, miss load, and source database saturation.
A TypeScript Example
This example uses pseudocode-shaped interfaces so the important ordering is visible. In production, use your database client’s transaction and your cache client’s timeouts.
type Product = { id: string; name: string; priceCents: number };
async function getProduct(id: string): Promise<Product | null> {
const key = `product:v1:${id}`;
try {
const cached = await cache.get(key);
if (cached !== null) {
return JSON.parse(cached) as Product;
}
} catch (error) {
logger.warn({ error, key }, "Cache read failed; loading from the database");
}
const product = await database.findProduct(id);
if (product !== null) {
try {
// TTL bounds stale data if an invalidation is missed.
await cache.set(key, JSON.stringify(product), { ttlSeconds: 300 });
} catch (error) {
logger.warn(
{ error, key },
"Cache write failed; returning database result"
);
}
}
return product;
}
async function updateProduct(product: Product): Promise<void> {
await database.updateProduct(product); // Source of truth commits first.
const key = `product:v1:${product.id}`;
try {
await cache.delete(key); // Next read reloads it.
} catch (error) {
// The durable update still succeeds; TTL and a repair workflow bound staleness.
logger.warn({ error, key }, "Cache invalidation failed");
}
}
The error policy belongs around these calls. A cache get failure may fall back to the database; a database failure cannot be masked by blindly returning an arbitrary cached value unless the endpoint permits stale data. Keep that policy explicit in the service contract.
When Cache-Aside Fits
Cache-aside works best for read-heavy, repeatedly requested, independently cacheable data: catalog entries, public profiles, configuration, feature metadata, and expensive computed views with a clear freshness budget. It naturally caches only keys that are actually requested, which keeps cold data out of memory.
It is a poor first choice for write-heavy data, strict cross-key consistency, or values whose correctness depends on every read seeing the latest commit. For those cases, the source query may need improvement first; use the slow SQL debugging guide before treating caching as a substitute for a sound access path. A shared cache versus per-process cache decision is covered by the distributed cache system design guide.
Production Checklist
- Define the source of truth and allowed staleness for every cached response.
- Namespace keys and include all tenant, viewer, locale, and response-shaping inputs.
- Bound every value with TTL; add jitter for hot populations.
- Commit the database before invalidating or updating the cache.
- Make cache bypass safe with timeouts, circuit breaking, and capacity planning.
- Test a cache restart, eviction burst, invalidation failure, and concurrent miss surge.
- Monitor hit ratio, cache latency, errors, evictions, misses, source load, and stale-data reports.
Interview Questions
1. What is cache-aside?
It is an application-managed lazy-loading pattern. The application checks the cache, reads the source on a miss, then stores the result with a TTL. The cache is an acceleration layer rather than a source of truth, so cache loss results in a miss and reload.
2. How do you keep cache-aside data fresh after a write?
I commit the source-of-truth write first, then invalidate the affected key or keys. The following read repopulates from current data. I use TTL as a safety bound, retry important invalidations, and use an outbox/event mechanism when one write affects many derived keys.
3. How do you prevent a cache stampede?
I add TTL jitter, coalesce concurrent misses for a hot key, and choose whether stale-while-revalidate is allowed. I also ensure the database can handle a controlled cache bypass, because a lock or cache timeout must not turn into a larger outage.
4. Why is cache key design security-sensitive?
The key determines who can receive the cached representation. If it omits tenant or permission scope, a cache hit can return one user’s data to another user. I include every input that changes authorization or output and test those boundaries explicitly.
5. Should the application update or delete a cache entry on writes?
Deleting after the authoritative commit is the safer default because the next read rebuilds the canonical representation. Updating keeps the cache warm but is correct only when the writer has the complete, final value and handles failure. The choice depends on freshness needs and how many derived keys the write affects.
Conclusion
Cache-aside is simple in its core loop but serious in its edges: read cache first, load on a miss, write the source first, and invalidate deliberately. Treat the cache as disposable, use TTLs as a freshness boundary, design keys as carefully as authorization, and test the miss and failure paths as rigorously as the hit path.
References
- Redis Documentation: Cache-Aside
- Azure Architecture Center: Cache-Aside Pattern
- AWS: Database Caching Strategies Using Redis
YouTube Videos
- “Redis and MongoDB: Cache-Aside Pattern” - Redis https://www.youtube.com/watch?v=AJhTduDOVCs
- “Redis Caching Strategies Explained: Cache Aside vs Write-Through Patterns” https://www.youtube.com/watch?v=nOFyeXFhpLk