Introduction

When a program calls fork(), the operating system creates a child process that is an exact copy of the parent. In the 1970s that meant physically duplicating every page of memory — instantly expensive, even if the child immediately replaced itself with a new program via exec().

Copy-on-write (COW) changed the game with a beautifully lazy idea: instead of copying, let both parent and child point at the same physical pages and mark them all read-only. Nothing is duplicated yet. The moment either process tries to write to one of those pages, the hardware raises a fault, the kernel steps in, makes a private copy of just that page, and lets the write proceed. Pages that are never written are never copied at all.

The result is that fork() goes from O(n)O(n) work — proportional to the size of the process — to roughly O(1)O(1) work plus a small per-write overhead paid lazily. For programs that fork only to exec a new binary right away, the total copy cost approaches zero.

Fork and Write

The demo below simulates a process with several memory pages. Each page starts shared between parent and child after a fork. Click Fork to create the child, then click any page to write to it and watch copy-on-write split it into two private copies.

<!-- COW demo: shows shared/private page state between parent and child processes -->
<p class="hint">A process owns memory pages. After fork(), parent and child share every page — no copy yet. Click a page to write to it: the kernel privately copies just that page (COW). Pages never written are never copied.</p>
<div class="arena" id="arena">
  <div class="proc-col" id="col-parent">
    <div class="proc-label">Parent process</div>
    <div class="pages" id="pages-parent"></div>
  </div>
  <div class="proc-col hidden" id="col-child">
    <div class="proc-label">Child process</div>
    <div class="pages" id="pages-child"></div>
  </div>
</div>
<div class="status" id="status">Click Fork to create a child process.</div>
<div class="btns">
  <button id="btn-fork" type="button">Fork process</button>
  <button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="legend">
  <span class="badge shared">S</span> Shared (no copy)
  <span class="badge private-p">P</span> Parent private copy
  <span class="badge private-c">C</span> Child private copy
