StackPractices
advanced By Mathias Paulenko

Write-Behind Cache Pattern

Write to the cache synchronously and persist to the database asynchronously for high-throughput write workloads with eventual consistency.

Overview

In a write-behind cache (also called write-back), writes land in the cache synchronously and reach the database asynchronously. The application writes to the cache, gets an immediate acknowledgment, and a background worker flushes the pending changes to the database in batches.

This decouples write latency from database performance. The cache absorbs writes at memory speed, and the database receives consolidated batches at a controlled rate, which smooths load spikes and raises total throughput. It’s the same trick databases pull with write-ahead logs and buffer pools, moved up one layer into your application.

The trade-off is eventual consistency plus a data-loss window: anything sitting in the dirty set when the cache dies is gone. How wide that window is depends on your flush interval and, just as important, on whether the dirty set itself survives a restart.

When to Use

  • Write-heavy workloads where write latency has to stay low: counters, telemetry, activity feeds
  • The database can’t keep up with the peak write rate, but can keep up with the average
  • You can tolerate a few seconds of divergence between cache and database
  • Batch updates are cheaper than row-by-row writes, usually
  • You’ve got a recovery story for cache failures: Redis AOF, replication, or replayable upstream events

Skip write-behind when other services read the same data straight from the database (they’ll see stale rows), when losing a few seconds of writes is unacceptable (payments, inventory, auth state), or when write volume is modest; the extra machinery buys you nothing. If you need the simpler synchronous guarantee, use the Write-Through Cache Pattern instead.

Solution

Python with Redis and a Durable Dirty Set

import redis
import json
import threading
import time

class WriteBehindCache:
    """Dirty keys live in a Redis set, so a process crash doesn't lose the flush queue."""

    DIRTY_SET = "writebehind:dirty"
    FLUSH_SET = "writebehind:flushing"

    def __init__(self, redis_client: redis.Redis, db_writer: callable, flush_interval: float = 5.0):
        self.redis = redis_client
        self.db_writer = db_writer
        self.flush_interval = flush_interval
        self._running = True
        self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
        self._flush_thread.start()

    def write(self, key: str, value: dict, ttl: int = 3600) -> dict:
        # Pipeline the value write and the dirty-key mark into one round trip
        pipe = self.redis.pipeline()
        pipe.set(key, json.dumps(value), ex=ttl)
        pipe.sadd(self.DIRTY_SET, key)
        pipe.execute()
        return value

    def _flush_loop(self):
        while self._running:
            time.sleep(self.flush_interval)
            self._flush()

    def _flush(self):
        try:
            # Atomically take the current dirty set; writes during the flush
            # land in a fresh DIRTY_SET and are picked up next cycle
            self.redis.rename(self.DIRTY_SET, self.FLUSH_SET)
        except redis.ResponseError:
            return  # no dirty keys

        keys = self.redis.smembers(self.FLUSH_SET)
        batch = {}
        for key in keys:
            raw = self.redis.get(key)
            if raw is not None:
                batch[key] = json.loads(raw)

        try:
            if batch:
                self.db_writer(list(batch.values()))
        except Exception:
            # Merge the failed keys back so they're retried next cycle
            self.redis.sunionstore(self.DIRTY_SET, self.DIRTY_SET, self.FLUSH_SET)
            self.redis.delete(self.FLUSH_SET)
            raise

        self.redis.delete(self.FLUSH_SET)

    def flush_now(self):
        self._flush()

    def shutdown(self):
        self._running = False
        self._flush_thread.join(timeout=10)
        self._flush()


def batch_upsert_users(users: list[dict]):
    """One multi-row upsert instead of N single-row updates."""
    if not users:
        return
    db.executemany(
        """
        INSERT INTO users (id, name, email)
        VALUES (%s, %s, %s)
        ON CONFLICT (id) DO UPDATE
        SET name = EXCLUDED.name, email = EXCLUDED.email
        """,
        [(u["id"], u["name"], u["email"]) for u in users],
    )


cache = WriteBehindCache(
    redis.Redis(host="localhost", port=6379),
    db_writer=batch_upsert_users,
    flush_interval=2.0,
)

def update_user(user_id: str, name: str, email: str) -> dict:
    user = {"id": user_id, "name": name, "email": email}
    return cache.write(f"user:{user_id}", user, ttl=3600)

TypeScript with Redis and a Durable Dirty Set

import { createClient } from 'redis';

const DIRTY_SET = 'writebehind:dirty';
const FLUSH_SET = 'writebehind:flushing';

class WriteBehindCache {
  private flushTimer: NodeJS.Timeout | null = null;

  constructor(
    private client: ReturnType<typeof createClient>,
    private dbWriter: (batch: Array<{ key: string; value: any }>) => Promise<void>,
    private flushIntervalMs = 5000
  ) {
    this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
  }

