Skip to main content

Command Palette

Search for a command to run...

Write-Ahead Log Explained: Build Crash-Safe Durability in 30 Lines

Updated
17 min readView as Markdown
Write-Ahead Log Explained: Build Crash-Safe Durability in 30 Lines
J

I am software developer, primarily working on the nodejs, graphql, react and mongoDB.

Last time we built a connection pool from scratch. This time: how does a database not lose your data when the power dies?


You write a row. The database says OK. A millisecond later, someone trips over the power cable.

When the machine boots back up, is your row still there?

If the answer is "yes," then some code ran before that OK was sent that made your write survive a crash that hadn't happened yet. That code is the write-ahead log. It sounds like the deepest, scariest part of a database. It's an append to a file and one system call.

We're going to derive it. One problem at a time, starting from a Map.

The store that forgets

Here's the simplest key-value store there is:

const store = new Map();
store.set("balance", 100);

Fast. Correct. Completely fake — it lives in RAM.

$ kill -9 <pid>

Every write since startup is gone. SIGKILL can't be caught, so there's no shutdown hook, no flush-on-exit, no goodbye. The process is simply deleted mid-run.

So, the problem, stated plainly:

How does a single write survive a crash?

Everything below is that one question, chased until it's answered properly.

The first step is obvious enough: put the data somewhere that isn't RAM.

Step 1: write it to a file

"Durable" just means "it's in a file," right? So after every change, save the map:

const fs = require("fs");
const store = new Map();

function set(key, value) {
  store.set(key, value);
  fs.writeFileSync("data.json", JSON.stringify([...store]));  // save everything
}

function load() {
  if (!fs.existsSync("data.json")) return;
  for (const [k, v] of JSON.parse(fs.readFileSync("data.json", "utf8"))) store.set(k, v);
}

