A keystroke should never wait for the disk
Adding backlinks made outl slow to open and edit on a big workspace: 2800 pages, 211k ops, and every Esc stuttered. The fix wasn't one trick, it was a rule. The op log is the truth, so everything else (the .md, the sidecar, the backlink index, the plugin hooks) is a projection that can happen in the background. How I found the real cost, the numbers before and after, and why the user should never feel any of the machinery.
outl has one promise it can’t break: opening a page and typing into it are instant. Everything else is negotiable. That one isn’t.
I imported my real workspace to test it. 2800 pages, 211k ops, six years of daily notes. Then I added backlinks, the “linked from” panel that shows every block pointing at the page you’re on. Small feature. And the whole thing got slow. Opening the journal took a couple of seconds. Pressing Esc to leave edit mode stuttered. Creating a line with o and typing lagged behind my fingers.
That’s the promise broken. So I stopped adding features and went to find out where the time went.
the machinery is mine, not yours
The user should never know how outl stores anything. Not the op log, not the CRDT, not the sidecar files, not the backlink index. They open a note and type. If any of that plumbing shows up as lag, the plumbing is wrong, not the user’s expectation.
So “make it fast” isn’t really the goal. Fast for its own sake doesn’t move the needle. The goal is to make the machinery invisible. Fast is just how invisible feels.
measure first, or you’ll fix the wrong thing
My first instinct was wrong. I assumed a big synced workspace meant a peer was constantly reloading the tree and holding a lock the editor needed. Plausible. Also completely wrong, and I only knew that because I put a timer on the commit and printed where the milliseconds actually went.
xychart-beta
title "where the commit's ~330ms went (debug build)"
x-axis ["lock wait", "render page", "sidecar SHA-256"]
y-axis "milliseconds" 0 --> 340
bar [0.01, 170, 160]
lock wait at basically zero killed the reload theory on the spot. The cost was plain work sitting on the thread that answers the UI. Not a lock, not the network, not the backlinks scan I’d have guessed second. Just rendering and hashing the page, three times per edit, in front of the next key.
One more thing that number hides: this is a debug build. Rust without optimization runs SHA-256 and text rendering about ten times slower than release. So the same commit was ~30ms in a real build. Still wrong to have it on the input path, but a good reminder to always read the release number before you panic. Debug lies, and it lies loudest exactly on the CPU-bound work you’re trying to profile.
three places the time was hiding
1. the reconcile rewrote the whole page every edit
outl keeps markdown 100% clean, no IDs in the file. The block IDs live in a sidecar. When you edit, the app diffs the new text against the old tree and emits operations. The bug lived in that diff. It handed every block a fresh fractional position on every commit, so every block looked like it had moved. A one-block edit in an eleven-block journal emitted 23 operations, each one fsynced to disk.
That’s slow twice. Slow on the Esc, because 23 fsyncs. And slow on the next boot, because the op log grew by the whole page on every keystroke, so replaying it took longer forever.
The fix was to reuse a block’s current position when its order didn’t actually change, and to drop operations that are already a no-op against the tree.
xychart-beta
title "ops written for a one-block edit (11-block journal)"
x-axis ["before", "after"]
y-axis "ops written" 0 --> 25
bar [23, 1]
23 to 1. The op log stops bloating, the fsyncs stop piling up, and boot stays flat as the vault grows.
2. the backlink index materialized the whole vault
To show “linked from”, outl needs an index of what points where. I built it the obvious way: walk every block, read its text, record its references. On a small vault, fine. On mine, that walk read the text of every block in the workspace, which forced outl’s lazy-loaded blocks (issue #179) to fully materialize, all of them, while holding the workspace lock. That was the “opening freezes / Esc freezes” bug, front and center.
Two changes. The index now reads the .md files off disk instead of walking the live tree, so it never materializes anything and never touches the lock. And it’s built on a background thread, so the journal paints immediately and the panel fills in a beat later. A local edit patches just the one page’s entries instead of rebuilding the world.
3. the write itself was on the input path
This is the big one.
When you leave edit mode, outl has to persist. The old commit did all of this before it let the UI respond: render the page to markdown, compute a SHA-256 for every block into the sidecar, write both files to disk, and (on the desktop) run the plugins’ hooks and wait for them. All synchronous. All in front of your next keystroke.
But none of that has to be synchronous, because none of it is the truth. The truth is the op log, and the op log write is tiny and fast. The .md file, the sidecar, the backlink index, they’re all projections of the op log. A projection can be a moment behind and nothing breaks, because the next time anything reads the truth it rebuilds them.
flowchart TD
K["your keystroke"] --> OP["op log<br/>the truth · written now"]
OP --> V["view from the tree<br/>responds immediately"]
OP -. "background" .-> MD[".md file"]
OP -. "background" .-> SC["sidecar (.outl)"]
OP -. "background" .-> BL["backlink index"]
So the commit got cut down to what’s actually load-bearing: apply the operation to the op log, build the reply straight from the tree in memory, return. The markdown-and-sidecar write goes to a background writer. The plugin hooks fire without an await. The keystroke never waits.
flowchart TB
subgraph before["before: everything runs before the UI responds"]
direction LR
A1["apply op"] --> A2["render .md"] --> A3["SHA-256 sidecar"] --> A4["write .md + .outl"] --> A5["await plugins"] --> A6["respond"]
end
subgraph after["after: only what's load-bearing"]
direction LR
B1["apply op"] --> B2["build view from tree"] --> B3["respond"]
B3 -. "queue" .-> B4[("background writer:<br/>render + sidecar + write")]
end
before ~~~ after
The scary part of making a write async is corruption. If two writes race, you could get a .md from one edit next to a sidecar from another, and outl’s whole “match the file back to the tree” step falls apart. Two rules stop that cold. There’s exactly one background writer, and it’s serial, so two projections never run at once. And it writes under the same workspace lock every other path already holds, so its write can’t interleave with anyone else’s. The pair is always written from one consistent snapshot.
And durability isn’t at risk either, because the op log is the truth and it was already written synchronously. If the app dies with a projection still queued, the .md is briefly behind, never wrong, never lost. The next boot re-projects the stale pages, and peers sync operations over the network, not the .md, so a lagging file never ships a wrong tree to another device.
the same rule in three clients
outl runs in three places: a terminal UI, a desktop app, and an iPhone app. The rule is now the same in all three. The UI updates the instant you act. The heavy write drains in the background.
The terminal does it by coalescing. Pressing Esc marks the page dirty and repaints, and the actual write drains the moment you pause typing, so a burst of edits collapses into one write when you stop. The desktop and phone do it with the background writer and fire-and-forget hooks. Different mechanics, same contract. Nothing you wait on ever runs a disk write.
xychart-beta
title "what you wait for when leaving edit mode (debug build)"
x-axis ["before", "after"]
y-axis "milliseconds" 0 --> 350
bar [330, 0]
what this cost to build, and why you’ll never see it
Adding this was not small. A background writer with coalescing and a serial queue. A second way to build the page view that reads from the tree instead of the file. A diff that reasons about which positions actually changed. A commit path in the terminal that defers and flushes on idle. Tests that prove the tree-built view is byte-identical to the file-built one, so the async path can’t drift from the sync one.
That’s real complexity, and it all lives under the floor. You get a note that opens now and takes your typing without a beat of lag, on a workspace with a quarter million operations in it, on a phone. The machinery got more complicated precisely so that your side of it got simpler.
That’s the trade I’ll make every time. The op log is the truth. Everything else can wait its turn, quietly, while you keep typing.