  async write<T>(key: string, value: T, ttl = 3600): Promise<T> {
    await this.client
      .multi()
      .set(key, JSON.stringify(value), { EX: ttl })
      .sAdd(DIRTY_SET, key)
      .exec();
    return value;
  }

  async flush(): Promise<void> {
    // RENAME is atomic: concurrent writes create a fresh DIRTY_SET
    try {
      await this.client.rename(DIRTY_SET, FLUSH_SET);
    } catch {
      return; // no dirty keys
    }

    const keys = await this.client.sMembers(FLUSH_SET);
    const batch: Array<{ key: string; value: any }> = [];
    for (const key of keys) {
      const raw = await this.client.get(key);
      if (raw !== null) batch.push({ key, value: JSON.parse(raw) });
    }

    try {
      if (batch.length > 0) await this.dbWriter(batch);
      await this.client.del(FLUSH_SET);
    } catch (error) {
      // Merge failed keys back into DIRTY_SET for the next cycle
      await this.client.sUnionStore(DIRTY_SET, [DIRTY_SET, FLUSH_SET]);
      await this.client.del(FLUSH_SET);
      throw error;
    }
  }

  async shutdown(): Promise<void> {
    if (this.flushTimer) clearInterval(this.flushTimer);
    await this.flush();
  }
}

// Usage
const redisClient = createClient({ url: 'redis://localhost:6379' });
await redisClient.connect();

const cache = new WriteBehindCache(
  redisClient,
  async (batch) => {
    const values = batch.flatMap((b) => [b.value.id, b.value.name, b.value.email]);
    const placeholders = batch
      .map((_, i) => `($${i * 3 + 1}, $${i * 3 + 2}, $${i * 3 + 3})`)
      .join(', ');
    await db.query(
      `INSERT INTO users (id, name, email) VALUES ${placeholders}
       ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email`,
      values
    );
  },
  3000
);

await cache.write(`user:${userId}`, { id: userId, name, email });

Java with Spring Cache and Scheduled Flush

import org.springframework.cache.annotation.CachePut;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class WriteBehindCacheService {

    private final UserRepository userRepository;
    // In-memory dirty map: simplest option, but a crash loses pending writes.
    // For durability, persist dirty IDs in Redis alongside the cached values.
    private final Map<String, User> dirtyEntries = new ConcurrentHashMap<>();

    public WriteBehindCacheService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @CachePut(value = "users", key = "#user.id")
    public User writeUser(User user) {
        dirtyEntries.put(user.getId().toString(), user);
        return user;
    }

    @Scheduled(fixedDelay = 5000)
    public void flushToDatabase() {
        if (dirtyEntries.isEmpty()) return;

        List<User> batch = new ArrayList<>(dirtyEntries.values());
        dirtyEntries.clear();

        try {
            userRepository.saveAll(batch);
        } catch (Exception e) {
            // Re-queue failed entries for the next scheduled flush
            for (User u : batch) {
                dirtyEntries.put(u.getId().toString(), u);
            }
        }
    }
}

Explanation

The write-behind pattern splits write acknowledgment from persistence:

  1. Cache write: the application writes to the cache and marks the key as dirty. The caller gets an acknowledgment in about a millisecond and never touches the database.
  2. Dirty tracking: the dirty set records which keys still need to reach the database. In the examples above it lives in Redis itself, so it survives an application crash and can be shared across instances.
  3. Background flush: a timer fires every N seconds. The flusher takes the dirty set, reads the latest values back from the cache, and writes them to the database in a single batch.
  4. Failure recovery: if the batch write fails, the keys go back into the dirty set for the next cycle. Nothing is dropped silently.
Write-behind flow — the application writes to the cache and gets an immediate ACK while the key is marked dirty; a background flusher later batches the dirty keys to the database, and any unflushed write is lost if the cache crashes first.

The subtle part most write-ups skip: dirty tracking has to be at least as durable as the data it’s tracking. An in-process Set or HashMap works until the app restarts mid-flush and forgets which keys were pending: the values still sit in Redis, but nothing ever sends them to the database. Keeping the dirty set in Redis (or in a real write-ahead log) closes that hole. Redis’ own persistence options (AOF everysec, RDB snapshots, or both) then bound how much you can lose if Redis itself goes down.

Two knobs control the data-loss window and the database load:

  • Flush interval is your loss budget. A 5-second interval means a crash costs at most 5 seconds of acknowledged writes. Pick it from what the business can lose, not from a framework default.
  • Batch size is your efficiency lever. One multi-row INSERT ... ON CONFLICT of 500 rows is dramatically cheaper than 500 individual updates; size it so a single flush finishes well inside the interval.

Runnable versions of these implementations live in the companion repository.

Variants

ApproachFlush TriggerBest For
Time-based flushFixed interval (e.g. 5s)Steady write workloads
Batch-size flushWhen N dirty entries accumulateHigh-volume writes
Hybrid flushTime OR batch size, whichever firstVariable write rates
Event-driven flushOn specific events (e.g. shutdown)Graceful degradation
WAL-based flushWrite-ahead log for durabilityHard crash-recovery requirements

