Loading learning content...
When engineers say "we added caching," they're most often referring to the cache-aside pattern—the most widely adopted caching strategy in production systems worldwide. From Reddit and Twitter to Netflix and Uber, cache-aside forms the backbone of how applications interact with their caches.
Unlike other caching patterns that integrate the cache as an automatic intermediary, cache-aside puts the application in full control. The application explicitly checks the cache, explicitly fetches from the database on misses, and explicitly populates the cache with fresh data. This explicit control is both its greatest strength and its source of complexity.
By the end of this page, you will understand the cache-aside pattern at a deep level—its mechanics, implementation patterns, subtle failure modes, consistency guarantees, and precisely when it outshines other caching strategies. You'll be equipped to implement cache-aside correctly and reason about its behavior under various conditions.
The cache-aside pattern—also known as lazy-loading or demand-loading cache—is a caching strategy where the application code assumes responsibility for maintaining the cache. The cache sits "aside" the data store rather than in front of it, and the application directly orchestrates interactions between both.
The defining characteristic: Neither the cache nor the database knows about each other. The application serves as the sole coordinator, deciding when to read from cache, when to fall back to the database, and when to update the cache.
This stands in contrast to patterns like read-through and write-through, where the cache itself intercepts requests and manages the underlying data store transparently. Cache-aside requires explicit application logic—but provides unparalleled control.
The name comes from the architectural position of the cache—it sits "aside" the main data path rather than "in front of" or "behind" it. The application reaches out to the cache as a side operation, falling back to the primary data store when necessary. This is in contrast to read-through caches, which sit directly in the request path.
Understanding cache-aside requires walking through its exact read path. Every read operation follows a consistent decision tree that determines whether data comes from cache or database.
The Cache-Aside Read Algorithm:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
interface User { id: string; email: string; name: string; preferences: UserPreferences;} class UserService { private cache: CacheClient; private database: DatabaseClient; private readonly CACHE_TTL_SECONDS = 3600; // 1 hour constructor(cache: CacheClient, database: DatabaseClient) { this.cache = cache; this.database = database; } async getUser(userId: string): Promise<User | null> { const cacheKey = `user:${userId}`; // Step 1: Check the cache const cachedUser = await this.cache.get<User>(cacheKey); // Step 2: Cache hit - return immediately if (cachedUser !== null) { this.metrics.increment('cache.hit', { entity: 'user' }); return cachedUser; } // Step 3: Cache miss - query the database this.metrics.increment('cache.miss', { entity: 'user' }); const user = await this.database.query<User>( 'SELECT * FROM users WHERE id = $1', [userId] ); if (user === null) { // User doesn't exist - consider caching the null result // to prevent repeated database lookups (negative caching) return null; } // Step 4: Populate the cache for future requests await this.cache.set(cacheKey, user, this.CACHE_TTL_SECONDS); // Step 5: Return to caller return user; }}Critical observations from this implementation:
The application owns the logic — There's no magic. Every decision is explicit in code.
Cache key design matters — The key user:{userId} must be unique and consistent. Poor key design leads to collisions or cache fragmentation.
TTL is a crucial parameter — The CACHE_TTL_SECONDS determines how long data remains cached before automatic expiration. This is our first line of defense against staleness.
Metrics are essential — Tracking cache hits vs. misses reveals whether caching is actually helping. A 10% hit rate indicates a problem; 95%+ is excellent.
Null handling requires thought — What happens when a user doesn't exist? Should we cache that fact, or let every non-existent ID hit the database?
Reads in cache-aside are straightforward—the real complexity lies in handling writes. When data changes in the database, the cache can become stale. Cache-aside offers two primary strategies for handling writes:
Strategy 1: Cache Invalidation (delete on write)
When data is updated in the database, delete the corresponding cache entry. The next read will trigger a cache miss and reload fresh data.
Strategy 2: Cache Update (update on write)
When data is updated in the database, also update the cache entry with the new value. Subsequent reads hit the cache with already-fresh data.
Both strategies have valid use cases, but invalidation is generally preferred in cache-aside due to its simplicity and consistency properties.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
class UserService { /** * Strategy 1: Cache Invalidation (Recommended) * * Order of operations is critical: * 1. Update the database first (source of truth) * 2. Then delete from cache * * This ordering ensures we never have stale data in cache * while database has newer data. At worst, a read between * step 1 and 2 serves old cached data (acceptable). */ async updateUser(userId: string, updates: Partial<User>): Promise<User> { const cacheKey = `user:${userId}`; // Step 1: Update the database (source of truth) const updatedUser = await this.database.query<User>( 'UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *', [userId, updates.name, updates.email] ); // Step 2: Invalidate the cache entry // Even if this fails, TTL will eventually expire the stale entry await this.cache.delete(cacheKey); // Return the fresh data from database return updatedUser; } /** * Strategy 2: Cache Update (Alternative) * * Update both database and cache. Useful when: * - Reads immediately follow writes (user sees their own update) * - Cache reconstruction is expensive * - You can guarantee the update produces identical data to a fresh read */ async updateUserWithCacheUpdate(userId: string, updates: Partial<User>): Promise<User> { const cacheKey = `user:${userId}`; // Step 1: Update the database const updatedUser = await this.database.query<User>( 'UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *', [userId, updates.name, updates.email] ); // Step 2: Update the cache with the same data // Critical: The cached value must exactly match what a fresh read would return await this.cache.set(cacheKey, updatedUser, this.CACHE_TTL_SECONDS); return updatedUser; }}Always update the database before modifying the cache. If you delete from cache first, then fail to update the database, subsequent reads will reload old data from the database into cache—making it appear the update succeeded when it didn't. Database-first ensures correctness at the cost of a brief staleness window.
Cache-aside's explicit control comes with explicit responsibilities—particularly around race conditions. In concurrent systems, multiple operations can interleave in ways that leave the cache inconsistent with the database.
The Classic Read-Write Race:
Consider two concurrent operations: a read (R) and a write (W) for the same key.
Timeline:
R1: Cache miss, query database → gets value A
W1: Update database from A to B
W2: Delete cache entry (succeeds)
R2: Write A to cache (stale!)
The read operation retrieves value A from the database, but before it can write to cache, a write operation updates the database to B and invalidates the cache. Then the read writes A to cache—and now the cache holds stale data.
This is not theoretical—it happens in production, especially under high load where operations take variable time.
| Scenario | Interleaving | Result | Mitigation |
|---|---|---|---|
| Read-Write Race | Read fetches from DB, Write updates DB and invalidates, Read populates cache | Cache holds stale data | Short TTL, read-before-populate check |
| Write-Write Race | Two concurrent updates, both invalidate cache | Usually harmless—cache correctly empty | Database handles actual conflict |
| Read-Read Stampede | Cache expires, 1000 concurrent requests all miss, all query database | Database overwhelmed | Request coalescing, early refresh |
| Double Population | Two reads miss simultaneously, both query DB, both populate | Wasteful but correct—same data written twice | Distributed locks (often overkill) |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
class RobustUserService { /** * Mitigation: Check cache before populating * * After fetching from database, check if cache was populated * by another request while we were waiting. Only populate if still empty. * This reduces (but doesn't eliminate) the read-write race window. */ async getUser(userId: string): Promise<User | null> { const cacheKey = `user:${userId}`; // Initial cache check let user = await this.cache.get<User>(cacheKey); if (user !== null) { return user; } // Cache miss - fetch from database user = await this.database.query<User>( 'SELECT * FROM users WHERE id = $1', [userId] ); if (user === null) { return null; } // Mitigation: Only populate if still not in cache // SET NX = Set if Not eXists (atomic operation) // This prevents overwriting a fresher value that was set while we waited await this.cache.setNX(cacheKey, user, this.CACHE_TTL_SECONDS); return user; } /** * Mitigation: Request coalescing for cache stampedes * * When cache expires, only one request actually fetches from database. * Other concurrent requests wait for that result. */ private inFlightRequests = new Map<string, Promise<User | null>>(); async getUserWithCoalescing(userId: string): Promise<User | null> { const cacheKey = `user:${userId}`; // Check cache first const cached = await this.cache.get<User>(cacheKey); if (cached !== null) { return cached; } // Check if another request is already fetching this user const existingRequest = this.inFlightRequests.get(userId); if (existingRequest) { // Wait for the existing request instead of making a new one return existingRequest; } // We're the first—create the fetch promise const fetchPromise = this.fetchAndCache(userId, cacheKey); this.inFlightRequests.set(userId, fetchPromise); try { return await fetchPromise; } finally { // Clean up after completion this.inFlightRequests.delete(userId); } } private async fetchAndCache(userId: string, cacheKey: string): Promise<User | null> { const user = await this.database.query<User>( 'SELECT * FROM users WHERE id = $1', [userId] ); if (user !== null) { await this.cache.set(cacheKey, user, this.CACHE_TTL_SECONDS); } return user; }}Cache-aside inherently provides eventual consistency, not strong consistency. Brief windows of staleness are acceptable for most applications. Rather than adding complex distributed locking, use short TTLs (5-15 minutes for most data) and accept that rare race conditions will resolve naturally when the TTL expires.
The cache key is deceptively important. A well-designed key schema enables efficient caching, supports wildcard invalidation, and prevents collisions. A poorly designed schema leads to subtle bugs, cache pollution, and invalidation nightmares.
Key Design Principles:
{service}:{entity}:{identifier}:{variant}. This enables targeted invalidation and debugging.v1: or similar. When your data format changes, increment the version to avoid deserializing old formats.user:12345:profile beats u12345p.user:12345:* enable pattern-based deletion.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
/** * Centralized cache key generation * * Benefits: * - Consistent key format across entire application * - Easy to add versioning, prefixes, or namespaces * - Single place to audit key patterns * - Enables key-based invalidation strategies */class CacheKeyFactory { private readonly namespace: string; private readonly version: string; constructor(namespace: string = 'app', version: string = 'v1') { this.namespace = namespace; this.version = version; } // Single entity by ID user(userId: string): string { return `${this.namespace}:${this.version}:user:${userId}`; } // Entity sub-resource userProfile(userId: string): string { return `${this.namespace}:${this.version}:user:${userId}:profile`; } userPreferences(userId: string): string { return `${this.namespace}:${this.version}:user:${userId}:preferences`; } // Collection or list userOrders(userId: string, page: number = 1): string { return `${this.namespace}:${this.version}:user:${userId}:orders:page:${page}`; } // Query-based cache (parametric) productSearch(query: string, filters: ProductFilters): string { // Normalize and hash complex query parameters const normalizedQuery = query.toLowerCase().trim(); const filterHash = this.hashFilters(filters); return `${this.namespace}:${this.version}:search:products:${normalizedQuery}:${filterHash}`; } // Pattern for wildcard invalidation userPattern(userId: string): string { return `${this.namespace}:${this.version}:user:${userId}:*`; } allUsersPattern(): string { return `${this.namespace}:${this.version}:user:*`; } private hashFilters(filters: ProductFilters): string { // Sort keys for deterministic ordering const sortedKeys = Object.keys(filters).sort(); const normalized = sortedKeys.map(k => `${k}=${filters[k]}`).join('&'); // Use a simple hash for the key—full value for debugging return this.simpleHash(normalized); } private simpleHash(str: string): string { // Simple hash function for key generation let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash).toString(36); }} // Usageconst keys = new CacheKeyFactory('myapp', 'v2'); // Generates: myapp:v2:user:12345const userKey = keys.user('12345'); // Generates: myapp:v2:user:12345:profileconst profileKey = keys.userProfile('12345'); // For invalidation: myapp:v2:user:12345:*const userPattern = keys.userPattern('12345');Never include mutable data like timestamps or session IDs in cache keys for entity data. The key user:12345:session:abc123 will never hit again after the session changes. Similarly, avoid including version-varying data like 'format=v2' in keys—instead, version the entire key prefix.
A subtle but important optimization in cache-aside is negative caching—caching the fact that something doesn't exist. Without negative caching, repeated requests for non-existent data hit the database every time.
The Problem:
Consider a user lookup for ID 99999 that doesn't exist. Without negative caching:
Each request hits the database. For high-traffic systems, this becomes a significant load, especially if an attacker intentionally queries non-existent IDs.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
interface CachedUser { exists: true; user: User;} | { exists: false; // No user data—this is a cached negative result} class UserServiceWithNegativeCaching { private readonly POSITIVE_TTL = 3600; // 1 hour for existing users private readonly NEGATIVE_TTL = 300; // 5 minutes for non-existent users async getUser(userId: string): Promise<User | null> { const cacheKey = `user:${userId}`; // Check cache—may contain positive or negative result const cached = await this.cache.get<CachedUser>(cacheKey); if (cached !== null) { if (cached.exists) { return cached.user; // Positive cache hit } else { return null; // Negative cache hit—we know this user doesn't exist } } // Cache miss—query database const user = await this.database.query<User>( 'SELECT * FROM users WHERE id = $1', [userId] ); if (user !== null) { // Cache the positive result await this.cache.set(cacheKey, { exists: true, user }, this.POSITIVE_TTL); return user; } else { // Cache the negative result (shorter TTL) await this.cache.set(cacheKey, { exists: false }, this.NEGATIVE_TTL); return null; } }} /** * Alternative: Sentinel value approach * * Instead of wrapping in {exists: true/false}, use a sentinel value * like a special string or empty object to represent "not found" */class UserServiceSentinel { private readonly NOT_FOUND_SENTINEL = '__NOT_FOUND__'; private readonly NEGATIVE_TTL = 300; async getUser(userId: string): Promise<User | null> { const cacheKey = `user:${userId}`; const cached = await this.cache.get<User | string>(cacheKey); if (cached === this.NOT_FOUND_SENTINEL) { return null; // Negative cache hit } if (cached !== null) { return cached as User; // Positive cache hit } // Cache miss const user = await this.database.query<User>( 'SELECT * FROM users WHERE id = $1', [userId] ); if (user !== null) { await this.cache.set(cacheKey, user, this.POSITIVE_TTL); } else { await this.cache.set(cacheKey, this.NOT_FOUND_SENTINEL, this.NEGATIVE_TTL); } return user; }}Negative cache entries should expire faster than positive ones (5 minutes vs. 1 hour). Why? If a user is created shortly after a negative lookup, we don't want to wait an hour before they appear. Short negative TTLs balance protection from repeated misses against responsiveness to new data.
Cache-aside isn't universally optimal—no caching pattern is. But it's the best choice in many scenarios:
Cache-aside is ideal when:
| Factor | Favors Cache-Aside | Favors Alternatives |
|---|---|---|
| Read/Write Ratio | High (90%+ reads) | Low (frequent writes) |
| Consistency Requirement | Eventual consistency OK | Strong consistency required |
| Data Source | Multiple or complex sources | Single database |
| Invalidation Logic | Complex, business-driven | Simple, automatic |
| Infrastructure | Separate cache and DB | Integrated caching layer |
| Cold Start Tolerance | Acceptable miss penalty | Must be pre-warmed |
The cache-aside pattern is the workhorse of production caching. Its explicit, application-controlled nature provides flexibility and transparency at the cost of requiring careful implementation.
Key Takeaways:
What's Next:
In the next page, we'll explore the read-through cache pattern, where the cache itself intercepts read requests and manages the underlying data store. This shifts complexity from application code to infrastructure, offering different tradeoffs that may better suit certain architectures.
You now have a deep understanding of the cache-aside pattern—its mechanics, implementation strategies, consistency challenges, and optimal use cases. This foundation prepares you for the remaining cache patterns and for making informed decisions about caching strategies in production systems.