Notes list: one sorted index, views told once per batch (#327) #394

Closed
kayg wants to merge 1 commit from issue/327-notes-index into dev
Owner

Part of #327. The biggest web cost by far: with 100,000 notes, the notes list did O(n log n) work on every change.

Why

Profiles (Chromium, CPU 4x, 100k Home) showed every page of the list load (200 pages of 500), and every save's journal refresh, doing the same full pass:

  • sorting and regrouping every note with localeCompare and a Date per note (groups: 7.3 s during 170 keystrokes);
  • recounting every tag (allTags: 1.7 s);
  • joining every id into one string (orderKey);
  • re-reading every key of a SvelteMap (store.notes).

Boot never went quiet: 33 s to interactive, 22 s blocked. WebKit took 1.1 s per keystroke.

What

  • lib/notes-index.ts: plain data. Notes by id; all notes newest change first, kept with one binary search per change (the server's own order, so loading only appends); tag counts; file → id.
  • notes.svelte.ts: views hear about a change once per batch, through one signal:
    • the first page shows at once, the rest together;
    • the next page is requested while the last one is read in;
    • store.notes is the same array until something changes.
  • groups(): no sort when the list is already in order (every list but the Journal). Buckets come from comparing ISO strings with each group's start, with no Date per note.
  • allTags() is rebuilt only when a count changes. byFile() is a map lookup.
  • NoteList: the FLIP key is the grouping itself, not every id joined.

No look changes.

Numbers (perf/web.sh, 100k notes; before = dev 583c46e, after = this branch)

metric before p50 / p95 after p50 / p95
Chromium boot: total blocking time 22196 / 22350 ms 1471 / 1489 ms
Chromium boot: time to interactive (cold) 33211 / 33370 ms 3621 / 9671 ms
Chromium boot: time to interactive (warm) 31808 / 32488 ms 3209 / 8262 ms
Chromium boot: first note (cold) 2851 / 3058 ms 2308 / 2368 ms
Chromium boot: first note (warm) 1433 / 2243 ms 832 / 1270 ms
Chromium blaze: live after the walk 2275 ms 265 ms
Chromium blaze: long tasks during the walk 196 (max 793 ms) 86 (max 249 ms)
Chromium blaze: heap growth over 50 notes 39.1 MB 1.9 MB
WebKit typing, 50,000-word note 1114 / 3943 ms 398 / 1429 ms
WebKit typing, nested list 705 / 2092 ms 279 / 593 ms
WebKit blaze: note shown 231 / 462 ms 91 / 123 ms
WebKit blaze: live after the walk 3273 ms 122 ms

Chromium typing once the list has loaded is unchanged (steady-state A/B, two rounds each: about 75–97 ms p50 both ways). The win there is in the seconds while the list loads and after every save, which no longer re-sort 100,000 notes. The host is shared (load 10–20), so single numbers are noisy. The boot rows were repeated in two interleaved A/B rounds (TBT 22–23 s before vs 2.2–3.0 s after).

Verified

  • e2e on chromium-1440: tags, tag-rename, switching, trash, tree, info, chips, tabs, links, day, smart-note, served. Also chromium-390 and webkit-1440 for tags, switching, trash, smart-note and day.
  • Every failure also fails on dev, or passes when rerun: tag-rename merge/escape (strict-mode duplicate text), links.spec:57.
  • bun run check is clean.
  • A randomized check of the index (20k mixed sets and deletes) matched a plain sort and tag count every round.

Budgets to tighten once #360 is in

web.chromium.boot.cold_tti@100000 becomes gated at 12000. Add web.chromium.boot.cold_tbt@100000 at p95 ≤ 3000, gated. web.chromium.blaze.live@100000 becomes gated at 1000.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

Part of #327. The biggest web cost by far: with 100,000 notes, the notes list did O(n log n) work on **every** change. ## Why Profiles (Chromium, CPU 4x, 100k Home) showed every page of the list load (200 pages of 500), and every save's journal refresh, doing the same full pass: - sorting and regrouping every note with `localeCompare` and a `Date` per note (`groups`: 7.3 s during 170 keystrokes); - recounting every tag (`allTags`: 1.7 s); - joining every id into one string (`orderKey`); - re-reading every key of a SvelteMap (`store.notes`). Boot never went quiet: 33 s to interactive, 22 s blocked. WebKit took 1.1 s per keystroke. ## What - **`lib/notes-index.ts`**: plain data. Notes by id; all notes newest change first, kept with one binary search per change (the server's own order, so loading only appends); tag counts; file → id. - **`notes.svelte.ts`**: views hear about a change **once per batch**, through one signal: - the first page shows at once, the rest together; - the next page is requested while the last one is read in; - `store.notes` is the same array until something changes. - **`groups()`**: no sort when the list is already in order (every list but the Journal). Buckets come from comparing ISO strings with each group's start, with no `Date` per note. - **`allTags()`** is rebuilt only when a count changes. **`byFile()`** is a map lookup. - **NoteList**: the FLIP key is the grouping itself, not every id joined. No look changes. ## Numbers (`perf/web.sh`, 100k notes; before = dev 583c46e, after = this branch) | metric | before p50 / p95 | after p50 / p95 | |---|---:|---:| | Chromium boot: total blocking time | 22196 / 22350 ms | **1471 / 1489 ms** | | Chromium boot: time to interactive (cold) | 33211 / 33370 ms | **3621 / 9671 ms** | | Chromium boot: time to interactive (warm) | 31808 / 32488 ms | **3209 / 8262 ms** | | Chromium boot: first note (cold) | 2851 / 3058 ms | 2308 / 2368 ms | | Chromium boot: first note (warm) | 1433 / 2243 ms | **832 / 1270 ms** | | Chromium blaze: live after the walk | 2275 ms | **265 ms** | | Chromium blaze: long tasks during the walk | 196 (max 793 ms) | **86 (max 249 ms)** | | Chromium blaze: heap growth over 50 notes | 39.1 MB | **1.9 MB** | | WebKit typing, 50,000-word note | 1114 / 3943 ms | **398 / 1429 ms** | | WebKit typing, nested list | 705 / 2092 ms | **279 / 593 ms** | | WebKit blaze: note shown | 231 / 462 ms | **91 / 123 ms** | | WebKit blaze: live after the walk | 3273 ms | **122 ms** | Chromium typing once the list has loaded is unchanged (steady-state A/B, two rounds each: about 75–97 ms p50 both ways). The win there is in the seconds while the list loads and after every save, which no longer re-sort 100,000 notes. The host is shared (load 10–20), so single numbers are noisy. The boot rows were repeated in two interleaved A/B rounds (TBT 22–23 s before vs 2.2–3.0 s after). ## Verified - e2e on chromium-1440: tags, tag-rename, switching, trash, tree, info, chips, tabs, links, day, smart-note, served. Also chromium-390 and webkit-1440 for tags, switching, trash, smart-note and day. - Every failure also fails on dev, or passes when rerun: tag-rename merge/escape (strict-mode duplicate text), links.spec:57. - `bun run check` is clean. - A randomized check of the index (20k mixed sets and deletes) matched a plain sort and tag count every round. ## Budgets to tighten once #360 is in `web.chromium.boot.cold_tti@100000` becomes gated at 12000. Add `web.chromium.boot.cold_tbt@100000` at p95 ≤ 3000, gated. `web.chromium.blaze.live@100000` becomes gated at 1000. 🤖 Generated with [Claude Code](https://claude.com/claude-code) 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
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:03 +00:00
Some checks failed
CI / server (pull_request) Successful in 25s
CI / rust (pull_request) Failing after 1m43s
CI / web (pull_request) Failing after 2m16s

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!394
No description provided.