System Design/
Lesson

Knowing the types of caches is only half the picture. The other half is how data flows between your cache and your database. When a user requests data, who checks the cache? When data changes, who updates the cache? These questions define your caching strategy, and choosing the wrong one leads to stale reads, inconsistent writes, or unnecessary complexity.

There are four fundamental strategies. Real systems often combine two or more of them, but understanding each one in isolation makes the combinations obvious.

Cache-aside (lazy loadingWhat is lazy loading?Deferring the loading of a resource like an image or component until the moment it's actually needed, speeding up the initial page load.)

Cache-aside is the most widely used pattern because it is simple, explicit, and gives your application full control. The flow is:

  1. Application checks the cache for the requested key
  2. If found (hit), return the cached data
  3. If not found (miss), query the database
  4. Store the database result in the cache
  5. Return the data to the caller

The "aside" in the name refers to the fact that the cache sits to the side, the application talks to both the cache and the database directly.

// Cache-aside pattern
async function getProduct(productId) {
  const cacheKey = `product:${productId}`;

  // Step 1: Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached); // Step 2: Cache hit
  }

  // Step 3: Cache miss - query database
  const product = await db.query(
    'SELECT * FROM products WHERE id = ?',
    [productId]
  );

  if (product) {
    // Step 4: Populate cache for next time
    await redis.setex(cacheKey, 300, JSON.stringify(product)); // 5 min TTL
  }

  // Step 5: Return data
  return product;
}

Advantages: simple to implement, cache only contains data that has actually been requested (no wasted memory), works with any cache and any database.

Disadvantages: the first request for any piece of data is always a cache miss (cold startWhat is cold start?The delay that occurs when a serverless function runs for the first time after being idle. The cloud provider needs to spin up a new container, which adds latency.), and the application must manage both cache and database logic. If the database changes outside your application (e.g., a migrationWhat is migration?A versioned script that changes your database structure (add a column, create a table) so every developer and server stays in sync. script updates prices), the cache will serve stale data until the TTLWhat is ttl?Time-to-Live - a countdown attached to cached data that automatically expires it after a set number of seconds. expires.

02

Read-through

Read-through looks similar to cache-aside from the application's perspective, but the cache itself is responsible for fetching from the database on a miss. The application only talks to the cache, it never queries the database directly.

// Read-through: the cache library handles misses automatically
// Using a hypothetical cache library with a loader function
const productCache = new ReadThroughCache({
  ttl: 300,
  loader: async (key) => {
    // This function is called automatically on a cache miss
    const productId = key.replace('product:', '');
    return db.query('SELECT * FROM products WHERE id = ?', [productId]);
  }
});

// Application code is simpler - just ask the cache
async function getProduct(productId) {
  return productCache.get(`product:${productId}`);
  // Cache handles: check → miss → load from DB → store → return
}

Advantages: simpler application code (single data source to talk to), cache population logic is centralized instead of scattered across your codebase.

Disadvantages: tighter coupling between the cache and the database, harder to debug when things go wrong because the cache is doing more behind the scenes. Less common in practice than cache-aside because most teams prefer explicit control.

03

Write-through

Write-through writes data to both the cache and the database synchronously. The write is not considered complete until both stores have been updated. This guarantees that the cache always has the latest data.

// Write-through pattern
async function updateProduct(productId, updates) {
  const cacheKey = `product:${productId}`;

  // Write to database first
  await db.query(
    'UPDATE products SET name = ?, price = ? WHERE id = ?',
    [updates.name, updates.price, productId]
  );

  // Then update the cache (synchronously, before returning)
  const updatedProduct = { id: productId, ...updates };
  await redis.setex(cacheKey, 300, JSON.stringify(updatedProduct));

  return updatedProduct;
}

Advantages: the cache is always consistent with the database, so read-after-write always returns the latest data. No stale reads.

Disadvantages: every write takes longer because you are writing to two places. If the cache write fails after the database write succeeds (or vice versa), you have an inconsistency. You also cache data that might never be read, wasting memory.

04

Write-behind (write-back)

Write-behind inverts the priority: write to the cache first, return immediately to the caller, and asynchronously sync the change to the database in the background.

// Write-behind pattern
const writeQueue = []; // In production, use a proper message queue

async function updateProduct(productId, updates) {
  const cacheKey = `product:${productId}`;
  const updatedProduct = { id: productId, ...updates };

  // Write to cache immediately (fast)
  await redis.setex(cacheKey, 300, JSON.stringify(updatedProduct));

  // Queue the database write for async processing
  writeQueue.push({
    query: 'UPDATE products SET name = ?, price = ? WHERE id = ?',
    params: [updates.name, updates.price, productId],
    timestamp: Date.now(),
  });

  return updatedProduct; // Return immediately - user doesn't wait for DB
}