And this works. Not "sort of" — kill the process, restart, call load(), and every write you made is there. As a statement of intent, "save everything after every change" is exactly right. (It has a nasty hole we'll come back to in Step 8, and another one below.)

It fails on cost, and it fails badly.

But first, the other hole, because it's worse than it looks: writeFileSync opens with "w", which truncates the file to zero before writing a single byte. Crash mid-snapshot and you haven't lost one write — you've lost the entire dataset. The real fix is to write a temp file and rename() it over the original, since rename is atomic. Keep that in mind: our very first attempt is the least crash-safe design in this whole article.

Say the store holds 2 GB and one set() changes a 20-byte value. To persist those 20 bytes, you write out all 2 GB. The ratio of bytes-shipped-to-disk versus bytes-actually-changed is called write amplification, and here it's:

2,000,000,000 / 20  ≈  100,000,000 : 1

A hundred million units of work for one unit of change. On an SSD sustaining 500 MB/s, serializing 2 GB takes ~4 seconds — so a single key update blocks for four seconds before it can return OK.

Worse, the cost is O(N) in your total data, not in the size of the change. Grow to 20 GB and every tiny write takes 40 seconds. You've built a store that punishes you for putting data in it.

The fix has to be: stop writing things that didn't change.

Step 2: only write what changed

If just 20 bytes changed, write 20 bytes. Seek to wherever that key lives on disk and overwrite it in place:

function set(key, value) {
  store.set(key, value);
  const offset = offsets.get(key);              // where this key lives on disk
  fs.writeSync(fd, serialize(value), 0, len, offset);   // overwrite it
}

The write is tiny again, and it stays tiny no matter how big the dataset gets. Problem solved — except we just created two new ones.

Random I/O. Seeking to an arbitrary offset for every write is the slowest thing storage does. On a spinning disk, seek latency caps you around a hundred writes per second. Even on an SSD, a 20-byte write forces the drive to read-modify-write an entire flash page underneath you. We traded one big sequential write for an endless stream of small random ones.

Corruption. This is the fatal one. You are mutating the only copy of data you already committed. If the machine dies halfway through overwriting that record, you haven't lost a pending write — you've destroyed a finished one, and there's no clean version to fall back to. Instead of losing the newest write, you lose an old write that was already safe.

In-place updates and crash safety are fundamentally at war.

The mistake both attempts share

Two attempts, two dead ends. Step back and they have the same root cause:

Both attempts try to keep an up-to-date copy of the data on disk.

Maintaining that copy is what forces the bad choice. Rewrite it wholesale and you pay O(N) per write. Edit it in place and you risk destroying committed data.

So stop storing the data.

Step 3: store the changes instead

Don't maintain the map on disk. Instead, append a one-line description of what happened to a log file — and never touch bytes you've already written:

const fd = fs.openSync("data.wal", "a");    // "a" = append only

function append(record) {
  fs.writeSync(fd, JSON.stringify(record) + "\n");
}

append({ op: "set", key: "balance", value: 100 });

The file now looks like a diary rather than a database:

{"op":"set","key":"balance","value":100}
{"op":"set","key":"balance","value":250}
{"op":"del","key":"temp"}

Look at what that bought us, against both failures at once:

  • We only write the change. O(change), never O(N). A 20-byte update writes ~40 bytes of record, whether the store holds 2 MB or 2 TB.

  • Every write is a sequential append. No seeking — the fastest access pattern a disk has, on spinning rust and SSDs alike.

  • We never overwrite anything. Old records are immutable. A crash can only damage the record currently in flight — it can't reach back and corrupt a write that already succeeded.

That third point is the one that actually resolves Step 2's war. Append-only isn't just faster; it's structurally safer, because committed bytes are never in the line of fire.

One problem: this file is a list of changes, not a store. To read balance you'd have to scan the whole log every time.

Step 4: reads need memory

So keep the Map. It never stopped being useful — it was only ever the durability that was missing. Write to both: the log for survival, the map for speed.

function set(key, value) {
  append({ op: "set", key, value });   // durability
  store.set(key, value);               // speed
}

function get(key) {
  return store.get(key);               // pure memory, O(1)
}

Reads never touch the disk. Writes are one small sequential append. This is the shape almost every real storage engine has: an in-memory structure for serving, an append-only log for surviving.

Which leaves one hole. Restart the process and the Map is empty again.

Step 5: replay to rebuild

The log is a complete, ordered history of every change ever made. So on startup, read it top to bottom and re-apply each record. Whatever state you ended with, you get back:

function replay() {
  if (!fs.existsSync(path)) return;
  for (const line of fs.readFileSync(path, "utf8").split("\n")) {
    if (!line) continue;
    const r = JSON.parse(line);
    if (r.op === "set") store.set(r.key, r.value);
    if (r.op === "del") store.delete(r.key);
  }
}

This is why every record carries an op. A log of values alone can't express absence — "delete temp" is an event, and the only way to record an event is to name it. Replay is just re-running history.

Put it together and the store works — it's not finished, but it runs:

// wal.js
const fs = require("fs");

class WAL {
  constructor(path) {
    this.path = path;
    this.store = new Map();
    this.#replay();                       // rebuild memory from history
    this.fd = fs.openSync(path, "a");     // append handle for new writes
  }

  set(key, value) { this.#append({ op: "set", key, value }); this.store.set(key, value); }
  del(key)        { this.#append({ op: "del", key });        this.store.delete(key); }
  get(key)        { return this.store.get(key); }

  #append(record) {
    fs.writeSync(this.fd, JSON.stringify(record) + "\n");
  }

  #replay() { /* as above */ }
}

module.exports = WAL;

Now look closely at set(). It touches two places — the log and the map. Before worrying about their order, we need to pin down what we actually owe the caller.

Step 6: what exactly did we promise?

When set() returns, we've said OK. That's a promise: this write will survive a crash. Anything before the return is ours to lose freely. Anything after is a lie if we lose it. So the rule is:

The log record for an operation must be durable before the operation is acknowledged.

Walk every crash point against set():

Crash point Log has it? Caller got OK? Outcome
Mid-#append Partially No Nothing was promised — safe to drop (Step 9 makes replay handle it)
After append, before store.set Yes No Replay applies it. State correct
After store.set, before return Yes No Replay applies it. State correct
After return Yes Yes Promise kept

The log always contains at least every acknowledged write. It might contain one extra — a record we logged but hadn't returned from yet — and that's harmless, since replaying it just leaves state slightly newer than promised. What it can never do is lack a write we already confirmed. That would be a lie.

Now, the ordering. Look at the table again and notice something uncomfortable: swapping the two lines inside set() wouldn't break a single row. Our map is volatile — it's gone on crash either way — and the guarantee is anchored to the return, not to the map. So why insist on log-first?

Because that's an accident of our map living in RAM. The moment the structure you're updating is also on disk — real data pages, a B-tree, an SSTable — the order becomes everything. Mutate the page first, crash, and you're left with a half-modified persistent structure and no log record to redo or undo it: unrecoverable corruption. Log first, and recovery can always repair the page from the record.

That's what "write-ahead" means — the log lands ahead of the acknowledgment, and ahead of the data it describes. We'll keep log-first from here on, because that's the invariant that survives contact with a real storage engine.

Step 7: does it survive? kill -9

Enough theory. Let's kill it.

// writer.js
const WAL = require("./wal");
const db = new WAL("data.wal");
let n = 0;
setInterval(() => {
  db.set(`key:${n}`, { n, at: Date.now() });
  console.log("wrote", `key:${n++}`);
}, 500);
$ node writer.js
wrote key:0
wrote key:1
wrote key:2
wrote key:3
# in another terminal:
$ kill -9 $(pgrep -f writer.js)

No cleanup ran. No handler fired. Now read it back:

// reader.js
const WAL = require("./wal");
const db = new WAL("data.wal");
console.log([...db.store.entries()]);
$ node reader.js
[ [ 'key:0', {…} ], [ 'key:1', {…} ], [ 'key:2', {…} ], [ 'key:3', {…} ] ]

Every acknowledged write came back. We murdered the process and lost nothing.

Notice something, though: there is no fsync anywhere in this code. If durability supposedly requires forcing bytes to disk, why did this just work? And if it works without it — what exactly are we still exposed to?

Step 8: surviving the power cut

Here's the part most WAL tutorials get wrong.

fs.writeSync() does not put your bytes on the disk. It copies them into the operating system's page cache — kernel memory — and returns. The kernel flushes to the physical device later, on its own schedule.

That explains the test above. Once the bytes are in the page cache, they belong to the kernel, not your process. Your process can die any way it likes — kill -9, segfault, OOM kill, uncaught throw — and the kernel still writes them out. A process crash cannot lose data that's already in the page cache.

So write() alone buys real durability against your program crashing. That's the majority of crashes, and it's genuinely why the demo in Step 7 passed.

What write() does not survive is the operating system dying with those bytes still in its cache: a power cut, a kernel panic, a yanked plug. The page cache is RAM, and RAM doesn't survive power loss. Anything not yet flushed evaporates — including writes you already answered OK to. That breaks the invariant.

fsync() is the syscall that says do not return until these bytes are physically on the storage device:

  #append(record) {
    fs.writeSync(this.fd, JSON.stringify(record) + "\n");  // → page cache
    fs.fsyncSync(this.fd);                                 // → physical disk
  }

That one line is the border between "safe if my program crashes" and "safe if the building loses power."

It's also, by far, the expensive part. writeSync is a memcpy into kernel memory — nanoseconds. fsyncSync blocks on physical hardware — often a millisecond or more, and it's the reason every serious database gives you a knob to trade durability against throughput. We'll see three of those knobs shortly.

One more thing our 30 lines quietly assume: fs.writeSync returns the number of bytes it actually wrote, and it's allowed to write fewer than you asked for. Production code loops until the buffer is drained. For small appends to a regular file you'll effectively never see it — but "effectively never" is doing real work in that sentence.

Step 9: the half-written record

One crash case from Step 6's table deserves its own section: crashing mid-append.

writeSync is not atomic. Lose power partway through and the last line in the file is a fragment:

{"op":"set","key":"key:40","value":{"n":40,"at":1753500000000}}
{"op":"set","key":"key:41","value":{"n":41,"at":17

That's a torn record, and JSON.parse throws on it. Which means our replay loop crashes on startup — the durability feature has become a boot failure. Impressive own goal.

The fix is one line:

    let r;
    try { r = JSON.parse(line); } catch { break; }   // torn tail — stop replaying

Stop at the first line that won't parse, and keep everything before it.

It's worth checking that this heuristic actually holds rather than assuming it. Take one full record and try to parse every possible truncation of it: of the 57 cut points in {"op":"set","key":"a","value":{"n":41,"at":1753500000000}}, zero produce valid JSON. Truncation always removes a closing brace or quote, so a torn record can't masquerade as a complete one. For this format, the crude fix is genuinely sound — not by luck.

This is safe, and it's safe because of the invariant — not by luck:

  1. A torn record can only ever be the last record. Nothing gets appended after a crash, so there's no valid data hiding behind the damage.

  2. A torn record was never acknowledged. The fsync inside #append hadn't returned, so set() hadn't returned, so the caller was never told OK. Discarding it breaks no promise.

Truncating at the tear leaves the log as a clean prefix of history — exactly the guarantee we defined in Step 6.

Catching a JSON.parse exception is a crude way to detect corruption, but the idea is exactly right. So let's go see how the grown-ups implement it.

The finished thing

Every patch folded back in:

// wal.js — a crash-safe key-value store
const fs = require("fs");

class WAL {
  constructor(path) {
    this.path = path;
    this.store = new Map();
    this.#replay();                     // rebuild memory from history
    this.fd = fs.openSync(path, "a");   // append-only handle
  }

  set(key, value) { this.#append({ op: "set", key, value }); this.store.set(key, value); }
  del(key)        { this.#append({ op: "del", key });        this.store.delete(key); }
  get(key)        { return this.store.get(key); }

  #append(record) {
    fs.writeSync(this.fd, JSON.stringify(record) + "\n");  // → page cache
    fs.fsyncSync(this.fd);                                 // → physical disk
  }

  #replay() {
    if (!fs.existsSync(this.path)) return;
    for (const line of fs.readFileSync(this.path, "utf8").split("\n")) {
      if (!line) continue;
      let r;
      try { r = JSON.parse(line); } catch { break; }   // torn tail — stop
      if (r.op === "set") this.store.set(r.key, r.value);
      if (r.op === "del") this.store.delete(r.key);
    }
  }
}

module.exports = WAL;

Thirty lines. Append-only for cheap writes, fsync for power loss, replay for boot, break for the torn tail. Every line is there because a specific failure put it there.

Proof: the real thing

Nothing is magic, so let's check our 30 lines against production source.

LevelDB (db/log_writer.cc, db/log_reader.cc) is our toy, hardened. Where we write newline-delimited JSON, it frames each record with a 7-byte header — 4-byte CRC-32C, 2-byte length, 1-byte type — inside fixed 32 KB blocks. On recovery it recomputes the CRC and drops a bad or short final record, exactly where our break gives up: a checksum instead of a thrown exception, same decision. And it's literally write-ahead — DBImpl::Write appends to the log first, then applies to the memtable, which is an in-memory map just like ours. WriteOptions.sync is what turns on the fsync.

PostgreSQL exposes the knobs. synchronous_commit decides whether COMMIT waits for the WAL flush — turn it off and commits return before they're durable, which is faster and means a crash can lose the last few transactions without corrupting anything. wal_sync_method picks the actual syscall (fdatasync, fsync, …). And our torn-record problem gets solved at the page level by full_page_writes: the first change to a page after a checkpoint logs the entire page, so a torn page can be rebuilt from the WAL. (If you've been reading the Postgres 18 io_uring work — this is the write path it feeds.)

SQLite makes the tradeoff a single config line: PRAGMA synchronous = OFF | NORMAL | FULL. That's our fsyncSync, promoted to a user-facing dial.

Same primitive underneath all three: append a record, choose how hard to fsync, replay on boot, tolerate a torn tail.

The one problem we didn't solve

Two loose ends, one small and one large.

The small one: fsync on a file isn't always enough. When you create a new file, the filename itself lives in the parent directory, and that directory entry needs its own fsync to survive a crash — otherwise a power cut can leave you with durable bytes and no name pointing at them. And we do hit this: on first run, fs.openSync(path, "a") creates data.wal. Every append after that is safe; the very first one needs an fsync on the parent directory too. Ours doesn't do it.

The large one: the log grows forever. Every set appends, even overwriting the same key a thousand times. Nothing is ever removed. Replay gets slower every day the process runs, and the file eventually eats the disk.

Which means the log needs a way to say: I've folded everything up to here into the main data structure, so the old records can go. That's a checkpoint — and when the main data structure is a sorted tree on disk, checkpointing becomes compaction, the beating heart of every LSM engine.

That's the next brick. See you there.


If this was useful, the connection pooling post is its older sibling.

A

I like how this derives the WAL from first principles rather than introducing it as a database feature. Once you frame everything around durability guarantees, concepts like append-only logs, replay, and fsync start feeling inevitable rather than magical.