The managed-service version of this idea appears in the AWS ElastiCache caching strategies docs, which contrast lazy loading, write-through, and write-behind with TTLs. If your reads miss often and writes are rare, Cache-Aside or Microsoft’s Cache-Aside pattern guidance is a better starting point: write-behind only pays off when writes dominate.

Best Practices

  • Persist the dirty set: an in-memory dirty set forgets the flush queue on restart. Track dirty keys in Redis (as above) or in a WAL so the queue survives the process.
  • Batch database writes: the throughput win comes from consolidation. Prefer one multi-row upsert or a bulk API over per-row statements.
  • Retry with backoff: re-queue failed keys, but back off when the database is struggling so the flusher doesn’t pile onto a recovery.
  • Flush on shutdown: register a shutdown hook that drains the dirty set before the process exits.
  • Watch the dirty-set size and flush lag: a dirty set that grows without bound means the flusher is losing the race. Alert on SCARD dirty_set and on time-since-last-successful-flush.
  • Size the interval from the loss budget: see the tuning notes in Explanation. For a broader strategy view, the Redis caching strategies guide covers how write-behind fits with TTLs and eviction.

Common Mistakes

  • Dirty set in process memory only: the #1 production bug with this pattern. The app crashes, Redis still has the values, and nobody ever flushes them.
  • Flushing faster than the database drains: the dirty set grows until Redis memory or the flusher falls over. Measure flush duration, not just write latency.
  • TTL shorter than the flush cycle: a dirty key that expires or gets evicted before flushing is a silently lost write. Keep value TTLs well above the worst-case flush lag.
  • Serving database reads that expect fresh data: anything reading the DB directly (reports, other services, CDC consumers) sees stale rows. Write-behind assumes all reads go through the cache.
  • Using it for money or inventory: eventual consistency isn’t acceptable for data where “the database is the truth.” Use Write-Through there.

Troubleshooting

  • Dirty set grows unbounded: the database can’t drain the queue as fast as writes arrive. Increase batch size, shorten the interval, or scale the write path, and check for a poisoned key failing every batch.
  • One bad record fails every flush: the whole batch retries forever and the queue balloons. Catch per-record failures and push the offending keys to a dead-letter set for inspection.
  • Writes acknowledged but never reach the DB: dirty tracking was lost (in-memory set + restart) or the flusher thread died. Track last_flush_success as a metric and alert when it goes stale.
  • Downstream consumers see stale data: read replicas, CDC pipelines, and analytics read the database, which lags by design. Route freshness-sensitive reads through the cache or pick a different pattern.
  • Data loss after a Redis restart: RDB snapshots lose everything since the last save; AOF with everysec can still lose ~1 second. Combine AOF with the durable dirty set above, and know your bound.

Common Production Pitfalls

  • Treating the dirty set as an implementation detail: it’s the durability boundary, and losing it means silently dropping acknowledged writes.
  • Flush failures that only surface in logs nobody reads; the queue quietly grows for days until Redis memory blows up.
  • Load tests that measure write latency but never check whether the dirty set drains; the system looks fast right up until the database falls behind.
  • Every app instance keeping its own in-memory dirty set, so flushes duplicate or conflict; share one Redis set or partition keys per instance.
  • Assuming AOF everysec means zero loss: it’s a ~1 second bound, which is great but not zero.
  • Forgetting that read replicas and CDC consumers observe the lagging database, not the fresh cache.

Frequently Asked Questions

What is the difference between write-behind and write-through?

Write-through writes to cache and database synchronously, so the caller waits for both. Write-behind writes to the cache synchronously and the database asynchronously, so the caller only waits for the cache. Write-through is consistent; write-behind is eventually consistent and faster under load.

How do I prevent data loss if the cache crashes?

Two layers. First, keep the dirty set durable (in Redis or a WAL) so a restarted process still knows what to flush. Second, turn on Redis persistence: AOF with everysec bounds loss to about a second, RDB snapshots alone can lose minutes. For hard guarantees, write to an application-level WAL before touching the cache.

How do I choose the flush interval and batch size?

Start from the loss budget: the interval is the maximum data loss you're accepting. Then size the batch so one flush completes well inside that interval at peak load; if a flush takes longer than the interval, the dirty set grows forever. A 2–5 second interval with batches of a few hundred rows is a common starting point.

When is write-behind better than write-through?

When write throughput matters more than immediate consistency: view counters, analytics events, session data, user preferences. Write-through wins when the database must reflect the latest state the moment the call returns: financial transactions, inventory, anything another service reads directly.

Can I combine write-behind with read-through?

Yes, and it's a common pairing. Reads go through the cache with a loader callback (Read-Through), writes go to the cache with async persistence. The cache always holds the latest data for reads while the database catches up in the background.