// Background worker processes the queue
async function processWriteQueue() {
  while (writeQueue.length > 0) {
    const job = writeQueue.shift();
    try {
      await db.query(job.query, job.params);
    } catch (error) {
      console.error('Write-behind failed:', error);
      writeQueue.push(job); // Retry later
    }
  }
}

setInterval(processWriteQueue, 1000); // Flush every second

Advantages: writes are extremely fast from the user's perspective (cache is in-memory), and you can batch multiple writes to the database for efficiency.

Disadvantages: data loss risk. If the cache crashes before the background syncWhat is background sync?A Service Worker API that queues network requests made while offline and replays them automatically when connectivity is restored. completes, those writes are gone. This pattern requires a durable queue (like Kafka or RabbitMQ) in production, the in-memory array above is just for illustration. Also, the database will temporarily lag behind the cache, so any system reading directly from the database will see stale data.

05

Strategy comparison

StrategyRead latencyWrite latencyConsistencyData loss riskComplexity
Cache-asideHit: fast, Miss: slow (DB + cache write)N/A (no write caching)Eventual (stale until TTL)NoneLow
Read-throughHit: fast, Miss: slow (transparent)N/A (no write caching)Eventual (stale until TTL)NoneMedium
Write-throughAlways fast (cache is fresh)Slow (writes to both)Strong (always in sync)Low (dual write)Medium
Write-behindAlways fast (cache is fresh)Fast (cache only, async DB)Eventual (DB lags cache)High (cache crash = lost writes)High
06

When to use each strategy

Cache-aside is the safe default. Use it when your application is read-heavy (which most are), you want explicit control, and you can tolerate brief staleness. This covers 80% of caching needs.

Read-through works well when you want to centralize cache miss logic and your cache library supports it. It is essentially cache-aside with the miss-handling moved into the cache layer.

Write-through is the right choice when read-after-write consistency matters, for example, a user updates their profile and immediately sees the updated version. Pair it with cache-aside for reads.

Write-behind is for write-heavy workloads where you need to absorb burst writes without hammering the database. Analytics ingestion, view counters, and activity feeds are good candidates. You must have a durable queue and accept the risk of data loss on cache failure.

07

Combining strategies

Most production systems use cache-aside for reads and write-through for writes. This gives you lazy cache population (no wasted memory) with strong read-after-write consistency.

// Combined: cache-aside for reads, write-through for writes
async function getProduct(productId) {
  const cacheKey = `product:${productId}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const product = await db.query('SELECT * FROM products WHERE id = ?', [productId]);
  if (product) await redis.setex(cacheKey, 300, JSON.stringify(product));
  return product;
}

async function updateProduct(productId, updates) {
  await db.query('UPDATE products SET name = ?, price = ? WHERE id = ?',
    [updates.name, updates.price, productId]);
  // Immediately update cache so the next read sees fresh data
  const updated = await db.query('SELECT * FROM products WHERE id = ?', [productId]);
  await redis.setex(`product:${productId}`, 300, JSON.stringify(updated));
  return updated;
}

An alternative is cache-aside for reads with cache invalidationWhat is cache invalidation?Removing or updating cached data when the original data changes, so users never see outdated information. (delete) on writes. Instead of updating the cache on write, you delete the cache key, and the next read will repopulate it from the database. This is simpler and avoids the risk of the cache and database having different data shapes.

async function updateProduct(productId, updates) {
  await db.query('UPDATE products SET name = ?, price = ? WHERE id = ?',
    [updates.name, updates.price, productId]);
  // Delete from cache - next read will fetch fresh from DB
  await redis.del(`product:${productId}`);
}

The delete-on-write approach is often preferred because it is simpler and less error-prone. The tradeoff is one extra cache miss after every write.

AI pitfall
When you ask AI to implement caching, it almost always generates cache-aside (lazy loading) code. This is fine for reads, but AI rarely warns you about the race condition: if two requests both miss the cache simultaneously, they both hit the database and both write to the cache. For most apps this is harmless, but for expensive queries, it causes unnecessary database load.
Good to know
Write-through caching sounds safe ("always update the cache and DB together") but it adds latency to every write, and you are paying to cache data that may never be read. Use write-through only for data you know will be read frequently after being written, like a user profile that is displayed on every page.