Back to the journalNOTES BY FAJAR
Software Engineering9 min read

Caching Strategies: Cache-Aside to Write-Behind

Compare cache patterns by their read path, write path, failure behavior, and risk of stale data.

In this article 11 sections

A cache strategy determines how an application reads, writes, and refreshes cached data. I used to describe caching as "put stuff in Redis so your app goes faster." I later learned how the strategy affects consistency, latency, and write throughput.

Why Caching Matters

Every time your application talks to a database, there is 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.

The application needs a rule for how the cache and database interact.

The Five Main Strategies

The main strategies are:

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

1. Cache Aside (Lazy Loading)

With cache aside, the application manages cache reads, population, and invalidation.

mermaid
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.

typescript
// 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 is requested (no wasted memory)
  • Application has full control over cache logic
  • Cache failure does not break the app (just slower)

Cons:

  • First request for any key is always a cache miss (cold start)
  • Risk of stale data if cache is not 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. Use it as a 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.

mermaid
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
typescript
// 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. It is 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.

mermaid
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
typescript
// 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:

  • A successful write updates both stores when all writers use this path and the implementation handles partial failures
  • Coordinated writes can reduce stale reads, but concurrency and failure handling still require a defined consistency policy
  • A later read can use the cached value unless the entry expires or the cache evicts it

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)

Write behind absorbs writes immediately and flushes them to the database asynchronously in batches. The application gets a fast write confirmation without waiting for the database.

mermaid
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
typescript
// 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:

  • Lower write latency (the application does not wait for the DB)
  • Batch writes can reduce database load
  • Useful 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.

A possible alternative for batch ingestion

At Bank BTPN, we had a debit card reconciliation system that received data from 16 different sources. Each source contained 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 we imported data from several sources at the same time.

A possible alternative was to buffer the records before database writes:

  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. Separated ingestion from database writes so that all 16 sources could submit work independently, within the buffer capacity

We eventually used Kafka and consumer groups to address the throughput problem. The cache alternative would require durable buffering and a recovery process for accepted reconciliation records. It would also need a workload test before we could compare its capacity.

5. Write Around

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

mermaid
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. Do not populate the cache on a write. Invalidate an existing entry if the read path requires fresh data.
  3. On a cache miss, read the current value from the database.
typescript
// 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 does not get polluted with data that may never be read
  • Simple write path
  • Good for write heavy data that is rarely queried immediately

Cons:

  • A read after a write may need a database request when no valid cached entry exists
  • 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 do not want your cache filled with log entries that nobody looks at.

Choosing the Right Strategy

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

The following table groups use cases, but the pattern name alone does not guarantee consistency. Define the allowed staleness, invalidation behavior, and concurrent write handling. The Azure cache aside guidance explains this limitation:

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

The decision flowchart is:

mermaid
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 is the simplest, most understood pattern. Only move to something else when you hit a specific problem.
  • If you need strong consistency: define atomicity, concurrent access, and failure handling for the complete read and write path. A cache pattern alone is insufficient.
  • 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. Do not 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 is no rule that says your whole system has to use one pattern.

Common Pitfalls

I check these failure cases when I choose a cache strategy:

1. Define invalidation. Set a time to live (TTL) that matches the allowed staleness. Expiration limits stale data duration but does not guarantee immediate consistency.

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

Choose a strategy from the read and write requirements. Then test cache misses, concurrent writes, expiration, and recovery. These cases determine whether the cache meets the required consistency and latency.

Contact: [email protected].

FILED UNDER

THANKS FOR READING

Did this resonate?

A reaction or a conversation is always welcome.

Loading reactions…

Pass it along

Loading comments...

Related posts

All writing