Skip to content

Write-Back Caching: Deferred Writes with Dirty Page Management

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Write. We cover key concepts, practical examples, and best practices to help you master this topic.

Write-back Caching (also called write-behind) acknowledges writes immediately after updating the cache, then asynchronously persists the changes to the database. The cache tracks which entries are "dirty" (modified but not yet persisted) and flushes them in batches for optimal throughput.

flowchart TB
    Client -->|Write Request| App
    App -->|1. Write to Cache| Cache[(Cache)]
    Cache -->|2. Mark Dirty| DirtyMap
    App -->|3. Ack Client| Client
    Cache -->|4. Async Flush Scheduler| Scheduler
    Scheduler -->|5. Batch Flush| DB[(Database)]
    DB -->|6. Mark Clean| DirtyMap
    Scheduler -->|7. Retry on Failure| Retry[Retry Queue]

What You'll Learn

  • Dirty page tracking and write-back buffer management
  • Flush policies: time-based, count-based, threshold-based
  • Crash recovery: replaying dirty pages on restart
  • Trade-offs between write-back and write-through

Why It Matters

Write-back caching achieves the lowest write latency by decoupling the client-facing acknowledgment from the database write. It can increase write throughput by 50x while reducing database connection contention — ideal for logging, analytics, and metrics ingestion.

Real-World Use

An IoT platform ingests 100,000 sensor readings per second. Each reading is written to Redis with a dirty flag. Every 2 seconds, a background worker flushes all dirty readings to Cassandra in batches of 1000. Write latency is under 1ms for 99% of requests.

Write-Back Cache Implementation

Dirty Page Tracker

class DirtyPageTracker {
  constructor() {
    this.dirty = new Map();
    this.flushInProgress = false;
  }

  markDirty(key, value) {
    this.dirty.set(key, { value, timestamp: Date.now() });
  }

  markClean(key) {
    this.dirty.delete(key);
  }

  getDirtyPages() {
    return Array.from(this.dirty.entries()).map(([key, entry]) => ({
      key, value: entry.value, timestamp: entry.timestamp
    }));
  }

  isDirty(key) {
    return this.dirty.has(key);
  }

  size() {
    return this.dirty.size;
  }
}

Expected output:

Dirty pages are tracked by key. On flush, they are read, sent to DB, and marked clean. Crash recovery iterates remaining dirty entries on startup.

Write-Back Cache with Periodic Flush

class WriteBackCache {
  constructor(flushIntervalMs = 2000, maxDirty = 1000) {
    this.cache = new Map();
    this.dirtyTracker = new DirtyPageTracker();
    this.flushIntervalMs = flushIntervalMs;
    this.maxDirty = maxDirty;
    this.timer = setInterval(() => this.flush(), flushIntervalMs);
  }

  async get(key) {
    return this.cache.get(key)?.value ?? null;
  }

  async set(key, value, dbTable) {
    this.cache.set(key, { value, dbTable });
    this.dirtyTracker.markDirty(key, value);
    if (this.dirtyTracker.size() >= this.maxDirty) {
      await this.flush();
    }
    return { written: true };
  }

  async flush() {
    if (this.dirtyTracker.size() === 0 || this.dirtyTracker.flushInProgress) return;
    this.dirtyTracker.flushInProgress = true;

    const dirtyPages = this.dirtyTracker.getDirtyPages();
    const groups = this.groupByTable(dirtyPages);

    for (const [table, entries] of Object.entries(groups)) {
      try {
        const values = entries.map(e => e.value);
        await db.query(`INSERT INTO ${table} (data) VALUES ?`, [values]);
        entries.forEach(e => this.dirtyTracker.markClean(e.key));
      } catch (err) {
        console.error(`Flush failed for ${table}:`, err.message);
      }
    }

    this.dirtyTracker.flushInProgress = false;
  }

  groupByTable(pages) {
    const groups = {};
    for (const page of pages) {
      const entry = this.cache.get(page.key);
      const table = entry?.dbTable || 'default';
      if (!groups[table]) groups[table] = [];
      groups[table].push(page);
    }
    return groups;
  }

  async recover() {
    const dirtyPages = this.dirtyTracker.getDirtyPages();
    if (dirtyPages.length > 0) {
      console.log(`Recovering ${dirtyPages.length} dirty pages...`);
      await this.flush();
    }
  }

  shutdown() {
    clearInterval(this.timer);
    return this.flush();
  }
}

Expected output:

Writes acknowledged in <1ms. Flush interval: 2s or 1000 dirty entries. On shutdown or recovery, remaining dirty pages are flushed.

Write-Back with Shutdown Hook

const writeBackCache = new WriteBackCache(2000, 500);

process.on('SIGINT', async () => {
  console.log('Shutting down write-back cache...');
  await writeBackCache.shutdown();
  console.log('All dirty pages flushed.');
  process.exit(0);
});

process.on('SIGTERM', async () => {
  await writeBackCache.shutdown();
  process.exit(0);
});

Expected output:

On graceful shutdown, the write-back cache flushes all remaining dirty pages before exiting. This prevents data loss during deployments.

Common Mistakes

  • Not tracking dirty pages separately from the cache — after a flush, the cache still has the data but it should be marked clean.
  • Using a single flush interval for all table types — high-priority data should flush more frequently.
  • Not implementing backpressure — if the database cannot keep up, dirty pages accumulate and the cache grows unbounded.
  • Ignoring flush failures — if a batch fails, the dirty pages remain dirty but the application may not retry.
  • Not logging dirty page count — a growing backlog is the first symptom of a write-back bottleneck.

Practice Questions

  1. In write-back caching, what makes a cache entry dirty?
  2. How does write-back differ from write-through in write latency?
  3. What happens to dirty pages if the application crashes?
  4. Why is batch flushing more efficient than flushing entries one-by-one?
  5. How do you implement backpressure in a write-back cache?

Challenge

Design a write-back cache for a time-series metrics service that receives 50,000 data points per second. Implement dirty page tracking, batch flushing to InfluxDB every 3 seconds, crash recovery with Redis persistence, and a health endpoint that reports current backlog.

FAQ

What is write-back caching?

Write-back caching acknowledges writes immediately after updating the cache, marks the entry as dirty, and asynchronously persists it to the database. This provides the lowest write latency.

How does write-back handle crash recovery?

On restart, the cache replays all dirty pages by flushing them to the database. For durability, persist dirty page metadata to a write-ahead log (WAL) or use a persistent cache like Redis with AOF.

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

They are synonymous. Both refer to deferred writes where the cache acknowledges immediately and persists asynchronously.

How long should the flush interval be?

Start with 1-2 seconds. Monitor the dirty page count: if it grows continuously, shorten the interval. If CPU is high on the DB, lengthen it. Balance latency vs. database load.

Can write-back lose data?

Yes. If the cache server crashes before flushing, dirty pages in memory are lost. Mitigate by using a persistent cache (Redis AOF) or a reliable message queue (Kafka).

Mini Project

Extend the logging service from the write-around lesson. Implement write-back with dirty page tracking. Add a /status endpoint showing: total dirty pages, last flush time, flush success count, and error count. Write a crash simulation test that verifies recovery.

What's Next

Continue with Cache-Aside Pattern to revisit the most common caching pattern in depth.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro