---
title: 'Write-Through and Write-Behind'
source: 'https://academia.sh/en/courses/scaling-the-data/write-through-and-write-behind'
course: 'Scaling the Data Layer'
language: en
updated: '2026-08-23T07:01:33+00:00'
license: 'CC BY-SA 4.0'
---

# Write-Through and Write-Behind

Two arrangements for wiring the write path to the cache: write-through removing the delete and raising the hit ratio from 0.7285 to 0.9170, the second write target added by denormalization stretching the path to two stores, write-behind lowering store writes per event from 2.00 to 1.20 through batching, and measuring the 200 events pending in memory in exchange as a durability window.

The previous lesson's entire cost grew from one constraint: the cache was not where the write
happened, so it could only delete the old value, never put in the new one, and every deletion
caused a store read. That constraint lifts once the path writing shipment state to the store
passes through the application's own code: the write goes to both places and no deletion
happens; or the link goes one step further, writing to the cache first and the store later in
a batch. The first is **write-through**, the second **write-behind** — this lesson measures
both.

Both strategies' mechanics were built and measured in the Caching, Queues and Asynchronous
Processing course and are not retold here. The question here is scale: how many places the
write path stretches to, how much extra work each event costs, and which of K01's lines moves.

## The Second Write Target

A number this topic established changes in this lesson. The previous topic applied
denormalization to shorten the read path: a zone's shipment count is kept in a separate
counter instead of being counted at query time. That paid off on the read side but left a
silent debt on the write side — every state event now updates **two** places: the shipment's
tracking record and its zone's counter.

K01's table had only one write; `peak write req/s` 97.22 was computed assuming one event
writes to one record. When the write target doubles, the event rate stays the same, but the
store write rate does not. This lesson's first measure is therefore **write path length**: how
many places one event touches.

**B1 (this lesson's assumption): the carrier network is divided into 40 distribution zones.**
Rationale: route steps are reported at zone level, and V5's tracking response carries the zone
as a field. Sensitivity is in the last measurement: as the zone count grows, the coalescing
ratio in batching falls. Not added to K01's table.

```js
// cache/write.mjs — the in-process model of the arrangements where the write path is
// wired to the cache. Cache, store, and event stream are each their own module; counters
// are explicit. Batch window and zone count are parameters; no time is measured, only ops.
export function openBox(capacity) {
  const m = new Map();
  const o = {
    eviction: 0,
    has: (a) => m.has(a),
    get: (a) => { const d = m.get(a); if (d !== undefined) { m.delete(a); m.set(a, d); } return d; },
    put: (a, d) => { m.delete(a); m.set(a, d); if (m.size > capacity) { m.delete(m.keys().next().value); o.eviction += 1; } },
    remove: (a) => m.delete(a),
  };
  return o;
}

// Every state event updates two places: the shipment's tracking record and the zone's distribution counter.
export function stream({ reads, ratio, workingSet, burst, zones }) {
  let seed = 20260730;
  const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648);
  let next = 1;
  const active = Array.from({ length: workingSet }, () => ({ id: next++, remaining: burst }));
  const output = [];
  let debt = 0;
  for (let i = 0; i < reads; i++) {
    const j = Math.floor(rand() * active.length);
    const item = active[j];
    output.push(["read", `track:${item.id}`]);
    if ((item.remaining -= 1) === 0) active[j] = { id: next++, remaining: burst };
    for (debt += ratio; debt >= 1; debt -= 1) {
      const id = active[Math.floor(rand() * active.length)].id;
      output.push(["write", `track:${id}`, `zone:${Math.floor(rand() * zones) + 1}`]);
    }
  }
  return output;
}

// trackWrite: "delete" (cache-aside) or "update" (write-through).
// zoneWrite: "through" or "behind"; with write-behind, the batch window is `batchWindow` writes.
export function run(ops, { capacity, trackWrite, zoneWrite, batchWindow = 200 }) {
  const c = openBox(capacity);
  const zoneCount = new Map();
  const pending = new Map();                    // with write-behind, zone counters not yet sent to the store
  const s = { hit: 0, storeRead: 0, storeWrite: 0, touch: 0, writeTouch: 0, stale: 0, pendingEvents: 0 };
  const truth = new Map();
  let count = 0, eventDebt = 0, maxPendingEvents = 0;
  for (const [type, key, zone] of ops) {
    if (!truth.has(key)) truth.set(key, 0);
    if (type === "read") {
      s.touch += 1;
      if (c.has(key)) { s.hit += 1; if (c.get(key) !== truth.get(key)) s.stale += 1; continue; }
      s.storeRead += 1; s.touch += 2;
      c.put(key, truth.get(key));
      continue;
    }
    const value = truth.get(key) + 1;
    truth.set(key, value);
    s.storeWrite += 1; s.writeTouch += 1;                  // the tracking record always goes to the store
    s.writeTouch += 1;
    if (trackWrite === "delete") c.remove(key); else c.put(key, value);
    zoneCount.set(zone, (zoneCount.get(zone) ?? 0) + 1);   // the zone counter lands in the cache first
    s.writeTouch += 1;
    if (zoneWrite === "through") { s.storeWrite += 1; s.writeTouch += 1; continue; }
    pending.set(zone, zoneCount.get(zone));
    eventDebt += 1;
    maxPendingEvents = Math.max(maxPendingEvents, eventDebt);
    if ((count += 1) % batchWindow === 0) {
      s.storeWrite += pending.size; s.writeTouch += pending.size;
      pending.clear(); eventDebt = 0;
    }
  }
  s.pendingEvents = eventDebt;
  return { ...s, pendingEntries: pending.size, maxPendingEvents };
}

// M19/K01 back-of-the-envelope inputs (V1-V4, V8).
export const K01 = {
  peakRead: (2_000_000 * 6 / 86_400) * 3,
  peakWrite: (400_000 * 7 / 86_400) * 3,
};
```

The model is in-process. No time is measured, only operations counted; the batch triggers on a
write count, not a duration, since a duration would be machine-dependent and not a count.

## Three Arrangements

```js
// cache/arrangement.mjs — comparing the three write arrangements on the same stream
import { stream, run, K01 } from "./write.mjs";

const READS = 200_000, CAPACITY = 2000, ZONES = 40, WINDOW = 200;
const RATIO = K01.peakWrite / K01.peakRead;
const ops = stream({ reads: READS, ratio: RATIO, workingSet: 1000, burst: 10, zones: ZONES });
const EVENTS = ops.filter(([t]) => t === "write").length;

console.log(`reads ${READS}, state events ${EVENTS}, zones ${ZONES}, batch window ${WINDOW} writes`);
console.log(`K01: peak read ${K01.peakRead.toFixed(2)}/s, peak write ${K01.peakWrite.toFixed(2)}/s\n`);

const ARRANGEMENTS = [
  ["cache-aside + zone through", { trackWrite: "delete", zoneWrite: "through" }],
  ["write-through + zone through", { trackWrite: "update", zoneWrite: "through" }],
  ["write-through + zone behind", { trackWrite: "update", zoneWrite: "behind" }],
];

console.log("arrangement                       hit  store read store write write/event   path length reaching store/s write/read at store pending events");
console.log("------------------------------ ------ ----------- ----------- ----------- ------------- ---------------- ------------------- --------------");
for (const [label, choice] of ARRANGEMENTS) {
  const r = run(ops, { capacity: CAPACITY, batchWindow: WINDOW, ...choice });
  const h = r.hit / READS;
  const behind = K01.peakRead * (1 - h);
  const writeRate = K01.peakWrite * (r.storeWrite / EVENTS);
  console.log(`${label.padEnd(30)} ${h.toFixed(4).padStart(6)} ${String(r.storeRead).padStart(11)} ` +
    `${String(r.storeWrite).padStart(11)} ${(r.storeWrite / EVENTS).toFixed(2).padStart(11)} ` +
    `${(r.writeTouch / EVENTS).toFixed(2).padStart(13)} ` +
    `${(behind + writeRate).toFixed(2).padStart(16)} ${(writeRate / behind).toFixed(2).padStart(19)} ` +
    `${String(r.maxPendingEvents).padStart(14)}`);
}

const writeBehind = run(ops, { capacity: CAPACITY, batchWindow: WINDOW, trackWrite: "update", zoneWrite: "behind" });
console.log(`\nloss window if the same rule applied to the tracking record too:`);
console.log(`  events pending in memory = ${writeBehind.maxPendingEvents}`);
console.log(`  event record 220 bytes (V6) -> ${writeBehind.maxPendingEvents * 220} bytes unwritten`);
console.log(`  peak write ${K01.peakWrite.toFixed(2)}/s -> ${(writeBehind.maxPendingEvents / K01.peakWrite).toFixed(2)} seconds of event flow`);

console.log("\nwrites landing on the same key inside the batch window are the coalescing ratio:");
console.log("zone count    store write  write/event  reaching store/s");
for (const zones of [40, 200, 1000]) {
  const a = stream({ reads: READS, ratio: RATIO, workingSet: 1000, burst: 10, zones });
  const r = run(a, { capacity: CAPACITY, batchWindow: WINDOW, trackWrite: "update", zoneWrite: "behind" });
  const behind = K01.peakRead * (1 - r.hit / READS);
  const writeRate = K01.peakWrite * (r.storeWrite / EVENTS);
  console.log(`${String(zones).padStart(12)} ${String(r.storeWrite).padStart(11)} ` +
    `${(r.storeWrite / EVENTS).toFixed(2).padStart(11)} ${(behind + writeRate).toFixed(2).padStart(16)}`);
}
```

```
reads 200000, state events 46666, zones 40, batch window 200 writes
K01: peak read 416.67/s, peak write 97.22/s

arrangement                       hit  store read store write write/event   path length reaching store/s write/read at store pending events
------------------------------ ------ ----------- ----------- ----------- ------------- ---------------- ------------------- --------------
cache-aside + zone through     0.7285       54308       93332        2.00          4.00           307.59                1.72              0
write-through + zone through   0.9170       16592       93332        2.00          4.00           229.01                5.63              0
write-through + zone behind    0.9170       16592       55908        1.20          3.20           151.04                3.37            200

loss window if the same rule applied to the tracking record too:
  events pending in memory = 200
  event record 220 bytes (V6) -> 44000 bytes unwritten
  peak write 97.22/s -> 2.06 seconds of event flow

writes landing on the same key inside the batch window are the coalescing ratio:
zone count    store write  write/event  reaching store/s
          40       55908        1.20           151.04
         200       75983        1.63           192.87
        1000       88862        1.90           219.70
```

All the numbers belong to the **computed** class: they were counted over a deterministic
stream, and no time was measured.

## Reading the Numbers

**Write-through fixes the read side.** With the delete removed, the hit ratio rose from
0.7285 to 0.9170, and store reads fell from 54,308 to 16,592. Notably, 0.9170 sits even
**above** the no-write run's 0.8976: a write not only stops dropping the entry, it warms it —
the moment a shipment produces an event it enters the cache, and the next query does not miss.
V9's 0.90 is exceeded here for the first time, and the reason is not an assumption but the
write path being wired.

**The write path now stretches to two stores.** In the first two arrangements, store writes
per event are 2.00; path length is 4.00 touches — two store writes, one tracking-cache update,
one zone-counter update, a cost K01 never saw: the table assumed one event writes to one
record. The second write target is the price of the denormalization decision, pulling
`requests reaching the store/s` upward.

**Write-behind fixes the write side.** Holding the zone counter in the cache first and
batching it to the store every 200 writes brings store writes per event down from 2.00 to
**1.20**: consecutive increments landing on the same zone coalesce into a single update.
`requests reaching the store/s` drops from 229.01 to **151.04**, and `write/read at the store`
falls from 5.63 to 3.37.

The three arrangements' `requests reaching the store/s` column reads together: 307.59 →
229.01 → 151.04. K01's 138.89 equals none of them, since that computation knew neither
invalidation nor the second write target. 151.04 is that line's real value once the design is
finished.

**The coalescing ratio depends on the key space.** The last table gives the rule: whatever
number of writes land on the same key inside the window is what gets gained. At 40 zones, a
200-write window leaves 1.20 store writes per event; at 1000 zones, nearly every write lands
on a separate key, and the ratio climbs to 1.90, `requests reaching the store/s` to 219.70.
Write-behind pays off on a small, hot key space; on a wide one it only adds latency.

## Which Data Goes With Which Arrangement

Write-behind's cost is written in the last three lines. Since the batch window is 200 writes,
up to 200 events' worth of unwritten changes sit in memory at any moment — 2.06 seconds of
event flow at peak write rate. If the process stopped then, these changes would be lost.

For the zone counter, that loss is tolerable, since the counter is **derived**: the event
records sit in the store, and the counter can be recomputed from them. For the tracking record
and the event record itself, it is not. Per V6, an event record is 220 bytes, kept 730 days
per V12 for contract disputes. The 200 lost events are 44,000 bytes, with no second copy
anywhere.

The rule is therefore written by data, not by layer: **data whose source of truth sits
elsewhere** can use write-behind; **data that is itself the source of truth** cannot. Two keys
in the same cache layer running under two different arrangements is not an inconsistency but a
placement decision.

## Summary

- The zone counter added by denormalization stretched the write path to two stores: 2.00
  store writes per event, path length 4.00 touches. K01's table assumed one event writes to
  one record.
- Removing the delete, write-through raised the hit ratio from 0.7285 to 0.9170 — above V9's
  0.90 — since a write warms the entry rather than dropping it; store reads fell from 54,308
  to 16,592.
- Write-behind lowered store writes per event on the zone counter to 1.20; `requests reaching
  the store/s` fell from 229.01 to 151.04, `write/read at the store` from 5.63 to 3.37.
- The coalescing ratio is how many writes in the batch window land on the same key: 1.20 store
  writes per event at 40 zones, 1.90 at 1000, and `requests reaching the store/s` 219.70.
- Write-behind's loss window is 200 events, 44,000 bytes, and 2.06 seconds of flow; acceptable
  for derived data whose source sits elsewhere, not for the event record that is itself the
  source of truth.

## Next Step

In this lesson, writes kept an entry in the cache alive; but an entry for a key that never
receives a write drops for one reason only: its lifetime expires. The moment it does, a
request for that key misses, goes to the store, and waits — and if the key is hot, many
requests do the same thing at once. Waiting for expiry is not mandatory: while still valid and
nearing the end of its lifetime, an entry can be refreshed in the background, and the reader
never sees a miss. That refresh is not free; some refreshed entries are never read again, and
those refreshes are pure load written to the store for nothing. The next lesson turns the
threshold into a parameter and puts two numbers face to face: misses saved against refreshes
wasted.
