Software Engineering

Understanding Caching Strategies: From Cache-Aside to Write-Behind

9 min read
CachingSystem DesignSoftware EngineeringBackendPerformance

I've been studying caching strategies lately, and honestly, I used to think caching was just "put stuff in Redis so your app goes faster." Turns out there's a whole spectrum of patterns, each with different tradeoffs around consistency, latency, and write throughput. Some of these would have saved me a lot of pain in previous projects if I'd known about them earlier.

Here's what I've learned so far.

Why Caching Matters

Every time your application talks to a database, there's a cost: network latency, disk I/O, connection pool pressure, query execution time. When you have thousands of requests per second, those costs add up fast. Caching puts a faster storage layer (usually in-memory, like Redis or Memcached) between your application and the database so you can serve data without hitting the slow path every time.

But the question isn't just "should I cache?" It's how should the cache interact with the database? That's where caching strategies come in.

The Five Main Strategies

Here's a quick overview before we go deep:

StrategyRead PathWrite PathBest For
Cache-AsideApp checks cache, falls back to DBApp writes to DB, invalidates cacheGeneral purpose, read-heavy
Read-ThroughCache fetches from DB on missN/A (read-only pattern)Simplifying read logic
Write-ThroughPaired with read-throughCache writes to DB synchronouslyStrong consistency needed
Write-BehindPaired with read-throughCache writes to DB asynchronouslyHigh write throughput
Write-AroundCache-aside for readsApp writes directly to DB, skips cacheWrite-heavy, rarely re-read

Now let's break each one down.

1. Cache-Aside (Lazy Loading)

This is the most common pattern and probably the one you've already used without knowing the name. The application is responsible for all cache management.

sequenceDiagram
    participant App
    participant Cache
    participant DB
    App->>Cache: GET key
    alt Cache Hit
        Cache-->>App: Return data
    else Cache Miss
        Cache-->>App: null
        App->>DB: SELECT * FROM table
        DB-->>App: Return data
        App->>Cache: SET key = data
    end

How it works:

  1. Application checks the cache first
  2. If the data is there (cache hit), return it
  3. If not (cache miss), fetch from the database
  4. Store the result in cache for next time

On writes, the application writes directly to the database and then either invalidates the cache entry or updates it.

// Cache-Aside: Read
async function getUser(userId: string): Promise<User> {
  // Step 1: Check cache
  const cached = await redis.get(`user:${userId}`);
  if (cached) {
    return JSON.parse(cached);
  }

  // Step 2: Cache miss, fetch from DB
  const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);

  // Step 3: Populate cache for next time
  await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);

  return user;
}

// Cache-Aside: Write
async function updateUser(userId: string, data: Partial<User>): Promise<void> {
  // Write to DB first
  await db.query("UPDATE users SET name = $1 WHERE id = $2", [
    data.name,
    userId,
  ]);

  // Invalidate cache (next read will repopulate)
  await redis.del(`user:${userId}`);
}

Pros:

  • Simple to implement
  • Only caches data that's actually requested (no wasted memory)
  • Application has full control over cache logic
  • Cache failure doesn't break the app (just slower)

Cons:

  • First request for any key is always a cache miss (cold start)
  • Risk of stale data if cache isn't properly invalidated on writes
  • Application code has to manage both cache and database

When to use it: read-heavy workloads where you can tolerate the occasional cache miss. This is your default starting point.

2. Read-Through

Similar to cache-aside, but the cache itself is responsible for fetching data from the database on a miss. The application only talks to the cache, never directly to the database for reads.

sequenceDiagram
    participant App
    participant Cache
    participant DB
    App->>Cache: GET key
    alt Cache Hit
        Cache-->>App: Return data
    else Cache Miss
        Cache->>DB: Fetch data
        DB-->>Cache: Return data
        Cache->>Cache: Store data
        Cache-->>App: Return data
    end

How it works:

  1. Application asks the cache for data
  2. If cached, return immediately
  3. If not, the cache (not the application) fetches from the database, stores it, and returns it
// Read-Through: The cache layer handles DB fetching
class ReadThroughCache {
  private redis: Redis;
  private db: Database;

  async get(key: string, loader: () => Promise<unknown>): Promise<unknown> {
    const cached = await this.redis.get(key);
    if (cached) {
      return JSON.parse(cached);
    }

    // Cache handles the DB fetch
    const data = await loader();
    await this.redis.set(key, JSON.stringify(data), "EX", 3600);
    return data;
  }
}

// Application code becomes simpler
const cache = new ReadThroughCache(redis, db);
const user = await cache.get(`user:${userId}`, () =>
  db.query("SELECT * FROM users WHERE id = $1", [userId]),
);

Pros:

  • Application code is cleaner (no cache management logic scattered around)
  • Consistent caching behavior across the app

Cons:

  • First request is still a cache miss
  • Tighter coupling between cache and data source
  • Cache layer becomes more complex

When to use it: when you want to centralize your caching logic instead of repeating cache-aside patterns everywhere. This is basically cache-aside with better separation of concerns.

3. Write-Through

The cache sits in front of the database for writes too. Every write goes to the cache first, and the cache synchronously writes it to the database before confirming.

sequenceDiagram
    participant App
    participant Cache
    participant DB
    App->>Cache: Write data
    Cache->>DB: Write data (sync)
    DB-->>Cache: Confirm
    Cache-->>App: Confirm

How it works:

  1. Application writes to the cache
  2. Cache immediately writes the same data to the database
  3. Cache confirms the write only after the database confirms
// Write-Through: Cache handles both cache + DB write
class WriteThroughCache {
  async set(
    key: string,
    value: unknown,
    dbWriter: () => Promise<void>,
  ): Promise<void> {
    // Write to DB first (synchronous)
    await dbWriter();

    // Then update cache
    await this.redis.set(key, JSON.stringify(value), "EX", 3600);
  }
}

// Usage
await cache.set(`user:${userId}`, updatedUser, () =>
  db.query("UPDATE users SET name = $1 WHERE id = $2", [
    updatedUser.name,
    userId,
  ]),
);

Pros:

  • Cache and database are always in sync (strong consistency)
  • No stale data risk
  • Reads after writes always hit the cache (no cold miss)

Cons:

  • Higher write latency (every write waits for both cache AND database)
  • Not ideal for write-heavy workloads

When to use it: when data consistency is critical and you can accept higher write latency. Pair this with read-through for a complete caching solution.

4. Write-Behind (Write-Back)

This is the one that made me wish I'd known about it sooner. The cache absorbs writes immediately and flushes them to the database asynchronously in batches. The application gets a fast write confirmation without waiting for the database.

sequenceDiagram
    participant App
    participant Cache
    participant Queue
    participant DB
    App->>Cache: Write data
    Cache-->>App: Confirm (immediate)
    Cache->>Queue: Queue for DB write
    Queue->>DB: Batch write (async)
    DB-->>Queue: Confirm

How it works:

  1. Application writes to the cache
  2. Cache confirms immediately (fast!)
  3. In the background, the cache queues the write
  4. A background process flushes queued writes to the database in batches
// Write-Behind: Buffer writes and flush in batches
class WriteBehindCache {
  private writeBuffer: Map<string, { value: unknown; timestamp: number }> =
    new Map();
  private flushInterval: NodeJS.Timer;

  constructor(
    private redis: Redis,
    private db: Database,
  ) {
    // Flush every 5 seconds
    this.flushInterval = setInterval(() => this.flush(), 5000);
  }

  async set(key: string, value: unknown): Promise<void> {
    // Write to cache immediately
    await this.redis.set(key, JSON.stringify(value), "EX", 3600);

    // Buffer for async DB write
    this.writeBuffer.set(key, { value, timestamp: Date.now() });
  }

  private async flush(): Promise<void> {
    if (this.writeBuffer.size === 0) return;

    const entries = Array.from(this.writeBuffer.entries());
    this.writeBuffer.clear();

    // Batch write to database
    const batch = entries.map(([key, { value }]) => this.writeToDB(key, value));

    try {
      await Promise.all(batch);
    } catch (error) {
      // Re-queue failed writes
      entries.forEach(([key, entry]) => {
        this.writeBuffer.set(key, entry);
      });
      console.error("Write-behind flush failed, entries re-queued:", error);
    }
  }

  private async writeToDB(key: string, value: unknown): Promise<void> {
    // Extract entity type and ID from key pattern
    const [entity, id] = key.split(":");
    await this.db.query(
      `INSERT INTO ${entity} VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET data = $2`,
      [id, value],
    );
  }
}

