Boot under 1 s of blocking time at 10k and 100k notes (#327) #454

Closed
kayg wants to merge 10 commits from issue/327-boot-1s into dev
Owner

Part of #327. Boot "frozen time" under 1 s at 10k and at 100k notes (Chromium, CPU 4x, on the quiet perf VM).

Stacked on #360, #394, #402 and #407. This branch merges them, so the diff shows their commits until they land. The five commits of this PR are the last five.

Numbers (perf VM, flock /root/perf-vm.lock per run)

Before = #394 + #402 + #407 (2 rounds). After = this branch (4 rounds, 3–5 cold starts each). Each cell is p50 / p95 per round.

metric Home before after
TBT (web.dev: long tasks from first paint) 10k 1327/1550, 1212/1653 548/563, 431/494, 401/421, 296/402
100k 716/748, 811/928 339/477, 246/249, 206/265, 173/230
every long task from the address bar 10k 2261/3063, 2502/2787 1340/1381, 1138/1439, 990/1286, 871/1182
100k 1600/1786, 1862/1866 1285/1600, 845/1036, 775/946, 720/814
warm-start TBT 10k 308/596, 484/744 24/234, 27/133, 46/177, 33/94
100k 396/640, 385/813 163/346, 30/192, 73/92, 23/81
cold start, first note painted 10k 2578/3172, 2923/2985 2105/2118, 2020/2339, 1757/2183, 1645/2041
100k 1978/2265, 2224/2436 2237/2602, 1679/1893, 1605/1668, 1475/1539
layout shift both 0 0

WebKit has no long task timing, so it reports boot times only. After, two rounds:

  • 10k cold start 2177 / 1935 ms, warm 873 / 853;
  • 100k cold start 2156 / 1753 ms, warm 1013 / 859.

Before (#394–#407): 10k cold 2145 / 2115, 100k 1944 / 2807.

No regressions. Blaze, typing and memory, before vs after on the VM, 10k and 100k, both browsers:

  • blaze: live after the walk 142 → 117 ms (10k) and 154 → 93 ms (100k). Missed steps 0. Heap growth about 1.8 MB both. Note shown p50 77 → 78 ms (10k) and 80 → 69 ms (100k).
  • typing: per-key CPU within noise (10k long note 53 → 49 ms, nested list 94 → 95 ms).

What

  1. Harness. boot.cold_tbt now follows web.dev's definition (long tasks from the first contentful paint). boot.cold_blocked keeps the old sum of every long task from the address bar. Also boot.cold_fcp and boot.warm_tbt.
  2. Clouds in slices. The sky's clouds were a ~450 ms task right after the app showed. They are now worked out a few rows at a time (packages/ui/src/lib/slices.ts: scheduler.yield, with a message-channel hop in WebKit) and drawn in one go when done.
  3. Date labels. FlipLabel measured every letter of every list group label on render (torph, ~280 ms). It now shows plain text, and loads and sets up the morph on the first tap.
  4. Notes list, nothing walks 100k on the main thread.
    • groups() binary-searches and slices ordered lists.
    • The Journal reads day notes the index keeps in its own order.
    • NoteList computes the visible rows from the groups' sizes (lib/list-window.ts), with no row object per note. Checked against the old code for equal output.
  5. Editor goes live in place. When the live document matches the preview, only a compartment (read-only to live binding, plus the app's extensions) is swapped. Before, a new state re-parsed and re-laid out the whole note: the longest task of a cold start, and of every note going live after a walk.
  6. Smaller items.
    • syncThemeColor runs once per frame, after it is painted. It forced a whole-page restyle several times during start.
    • Peeked notes come 12 at a time, not 50: a 500 KB reply was a quarter-second parse.
    • The Settings bar builds at the next idle moment, not in the same long task as Settings.
    • The offline merge code (diff-match-patch) loads only when a merge runs.

Tried and dropped: splitting CodeMirror and Yjs into their own chunks (modulepreload). It was slower on the VM: 10k TBT 434/536 vs 401/296.

List page size and #422. The client keeps asking for 500 notes per page: each page then parses in under 50 ms on a 4x-slower CPU. If #422 makes pages larger, that parse needs to move to a worker, or pages stay at 500 for the background fill.

What is left above 50 ms

Before the first paint, the script evaluation (about 400 ms at 4x, mostly parsing CodeMirror, Yjs and Lezer) and the first mount and layout (about 450 ms). That is why the from-the-address-bar sum sits around 0.7–1.3 s.

Verified

  • e2e on chromium-1440, webkit-1440 and chromium-390: editor, switching, tabs, phone-bars, page-chrome, backdrops, safe-areas, info, tags, trash, tree, served, keyboard, links and day.
  • Failures only in links.spec:57, which also fails on dev. Five WebKit failures in backdrops and page-chrome came from a host at load 65; they passed 10/10 when rerun.
  • packages/editor: 130 unit tests pass, plus tsc. apps/web bun run check is clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

Part of #327. Boot "frozen time" under 1 s at 10k and at 100k notes (Chromium, CPU 4x, on the quiet perf VM). **Stacked on #360, #394, #402 and #407.** This branch merges them, so the diff shows their commits until they land. The five commits of this PR are the last five. ## Numbers (perf VM, `flock /root/perf-vm.lock` per run) **Before** = #394 + #402 + #407 (2 rounds). **After** = this branch (4 rounds, 3–5 cold starts each). Each cell is p50 / p95 per round. | metric | Home | before | after | |---|---|---|---| | **TBT** (web.dev: long tasks from first paint) | 10k | 1327/1550, 1212/1653 | **548/563, 431/494, 401/421, 296/402** | | | 100k | 716/748, 811/928 | **339/477, 246/249, 206/265, 173/230** | | every long task from the address bar | 10k | 2261/3063, 2502/2787 | 1340/1381, 1138/1439, 990/1286, 871/1182 | | | 100k | 1600/1786, 1862/1866 | 1285/1600, 845/1036, 775/946, 720/814 | | warm-start TBT | 10k | 308/596, 484/744 | 24/234, 27/133, 46/177, 33/94 | | | 100k | 396/640, 385/813 | 163/346, 30/192, 73/92, 23/81 | | cold start, first note painted | 10k | 2578/3172, 2923/2985 | 2105/2118, 2020/2339, 1757/2183, 1645/2041 | | | 100k | 1978/2265, 2224/2436 | 2237/2602, 1679/1893, 1605/1668, 1475/1539 | | layout shift | both | 0 | 0 | WebKit has no long task timing, so it reports boot times only. After, two rounds: - 10k cold start 2177 / 1935 ms, warm 873 / 853; - 100k cold start 2156 / 1753 ms, warm 1013 / 859. Before (#394–#407): 10k cold 2145 / 2115, 100k 1944 / 2807. **No regressions.** Blaze, typing and memory, before vs after on the VM, 10k and 100k, both browsers: - **blaze**: live after the walk 142 → 117 ms (10k) and 154 → 93 ms (100k). Missed steps 0. Heap growth about 1.8 MB both. Note shown p50 77 → 78 ms (10k) and 80 → 69 ms (100k). - **typing**: per-key CPU within noise (10k long note 53 → 49 ms, nested list 94 → 95 ms). ## What 1. **Harness.** `boot.cold_tbt` now follows web.dev's definition (long tasks from the first contentful paint). `boot.cold_blocked` keeps the old sum of every long task from the address bar. Also `boot.cold_fcp` and `boot.warm_tbt`. 2. **Clouds in slices.** The sky's clouds were a ~450 ms task right after the app showed. They are now worked out a few rows at a time (`packages/ui/src/lib/slices.ts`: `scheduler.yield`, with a message-channel hop in WebKit) and drawn in one go when done. 3. **Date labels.** `FlipLabel` measured every letter of every list group label on render (torph, ~280 ms). It now shows plain text, and loads and sets up the morph on the first tap. 4. **Notes list, nothing walks 100k on the main thread.** - `groups()` binary-searches and slices ordered lists. - The Journal reads day notes the index keeps in its own order. - `NoteList` computes the visible rows from the groups' sizes (`lib/list-window.ts`), with no row object per note. Checked against the old code for equal output. 5. **Editor goes live in place.** When the live document matches the preview, only a compartment (read-only to live binding, plus the app's extensions) is swapped. Before, a new state re-parsed and re-laid out the whole note: the longest task of a cold start, and of every note going live after a walk. 6. **Smaller items.** - `syncThemeColor` runs once per frame, after it is painted. It forced a whole-page restyle several times during start. - Peeked notes come 12 at a time, not 50: a 500 KB reply was a quarter-second parse. - The Settings bar builds at the next idle moment, not in the same long task as Settings. - The offline merge code (`diff-match-patch`) loads only when a merge runs. **Tried and dropped:** splitting CodeMirror and Yjs into their own chunks (modulepreload). It was slower on the VM: 10k TBT 434/536 vs 401/296. **List page size and #422.** The client keeps asking for 500 notes per page: each page then parses in under 50 ms on a 4x-slower CPU. If #422 makes pages larger, that parse needs to move to a worker, or pages stay at 500 for the background fill. ## What is left above 50 ms Before the first paint, the script evaluation (about 400 ms at 4x, mostly parsing CodeMirror, Yjs and Lezer) and the first mount and layout (about 450 ms). That is why the from-the-address-bar sum sits around 0.7–1.3 s. ## Verified - e2e on chromium-1440, webkit-1440 and chromium-390: editor, switching, tabs, phone-bars, page-chrome, backdrops, safe-areas, info, tags, trash, tree, served, keyboard, links and day. - Failures only in `links.spec:57`, which also fails on dev. Five WebKit failures in backdrops and page-chrome came from a host at load 65; they passed 10/10 when rerun. - `packages/editor`: 130 unit tests pass, plus `tsc`. `apps/web` `bun run check` is clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
kayg added 10 commits 2026-10-15 19:06:44 +00:00
Perf: a web client harness on big Homes, with budgets (#327)
Some checks failed
CI / server (pull_request) Successful in 22s
CI / rust (pull_request) Successful in 1m49s
CI / web (pull_request) Failing after 2m22s
dd3e19f5b9
perf/web.sh measures the app as production serves it, on the 10k and 100k
fixture Homes, in Chromium (CPU slowed 4x) and WebKit: bundle sizes (raw,
gzip, brotli), cold and warm boot to the first note painted, time to
interactive, keystroke to paint in a 50,000-word note and a 1,000-item
nested list, the blaze test from #271 (flick 50 notes at 100 ms and back),
scroll frame times, long tasks, memory and DOM growth, layout shift, and
the main thread's CPU time (the steady number on a busy machine).

The numbers are checked against new web.* budgets in perf/budgets.json with
the server harness's rules; CI gates the bundle sizes. docs/perf/web.md says
what each metric means and how to run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Notes list: one sorted index, views told once per batch (#327)
Some checks failed
CI / server (pull_request) Successful in 25s
CI / rust (pull_request) Failing after 1m43s
CI / web (pull_request) Failing after 2m16s
53c3da288d
With 100,000 notes, every page of the list load and every save re-sorted
and regrouped the whole list (localeCompare and a Date per note), recounted
every tag, joined every id into one string and re-read every key of a
SvelteMap. Boot never went quiet (33 s to interactive, 22 s blocked at 4x
CPU) and typing in WebKit took a second per key.

- lib/notes-index.ts keeps the notes by id, newest change first (a binary
  search per change; the server's own order, so loading only appends),
  tag counts and file → id, as plain data.
- notes.svelte.ts tells the views once per batch through one signal: the
  first page shows at once, the rest together; the next page is asked for
  while the last is read in. store.notes is the same array until a change.
- groups() no longer sorts what is sorted already, and buckets by
  comparing ISO strings with each group's start, no Date per note.
- allTags() is rebuilt only when a count changed; byFile() is a lookup.
- NoteList's FLIP key is the grouping itself, not every id joined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Typing: no whole-text copies per keystroke (#327)
Some checks failed
CI / server (pull_request) Successful in 29s
CI / rust (pull_request) Successful in 2m33s
CI / web (pull_request) Failing after 46m36s
d506f8160b
The two biggest pieces of JS per keystroke in a long note were copies of
the note's text, neither of them needed:

- frontmatterEnd copied the first 64 KB of the note on every call, several
  calls per keystroke (every note has frontmatter). It now walks lines
  without copying and remembers its answer per document.
- The note's metadata kept `base` (the whole text) in the same IndexedDB
  record as the fields that change on every keystroke, so each pending and
  confirmed flip cloned the whole text again. `base` now has its own key
  and is written only when it changed; older records still read.

Also: the editor re-reads the note's properties only when an edit reaches
them, and the bar's block list is replaced only when it held something, so
typing in the body no longer wakes the page style and the bar every key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
First note: its text over plain HTTP, not after the room catches up (#327)
Some checks failed
CI / server (pull_request) Successful in 26s
CI / rust (pull_request) Successful in 2m2s
CI / web (pull_request) Failing after 46m6s
75bdabe9a6
A note picked with nothing remembered (the first note after a cold start,
a note never near the list) showed an empty page until its live room had
joined and caught up over the socket. It now asks for the text with one
notes.peek at once and shows it the moment it arrives, read-only, as a
walked-to note already does; the live document attaches behind it as
before. An empty page waits for that text only when this device has no
copy of the note either, and never past the usual catch-up wait or a
click or key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
boot.cold_tbt now counts long tasks from the first contentful paint (the
standard definition); boot.cold_blocked keeps the old sum of every long
task from the address bar, and boot.cold_fcp says where that paint was.
Also boot.warm_tbt, and PERF_TRACE_CATS for extra trace categories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
- The sky's clouds were worked out in one go: half a second of a slow
  phone's main thread right after the app showed. They are now worked out
  a few rows at a time (lib/slices.ts: scheduler.yield, a message hop in
  WebKit) and drawn in one go when done.
- FlipLabel set up its letter morph on render, measuring every letter of
  every list group label (a quarter of a second). It shows plain text now,
  and loads and sets up the morph on the first tap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Nothing on the main thread walks every note any more when the list changes:
- groups() finds each group of an ordered list by binary search and
  slices it, instead of looking at every note;
- the Journal reads day notes the index keeps in its own order, instead of
  filtering every note and sorting them;
- NoteList works out the visible rows from the groups' sizes alone
  (lib/list-window.ts), instead of building a row object per note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
When the live document arrives with the text the preview already shows,
only the read-only binding is swapped for the live one (a compartment),
along with the app's extensions. The old way built a new editor state:
the whole note parsed and laid out again, the longest task of a cold start
and of every note going live after a walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Boot: theme colour once per frame, smaller peek batches, settings bar at idle (#327)
Some checks failed
CI / server (pull_request) Successful in 1m10s
CI / rust (pull_request) Successful in 3m8s
CI / web (pull_request) Failing after 44m55s
7872e2d2b2
- syncThemeColor read computed colours, forcing a restyle of the whole page,
  several times while the app started. It now runs once, after the frame.
- Notes' remembered text comes 12 at a time, not 50: a reply with 50 notes'
  text was a quarter-second parse on a slow phone.
- Settings, built ahead at idle (#173), builds its bar at the next idle
  moment rather than in the same long stretch (at once when shown).
- The offline merge code (diff-match-patch) loads only when a merge runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

This work is consolidated in PR #508: #508. Closing this superseded PR.

This work is consolidated in PR #508: https://git.kayg.org/kayg/calternal/pulls/508. Closing this superseded PR.
kayg closed this pull request 2026-10-16 17:35:08 +00:00
Some checks failed
CI / server (pull_request) Successful in 1m10s
CI / rust (pull_request) Successful in 3m8s
CI / web (pull_request) Failing after 44m55s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!454
No description provided.