oficina / docs Public

worktree-debounce: an edit reaches your other tabs in 250ms, not two seconds closed

On branch worktree-debounce

Opening this at the first commit rather than the last, which is what the previous branch got wrong.

Goal: an edit reaches your other tabs faster than you can notice the delay. Paul's number: 250ms, down from 2000ms.

Why this is possible now and was not last week

DEBOUNCE_MS was never about network smoothing. Every accepted POST is one doc_updates row, and core.MaxRetainedUpdates capped a document at 4096 of them — after which it stopped accepting edits entirely. 2000ms was headroom against that cliff: 4096 × 2s ≈ 2h16m of continuous typing before a document went read-only. At 250ms the same arithmetic gave about eight and a half minutes.

Snapshots removed the cliff (6bb0a03), so the constant is now free to be chosen for how it feels rather than for how long it postpones a failure.

What it costs, stated before doing it

Eight times the row rate. A continuously-typing person goes from roughly 1,800 doc_updates rows an hour to about 14,400. Each row is also smaller and carries less content — at 250ms a row holds a character or two rather than a sentence.

Snapshots make that safe to replay: the tail stays bounded by the cadence, so opening a document costs the same as it does today. What it does not bound is storage. §4.1's compaction horizon — the mechanism that drops superseded updates — is still deferred, so the log and the Litestream WAL it replicates grow eight times faster than they did yesterday. That is a deliberate trade for latency, and it makes compaction the next piece of work rather than a someday.

The honest alternative would be coalescing at the storage boundary (§4.1's first mechanism): let the client send at 250ms while the server merges same-actor updates arriving within a short window into one row. That keeps the latency and drops the row rate back down, and it belongs with the WebSocket transport, which needs the same machinery. Not this change.

What this branch does

  • DEBOUNCE_MS 2000 → 250 in fixtures/editor-entry.js, and the rebuilt bundle.
  • Rewrites that constant's comment. Its current justification is the cap arithmetic, which is now false — the exact "stale derived number outliving the thing it was derived from" failure the snapshots branch hit five times.
  • Updates the live-sync spec's §7.5, which states in as many words that "live means within about two seconds of the typist pausing".
  • Measures what actually changed: end-to-end cross-tab latency in a real browser, and the row rate under sustained typing.

What I will not change without asking

The snapshot cadence stays at 256. At 250ms that is a snapshot roughly every 64 seconds of continuous typing rather than every 8.5 minutes — more frequent snapshot work, but each one supersedes its predecessor, so storage stays at one blob per document and the per-POST replay cost is unchanged. If measurement says that is wrong, I will report it rather than quietly retune it.

Measured, both ways, in two real browser tabs on one document — last keystroke to the marker being visible in the other tab, which is what a person experiences rather than the debounce alone.

| | median | min | max | |---|---|---|---| | 2000ms (before) | 2032ms | 2028 | 2033 | | 250ms (after) | 284ms | 283 | 285 |

Five rounds each, same harness, same machine, localhost. The gap between the constant and the measurement is ~33ms in both — POST, broadcast, hub fan-out, SSE notify, ?since= fetch, Yjs apply and render. That figure being identical at both settings is the useful part: it says the transport is not the cost, the debounce was, which is what the earlier design claimed and had never actually measured.

7.2× faster, and the number is now under the threshold where a delay reads as the app thinking rather than responding.

One thing the measurement taught me, which is not a defect

The first run failed completely: five rounds, nothing arrived, while doc_updates showed the rows had been written. Saving worked; propagation did not.

The cause is the hibernation policy working exactly as designed. In headless Chromium the second tab is backgrounded, so document.hidden is true, and live-updates.mjs closes its stream immediately — a parked tab must stop holding its instance awake (§7.3, and the load-hidden case was added deliberately in the live-sync work). My harness was measuring a tab built not to listen.

The fix was Emulation.setFocusEmulationEnabled on both targets, which is the CDP override built for this, not a workaround. Worth stating plainly for anyone who later wonders why two tabs in one browser do not both update: the tab you are not looking at deliberately stops streaming, and catches up the moment you look at it — visibility fires, the stream reopens, and the connect-time event tells it what it missed. That is the intended behaviour and it is what keeps a document left open on Friday from pinning an instance all weekend.

Row rate

Unchanged in this measurement (five rounds, five rows) because each round is one short burst followed by a pause — the debounce coalesces exactly as it should. The eight-times figure in the commit message is the sustained-typing worst case, not what ordinary writing produces; real writing pauses, and every pause longer than 250ms closes a window that would otherwise have stayed open. The storage consequence is real but its worst case is rarer than the arithmetic suggests.

Merged as 2392ea3 and deployed; docs.bds.oncarlos.com is serving it. Landing recorded in the ledger.

One process note worth leaving here rather than losing: the branch was created by a checkout that silently failed on a config lock, so the commit landed on the previous branch while worktree-debounce stayed at the base. The first CI verdict I read was therefore green on main's commit, not on this change — the version in the status line is what gave it away. Reading the sha rather than the word 'passed' is the check that catches it, and it is the same shape as this repo's own rule about tools that answer confidently instead of failing.