How databases
survive a power cut.

Someone trips over the cable mid-write. When the machine boots again, your data is still correct. That trick has a name — the write-ahead log — and it fits in about twenty lines. Let's build one and then break it, repeatedly.

one mini key-value store, crashed many ways no prior database knowledge needed runs entirely in your browser
01 · The problem

Writing data in place is a gamble you eventually lose.

Here is the naive way to store a key-value pair: keep a file, find the row, overwrite it. It works perfectly right up until the instant it doesn't.

Say user:1 currently has a balance of 500 and we want to set it to 400. On disk that's three bytes to overwrite. Watch what a crash in the middle of those three bytes does:

Crash after writing
0 of 3 bytes

There is no safe moment. Overwriting is destructive: the old value is gone before the new one has fully arrived, so an interrupted write leaves a value that was never correct at any point in time. Databases call this a torn write.

Two enemies, and they are different. A lost write is data you were promised that quietly isn't there — bad, but the file still makes sense. A torn write is a value that is neither the old one nor the new one — the file itself is now nonsense, and no amount of retrying fixes it. Everything below is about defeating both.
02 · The rule

Write down what you're about to do, before you do it.

That's the whole idea. Keep a second file — the log — that only ever grows at the end. Before touching the real data, append a record describing the change and force it to disk. Only then apply it.

Appending is the key word. We never overwrite anything in the log, so there's no old value to destroy. A crash can leave the log's tail incomplete, but everything before that tail is untouched and still true.

function set(key, value) {
  const record = { lsn: nextLsn++, op: 'SET', key, value };

  appendToLog(record);   // 1. write the intent to the end of the log
  fsync(logFile);        // 2. force it out of the OS cache onto the disk
  memory.set(key, value) // 3. only NOW change the real data
  return 'committed';    // 4. and only now tell the client "done"
}

Steps 1 and 2 must finish before step 3, and step 3 before step 4. That ordering is the guarantee. Swap any two of them and the whole thing stops working — which is exactly what the crash simulator two sections down will show you.

Recovery is just reading the log back

After a crash, we don't try to repair anything. We reopen the data file, then walk the log from where the data file left off and re-apply every record we find:

function recover() {
  const state = loadDataFile();          // last known-good snapshot
  for (const record of readLog()) {
    if (record.lsn <= state.lastAppliedLsn) continue;  // already in the snapshot
    if (!checksumValid(record)) break;                  // torn tail — stop here
    apply(state, record);
  }
  return state;
}
One rule makes this legal: replay must be idempotent. Recovery can't know exactly how far it got last time, so it will sometimes re-apply a record that already landed. SET balance = 400 survives that — run it five times, same answer. balance = balance + 100 does not. This is why real WAL records store the resulting value or the raw page bytes, not the arithmetic that produced them.
03 · The write path, one step at a time

Watch the log run ahead of the data.

Six operations, broken into the four micro-steps from above. Drag through them and keep an eye on the gap between the log and the data file — that gap is doing all the work.

0 / 25
Write-ahead log
append-only · survives a crash once fsynced
Memory
volatile · vanishes on a crash
Data file
on disk · updated lazily

Notice how long the data file stays empty. For most of the run the only durable record of your committed writes lives in the log — and that's fine, because the log is enough to rebuild everything. Writing to the data file is an optimisation (it keeps recovery short), not the thing that makes your data safe.

Those checkpoint steps, at ticks 12 and 25. A log that only grows is a log that eventually fills the disk. Periodically the database flushes memory into the data file and records how far it got — then everything in the log before that point is dead weight and can be discarded. Run to the end and you can see the payoff: the data file finally holds the whole state, so recovery from there would have nothing left to replay. Checkpoint often and recovery is fast but you do more disk work; checkpoint rarely and it's the reverse. That's the whole tradeoff.
04 · Pull the plug

Three stores, same workload, one power cut.

The same six operations run in all three columns. Pick a moment, kill the power, and see who comes back intact. Try it at several different ticks — the point isn't any single result, it's which column is boring every single time.

tick 14 / 25

No log