</div>
/* Styles for the COW page-fork simulator */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.arena { display: flex; gap: 1.5rem; margin-bottom: .6rem; }
.proc-col { flex: 1; }
.proc-label { font-size: .8rem; font-weight: 700; text-transform: uppercase;
              letter-spacing: .06em; color: #555; margin-bottom: .4rem; }
.pages { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.page { border-radius: 8px; height: 56px; display: flex; flex-direction: column;
        align-items: center; justify-content: center; font-size: .78rem; font-weight: 600;
        cursor: pointer; border: 2px solid transparent; transition: all .15s;
        user-select: none; position: relative; }
.page .pg-name { font-size: .7rem; opacity: .7; }
.page .pg-badge { position: absolute; top: 3px; right: 5px; font-size: .65rem;
                  font-weight: 800; }
/* Shared pages: blue — both processes point to the same physical frame */
.page.shared { background: #dbeafe; border-color: #93c5fd; color: #1e40af; }
.page.shared:hover { background: #bfdbfe; }
/* Parent private: green — parent has its own copy after a write */
.page.private-p { background: #dcfce7; border-color: #86efac; color: #166534; }
/* Child private: yellow — child has its own copy after a write */
.page.private-c { background: #fef9c3; border-color: #fde047; color: #713f12; }
.page.cow-anim { animation: cowpop .4s ease; }
@keyframes cowpop { 0%{transform:scale(1)} 40%{transform:scale(1.18)} 100%{transform:scale(1)} }
.hidden { visibility: hidden; }
.status { font-size: .95rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.ok { color: #166534; }
.status.info { color: #1e40af; }
.status.warn { color: #92400e; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 14px system-ui; padding: .45rem .9rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: default; }
.legend { font-size: .78rem; color: #555; display: flex; gap: .8rem; flex-wrap: wrap;
          align-items: center; }
.badge { border-radius: 4px; padding: 1px 5px; font-size: .7rem; font-weight: 800;
         display: inline-block; }
.badge.shared { background: #dbeafe; color: #1e40af; border: 1px solid #93c5fd; }
.badge.private-p { background: #dcfce7; color: #166534; border: 1px solid #86efac; }
.badge.private-c { background: #fef9c3; color: #713f12; border: 1px solid #fde047; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Copy-on-write simulator
// Each process tracks how many times it wrote to each page.
const PAGE_NAMES = ['A','B','C','D','E','F'];
const TOTAL = PAGE_NAMES.length;

// forked = whether fork() has been called
let forked = false;
// write count per page per process (0 = never written = still shared after fork)
const writes = { parent: new Array(TOTAL).fill(0), child: new Array(TOTAL).fill(0) };
// Page state categories
// shared = both point to same frame; private-p = parent has own copy; private-c = child has own copy

function pageState(i) {
  // Determine the display state of page i.
  if (!forked) return 'unforked';
  const pw = writes.parent[i], cw = writes.child[i];
  if (pw === 0 && cw === 0) return 'shared';
  if (pw > 0 && cw === 0) return 'private-p';
  if (cw > 0 && pw === 0) return 'private-c';
  return 'both';
}

function render() {
  // Re-draw both columns based on current write counts.
  const pp = document.getElementById('pages-parent');
  const pc = document.getElementById('pages-child');
  pp.innerHTML = '';
  if (forked) pc.innerHTML = '';

  let sharedCount = 0;

  PAGE_NAMES.forEach((name, i) => {
    const st = pageState(i);

    // Build the parent's page tile.
    const dp = document.createElement('div');
    dp.className = 'page ' + (st === 'unforked' || st === 'private-p' || st === 'both' ? (forked ? (writes.parent[i] > 0 ? 'private-p' : 'shared') : 'unforked') : 'shared');
    if (forked && st === 'shared') sharedCount++;
    const pBadge = !forked ? '' : (writes.parent[i] > 0 ? 'P' : 'S');
    dp.innerHTML = `<span class="pg-name">Page ${name}</span><span class="pg-badge">${pBadge}</span>`;
    if (forked) {
      dp.title = 'Click to write from the parent';
      dp.addEventListener('click', () => writeToPage('parent', i));
    }
    pp.appendChild(dp);

    if (!forked) return;

    // Build the child's page tile.
    const dc = document.createElement('div');
    const childClass = writes.child[i] > 0 ? 'private-c' : 'shared';
    dc.className = 'page ' + childClass;
    const cBadge = writes.child[i] > 0 ? 'C' : 'S';
    dc.innerHTML = `<span class="pg-name">Page ${name}</span><span class="pg-badge">${cBadge}</span>`;
    dc.title = 'Click to write from the child';
    dc.addEventListener('click', () => writeToPage('child', i));
    pc.appendChild(dc);
  });

  setStatus(forked
    ? i18nFmt('%shared% of %total% pages still shared — no memory copied yet.', { shared: sharedCount, total: TOTAL })
    : 'Click Fork to create a child process.', forked ? 'info' : '');
}

function writeToPage(proc, i) {
  // Simulate a write to page i by the given process; first write triggers COW.
  writes[proc][i]++;
  const colId = proc === 'parent' ? 'pages-parent' : 'pages-child';
  const pages = document.getElementById(colId).children;
  if (pages[i]) {
    pages[i].classList.add('cow-anim');
    pages[i].addEventListener('animationend', () => pages[i].classList.remove('cow-anim'), { once: true });
  }
  const wasCow = writes[proc][i] === 1;
  if (wasCow) {
    setStatus(i18nFmt('COW: page %page% copied for %proc%.', { page: PAGE_NAMES[i], proc: proc === 'parent' ? 'parent' : 'child' }), 'ok');
    console.log(i18nFmt('COW split: page %page% now private for %proc%', { page: PAGE_NAMES[i], proc: proc }));
  } else {
    setStatus(i18nFmt('Page %page% already private — write proceeds normally.', { page: PAGE_NAMES[i] }), 'info');
  }
  render();
}

function doFork() {
  // Simulate fork(): mark as forked, show child column, disable the fork button.
  forked = true;
  document.getElementById('col-child').classList.remove('hidden');
  document.getElementById('btn-fork').disabled = true;
  setStatus(i18nFmt('Forked! All %total% pages shared. Click pages to write.', { total: TOTAL }), 'ok');
  console.log('fork() called — all pages marked shared (read-only), no memory copied');
  render();
}

function doReset() {
  // Reset everything back to the initial single-process state.
  forked = false;
  writes.parent.fill(0);
  writes.child.fill(0);
  document.getElementById('col-child').classList.add('hidden');
  document.getElementById('btn-fork').disabled = false;
  setStatus('Click Fork to create a child process.', '');
  render();
}

function setStatus(t, cls) {
  const s = document.getElementById('status');
  s.textContent = t;
  s.className = 'status ' + cls;
}

document.getElementById('btn-fork').addEventListener('click', doFork);
document.getElementById('btn-reset').addEventListener('click', doReset);
render();
Result
Console

Notice the asymmetry. The fork itself is instant regardless of how many pages exist — no memory is copied. Each write then pays a one-time split cost for that single page. If the child only reads and never writes, the total extra memory used is zero.

The Real Cost

Copy-on-write is not free — it trades an upfront bulk copy for a stream of per-page faults paid on demand. Understanding the real costs matters when you push it hard:

  • Fork latency drops to O(1)O(1). The kernel clones the page-table entries and marks them read-only. This is proportional to the number of page-table entries, not the data size — and modern kernels use huge pages to keep even that small.
  • Each write triggers a page fault. The hardware detects the read-only violation, traps to the kernel, which allocates a fresh physical page, copies the 4 KB of data, updates the page table, and resumes the faulting instruction. That round-trip costs a few microseconds.
  • TLB shootdowns multiply the cost in multicore systems. When a page-table entry changes on one CPU, other CPUs caching that entry must be notified via an inter-processor interrupt. With many cores all writing different pages simultaneously, shootdown storms can dominate.
  • Worst case: write-heavy workloads. A process that forks and then writes every page ends up paying the original copy cost plus the fault overhead for each page. Redis, for example, triggers this pattern during its background save — the parent keeps serving writes while the child persists a snapshot, and every dirty page doubles in memory.

The key insight is that COW moves cost from time of fork to time of first write. For scheduling or exec-heavy workflows this is a massive win. For copy-heavy ones it is a wash at best.

Where It Matters

The "share until you write" idea shows up everywhere a copy might never need to diverge:

  • OS process forking: every Unix-like OS uses COW in fork(). The shell spawns a child that execs a new binary without copying the shell's entire heap — the copy cost is zero.
  • Persistent data structures: functional languages like Clojure and Haskell use structural sharing to give you an "updated" collection that reuses most of the old one's nodes — the same COW principle applied in software rather than hardware.
  • Container and VM snapshots: Docker layers and virtual machine snapshots share disk blocks across images. A new container starts from a read-only base layer; blocks are copied to a writable layer only when written.
  • Database checkpointing: SQLite, PostgreSQL (MVCC), and Redis all use COW semantics during snapshotting. A background writer sees a stable view of the data while foreground writers create private copies of modified pages.
  • Git object storage: git's objects are immutable content-addressed blobs. Branching is free because no objects are duplicated — only new commits and trees point to the modified objects, sharing the rest.

Understand copy-on-write and you have the key to why program synthesis in functional languages is cheap, why containers start in milliseconds, and why a Redis save does not block your reads.

Conclusion

Copy-on-write is a masterclass in lazy evaluation applied to memory: why do work now when you can defer it to the moment it is actually needed — or skip it entirely if it never is?

The principle is simple enough to fit in a sentence — share pages until a write forces a split — yet it underlies process forking in every modern OS, structural sharing in functional data structures, snapshot isolation in databases, and the layered storage behind every container you have ever run.

The next time a fork() returns in microseconds despite a gigabyte heap, or a Docker image spins up in milliseconds, you are watching copy-on-write quietly doing nothing — which is exactly the point. Doing nothing, as fast as possible, until you absolutely must act.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/copy-on-write/Content licensed under CC BY-NC 4.0.