Pros:

  • Extremely fast writes (application doesn't wait for DB)
  • Batch writes reduce database load significantly
  • Great for absorbing write spikes

Cons:

  • Risk of data loss: if the cache crashes before flushing, buffered writes are gone
  • Eventual consistency (cache and DB may be temporarily out of sync)
  • More complex to implement correctly (error handling, retry logic, ordering)

When to use it: high write throughput scenarios where you can tolerate a small window of potential data loss. Think logging, metrics, analytics, or any situation where writes vastly outnumber reads.

Where Write-Behind Would Have Saved Me

Looking back at my time at Bank BTPN, we had a debit card reconciliation system that ingested data from 16 different sources, each containing anywhere from 30,000 to 600,000 records. The system needed to process these files daily for back-office operations.

At the time, we processed records more or less synchronously: read a batch from the SFTP file, validate, write to the database, repeat. The database became the bottleneck when multiple sources were being ingested simultaneously.

If I'd known about write-behind at the time, we could have:

  1. Buffered incoming records in Redis as they were parsed from SFTP files
  2. Flushed to the database in optimized batches (bulk inserts instead of individual writes)
  3. Decoupled the ingestion speed from database write speed, letting the system absorb all 16 sources concurrently without hammering the DB

We eventually moved to an event-driven architecture with Kafka, which solved the throughput problem in a different way (message queue + consumer groups). But for teams that don't want to introduce a full message broker, write-behind caching can solve a similar problem with less infrastructure.

5. Write-Around

The application writes directly to the database, completely bypassing the cache. The cache is only populated on reads (via cache-aside or read-through).

sequenceDiagram
    participant App
    participant Cache
    participant DB
    App->>DB: Write data (bypass cache)
    DB-->>App: Confirm
    Note over Cache: Cache not updated
    App->>Cache: Later: GET key (cache miss)
    Cache-->>App: null
    App->>DB: Fetch data
    DB-->>App: Return data
    App->>Cache: Populate cache

How it works:

  1. Application writes directly to the database
  2. Cache is not updated or invalidated
  3. Next read for that key will be a cache miss, fetching fresh data from DB
// Write-Around: Write to DB only, let cache populate on next read
async function createLogEntry(entry: LogEntry): Promise<void> {
  // Write directly to DB, skip cache entirely
  await db.query("INSERT INTO logs VALUES ($1, $2, $3)", [
    entry.id,
    entry.message,
    entry.timestamp,
  ]);
  // Cache is not touched. If someone reads this entry later,
  // cache-aside will populate it.
}

Pros:

  • Cache doesn't get polluted with data that may never be read
  • Simple write path
  • Good for write-heavy data that's rarely queried immediately

Cons:

  • Higher read latency for recently written data (guaranteed cache miss)
  • Not suitable if you read-after-write frequently

When to use it: when written data is rarely read back immediately. Audit logs, historical records, event streams. You don't want your cache filled with log entries that nobody looks at.

Choosing the Right Strategy

There's no universal answer. It depends on your read/write ratio, consistency requirements, and tolerance for complexity.

Here's a quick-reference table for common scenarios:

ScenarioExampleCaching Strategy
Strong ConsistencyFinancial systems, stock managementCache-Aside, Read-Through
Read-Heavy WorkloadsPopular news on websitesCache-Aside, Read-Through
Write-Heavy WorkloadsSystems with frequent updatesWrite-Around, Write-Behind
Data FreshnessStock trading platformsWrite-Through, Read-Through
Static DataCSS files, images, CDN contentCache-Aside
Frequently Accessed DataE-commerce stock updatesWrite-Through

And here's a decision flowchart if you prefer to think in terms of tradeoffs:

graph TD
    A[What's your workload?] --> B{Read-heavy?}
    B -->|Yes| C{Need simple code?}
    C -->|Yes| D[Cache-Aside]
    C -->|No| E[Read-Through]
    B -->|No| F{Write-heavy?}
    F -->|Yes| G{Tolerate data loss risk?}
    G -->|Yes| H[Write-Behind]
    G -->|No| I{Data rarely re-read?}
    I -->|Yes| J[Write-Around]
    I -->|No| K[Write-Through]
    F -->|Balanced| L{Consistency critical?}
    L -->|Yes| M[Write-Through + Read-Through]
    L -->|No| N[Cache-Aside]

Rules of thumb:

  • Start with cache-aside. It's the simplest, most understood pattern. Only move to something else when you hit a specific problem.
  • If you need strong consistency: write-through + read-through. Every write updates both cache and DB. Every read hits the cache.
  • If you need high write throughput: write-behind. But understand the data loss risk and build retry/recovery mechanisms.
  • If most writes are never re-read: write-around. Don't pollute your cache with data nobody asks for.
  • You can combine strategies. Use cache-aside for most things, write-behind for high-throughput ingestion, and write-around for audit logs. There's no rule that says your whole system has to use one pattern.

Common Pitfalls

A few things I've learned (some the hard way, some from reading about other people's hard way):

1. Cache invalidation is still the hardest problem. No matter which strategy you pick, getting invalidation right is where bugs hide. When in doubt, set a TTL and let entries expire naturally.

2. Thundering herd on cache miss. When a popular cache key expires, hundreds of requests might simultaneously miss the cache and all hit the database. Solutions: cache warming, request coalescing (only one request fetches, others wait), or staggered TTLs.

3. Serialization overhead. JSON.stringify/parse on every cache operation adds up. Consider using MessagePack or Protocol Buffers for frequently accessed, large objects.

4. Cache size management. An unbounded cache will eat your memory. Use LRU eviction policies, set max memory limits, and monitor hit rates.

Closing Thoughts

Caching isn't just "throw Redis at it." Each strategy makes a deliberate tradeoff between read latency, write latency, consistency, and complexity. Understanding these tradeoffs means you can make an informed choice instead of discovering the wrong one in production at 3 AM.

I'm still learning this stuff, and I'm sure there are edge cases and nuances I haven't run into yet. If you have war stories about caching strategies (especially write-behind in production), I'd love to hear them. Drop me a line at [email protected].

Share:
Loading reactions...

Loading comments...