Overwrite the data file in place. What most people write first.
Log
(none — that's the point)
Data file

Log, no fsync

Append to the log, but trust the OS to flush it eventually.
Log
Data file

Log + fsync

Append, force it to disk, and only then acknowledge the client.
Log
Data file

The middle column is the interesting one, because it looks like it's doing the right thing. It has a log. It appends before it applies. It just doesn't wait for the disk — and so it hands out promises the hardware never agreed to. That failure is silent: no error, no corruption, just a handful of confirmed writes that quietly aren't there any more.

05 · The two details everyone skips

Your write didn't reach the disk, and your record might be half a record.

The middle column failed for a specific reason worth understanding, and the left column failed for a different one. Here they are, one at a time.

a. write() returning success is not durability

When you call write(), the kernel copies your bytes into its page cache and returns immediately. That's a deliberate performance decision — it's why writes feel fast. But the data is still in volatile RAM. Only fsync() asks the drive to actually persist it and waits for the answer. Click any stage below to cut the power at that point:

your code
record in a buffer
⚡ power cut
OS page cache
write() returned "ok"
⚡ power cut
drive cache
fsync() in progress
⚡ power cut
persistent media
fsync() returned
⚡ power cut

b. A half-written record is not a half-truth — it isn't a record

Appending is safe, but the append itself can still be interrupted. So every log record carries its own length and a checksum of its contents. On recovery we read records until one fails its checksum or runs off the end of the file, and then we stop — everything after that point is discarded, unread.

Here's one record, SET user:1 = 400, laid out as 17 bytes. Drag to choose how many of them made it to disk before the crash:

Bytes on disk
17 / 17
len (2B) checksum (4B) key (7B) value (4B)
Why this is safe rather than lossy. A record whose checksum fails was, by definition, never acknowledged to the client — the acknowledgement only happens after fsync() confirms the whole record landed. So discarding a torn tail throws away exactly the writes nobody was ever promised. That's the deal WAL makes, and it's why the ordering in section 2 is non-negotiable.
06 · Making it fast again

fsync is slow. That doesn't mean durability has to be.

Waiting for the disk costs roughly a millisecond. Do that once per commit and you're capped at about a thousand commits per second, no matter how big your server is. The fix is to stop doing it once per commit.

If ten clients commit at the same moment, their records are all sitting in the same log, next to each other. One fsync() makes all ten durable. So the database waits a brief window, collects whatever arrives, flushes once, and acknowledges everybody together. Slide the number of concurrent clients:

Clients committing at once
12
waiting to be flushed fsync (0.8 ms)
Group commit versus one fsync per commit
StrategyThroughputWorst-case commit latencyScales with clients?
Batching is a trade, not a free win. At low concurrency it makes every commit slower — a lone client now waits out the batching window for nothing. It only pays off when there's a queue. That's why real systems make the window tunable (commit_delay in Postgres, linger.ms in Kafka) and default it to roughly zero.
07 · The same three moves, everywhere

You have been using write-ahead logs for years.

Append the intent · make it durable · apply it lazily. Once you can see that shape, you start finding it in almost every system that promises not to lose your data.

PostgreSQL

Every change is a WAL record written before the corresponding data page is touched. Checkpoints flush dirty pages and let old segments be recycled.

the log is also the replication stream

SQLite

In WAL mode, writers append to a -wal file and readers see a snapshot, so a reader no longer blocks a writer. "Checkpointing" folds the WAL back into the database.

PRAGMA journal_mode = WAL

Apache Kafka

Here the log isn't an implementation detail — it's the product. A partition is an append-only log, and consumers are just readers replaying it at their own offset.

offset ≈ LSN

Raft & consensus

Each node keeps a replicated log. An entry is committed once a majority have it durably appended; state machines then apply entries in log order.

same idempotent-replay requirement

ext4, XFS, NTFS

Filesystem journaling. The metadata change is journalled before the blocks move, so a crash can't leave a directory entry pointing at nothing.

that's what fsck replays

LSM engines

RocksDB, Cassandra, LevelDB. Writes go to an in-memory memtable — which is pure volatile state — plus a WAL. The WAL is what makes the memtable safe.

memtable flush = checkpoint

The mental model, in one breath

  1. Never overwrite in place if you can append instead. Appends can be cut short; overwrites destroy the old value on their way to failing.
  2. Order is the guarantee. Append → fsync → apply → acknowledge. If you acknowledge before the fsync returns, you are lying to your callers.
  3. Recovery is replay, not repair. Which is only correct if replaying a record twice is the same as replaying it once.
  4. Every record carries a checksum so recovery can find where the truth stops and discard the rest — writes nobody was ever promised.
  5. Amortise the fsync, don't skip it. Batch many commits into one flush; the moment you skip the flush you're back to the middle column.