Docs Performance

The writer

Commands a second with synchronous FULL and NORMAL, at 1, 50 and 200 producers, what a transaction costs, and what checkpoints do to the slowest commits.

BenchmarkWriter (writer/bench_test.go) runs the writer alone, without HTTP: 1, 50 or 200 producers, each moving money between ten accounts of its own and waiting for the commit with Do, as a request handler does. A command reads a balance, debits one account, credits another, appends to a ledger and touches a topic: four statements, plus the writer's SAVEPOINT and RELEASE. SQLite checkpoints the WAL itself (CheckpointAuto). Measured on 2026-10-12, medians of three runs.

Commands a second, by producers waiting with Dobench/RESULTS.md
020K40K60K80Kcommands a secondproducersFULL, producers 1: 526 commands a secondNORMAL, producers 1: 13,049 commands a second1FULL, producers 50: 13,260 commands a secondNORMAL, producers 50: 63,267 commands a second50FULL, producers 200: 34,893 commands a secondNORMAL, producers 200: 66,688 commands a second20052613,04913,26063,26734,89366,688
  • FULL
  • NORMAL
The numbers
producersFULLNORMAL
152613,049
5013,26063,267
20034,89366,688
synchronous Producers Commands a second Commands per transaction ms per transaction
FULL 1 526 (511 to 579) 1 1.9
FULL 50 13,260 (12,357 to 14,025) 25 1.9
FULL 200 34,893 (27,317 to 37,044) 122 3.5
NORMAL 1 13,049 (11,199 to 15,793) 1 0.07
NORMAL 50 63,267 (62,423 to 79,296) 26 0.39
NORMAL 200 66,688 (63,440 to 77,502) 109 1.4

A transaction's time runs from BEGIN IMMEDIATE to the end of COMMIT.

FULL waits for the disk

With synchronous=FULL, a producer alone waits for one fsync per command, 1.9 ms on this disk. Fifty producers share each fsync, 25 commands at a time, and get 25 times as far; at 200 a transaction takes 122 commands. The writer needs no setting for that: while a batch commits, the next commands queue, and the next batch takes all of them.

NORMAL is bound by the CPU

With NORMAL the writer costs about 13 µs per command at 200 producers. In a CPU profile of that case, sqlite3_step takes 42% of the process's samples, the rest of the writer's goroutine (database/sql, the driver's binding, locks) 32%, and the garbage collector 8%. The writer's own code, its queue, savepoints and statement cache, is under 2%.

What NORMAL costs is durability. It syncs the WAL only at checkpoints, so a power loss or a kernel crash can undo the commits since the last one, up to about 35 s of them with Linux's default writeback (vm.dirty_expire_centisecs 3000, flushed every 5 s). A crash of the process loses none. lith defaults to NORMAL; set sqlite.Options.Synchronous to "FULL" where an acknowledged commit must survive a power loss, at one fsync per batch.

Checkpoints

At 200 producers with CheckpointAuto, the slowest 1% of transactions take 7 to 26 ms: SQLite checkpoints inside the COMMIT that brings the WAL past 1000 pages. BenchmarkWriterCheckpoint runs the same case with sqlite.CheckpointWriter, where the writer checkpoints after it has answered the batch:

synchronous Producers Commands a second Per transaction ms per transaction p99 ms per transaction
FULL 200 45,100 (44,075 to 46,228) 125 2.6 4.6 to 5.3
NORMAL 200 71,381 (68,629 to 74,217) 102 1.1 3.0 to 3.8

The checkpoint is no longer part of a transaction's time: the producers queue their next commands while it runs. The template and this site open their databases with CheckpointWriter.

A real command

kanban's BenchmarkMoveCard on the same machine, on 2026-10-08 with FULL, moved 240 to 340, about 5,100 and about 13,800 cards a second with 1, 50 and 200 people, in transactions of 1, 25 and 103 moves. A move runs about fifteen statements, and a transaction alone took 2.9 to 4.2 ms. Before the writer prepared each SQL text once, modernc's parsing and planning took 41% of the writer's CPU at 200 people, and the same run moved 10,000 cards a second.

Anders Murphy's benchmark, two updates per transaction on an M1 MacBook, reports 98,163 transactions a second with a savepoint each and FULL. Without PRAGMA fullfsync, macOS's fsync does not wait for the drive's cache, so NORMAL is the closer comparison here. His workload is smaller than a real command: the shape carries over, the figures do not.

Measuring again

shell
taskset -c 0-11 go test ./writer -run '^$' -bench Writer -benchtime 5s -count 3

-bench Writer runs BenchmarkWriter and BenchmarkWriterCheckpoint, each with FULL and NORMAL.