Measuring Trilium startup requests
Two scripts in this folder do everything; don't reinvent them:
# 1. Capture a full startup (login → network quiet) into a JSON file:
TRILIUM_PASSWORD=<password> node .claude/skills/measure-startup-requests/capture-requests.mjs <out.json> [baseUrl]
# 2. Analyze captures:
node .claude/skills/measure-startup-requests/analyze-requests.mjs summary <capture.json> [--top N]
node .claude/skills/measure-startup-requests/analyze-requests.mjs probe <capture.json> [name ...]
node .claude/skills/measure-startup-requests/analyze-requests.mjs diff <before.json> <after.json> [--filter <regex>]
Prerequisites
- The dev server must already be running (
pnpm server:start, http://localhost:8080 by default).
Note which checkout it serves: the capture reflects the tree the server runs from, not your cwd
(verify with curl on a file that only exists in one tree if unsure).
TRILIUM_PASSWORD env var if the instance has a password. Without it the capture still
"succeeds" — it just never gets past the login page, yielding a plausible-looking ~178-request
file that measures nothing. Validate every capture before drawing conclusions: a real one
contains /api/ requests (/api/tree, /api/options, ...). Zero /api/ hits means you
captured the login screen.
- Playwright is resolved from
packages/trilium-e2e; the script prefers system Edge/Chrome, so no
playwright install is needed.
Workflow for lazy-loading work
- Capture a baseline before changing anything:
capture-requests.mjs baseline.json.
- Make the change (Vite dev picks it up automatically; a fresh headless session has no HMR state).
- Capture again and compare:
analyze-requests.mjs diff baseline.json after.json.
probe confirms specific heavy deps stayed off the boot path.
Timing one module graph, without logging in
The capture above needs a session. To weigh a single entry point instead — "what does reaching
CKEditor actually cost?" — skip the login entirely: Vite serves module URLs unauthenticated.
- URL form:
http://localhost:8080/assets/v<version>/@fs/<absolute repo path>/…/file.ts.
- Navigate a blank page to that origin,
setContent("<html><body>"), then take performance.now()
around await import(url).catch(() => {}).
- The
.catch is what makes this work. An ES module graph is fully fetched and instantiated
before any of it is evaluated, so a module whose evaluation throws without a session ("Logged in
session not found") still yields a valid download-and-instantiate measurement.
- Constructing an editor in that page needs
{ licenseKey: "GPL" }, or create() throws
license-key-missing.
This is how the "first text note is slow" delay was attributed to the module graph rather than to
the editor (dev server, 2026-08): packages/ckeditor5/src/index.ts ~290 ms / 216 modules and
EditableText.tsx ~355 ms / 328 modules, against PopupEditor.create() at 83 ms first and ~25 ms
after. That is why the idle preload (preloadCommonNoteTypes in note_types.tsx) is the lever —
and why the 428 KB emoji definitionsUrl fetch is not a suspect: EmojiRepository.init() fires it
without returning the promise, so it never blocks create().
Interpreting results
- Dev-mode numbers, not production. The dev server serves unbundled ES modules (~500+ script
requests is normal), so sizes are uncompressed and per-module. The module sets and import
chains are what matter; production chunk sizes differ.
- Request order ≈ import discovery order. To find what triggers a heavy load, look at the
seq of the first module of that package and at the /src/... modules requested just before it,
then confirm the chain by grepping for static importers.
- Sessions are stateful. Open tabs / the active note change what loads (e.g. a text note pulls
CKEditor legitimately). Totals between two captures are only comparable for the same session
state; prefer the
diff of targeted module sets, and treat full-MB totals as indicative.
- Never filter raw URLs. Dev URLs embed the absolute checkout path via
/@fs/..., so a
worktree named e.g. lazy-ribbon makes every request match /ribbon/. The analyzer normalizes
paths (strips host, ?v=/?t= params, /assets/vX.Y.Z, and the /@fs/<checkout> prefix) —
rely on that.
- Vite's hash-named shared chunks (
dist-XXXX.js) are identified by their .js.map in
.cache/vite-<port>/deps/ (each dev instance has its own): grep -o '"[^"]*node_modules/[^"]*"' <chunk>.js.map | ... and count by
package. (The 800 KB es-toolkit+mdast/hast chunk is CKEditor's internals, for example.)
Reference
The default probe list is the set of heavy deps that were deliberately made lazy (CKEditor,
highlight.js, KaTeX, codemirror-vim, snapdom, force-graph, the LLM chat graph, ...) — if one of
them reports LOADED on a plain board/empty note startup, a regression sneaked in. After the
2026-06 lazy-loading work the new-layout baseline was ~557 requests / 3.75 MB / 500 scripts
(down from 810 / 8.02 MB / 745).
Both eager-load offenders this file used to list have since been fixed (re-verified 2026-08):
applyModals in layout_commons.tsx now wraps every dialog in LazyDialog (a dynamic import()
on first summons), keeping only PopupEditor, CallToAction, Toast and ShortcutHintsPanel
eager — each with a comment saying why. The Inter font is served as woff2. Don't re-report either
as a finding; measure first.
1---2name: measure-startup-requests3description: Use when measuring what the Trilium client loads at startup — "what loads at boot?", "did this change reduce the startup bundle?", "is <dependency> lazy?", or any before/after comparison for lazy-loading / code-splitting work. Drives a headless browser through login against the running dev server, records every request, and analyzes captures (summary, heavy-dependency probe, before/after diff). Don't write a new throwaway Playwright script or inline node analyzers — both already live here.4---56# Measuring Trilium startup requests78Two scripts in this folder do everything; don't reinvent them:910```bash11# 1. Capture a full startup (login → network quiet) into a JSON file:12TRILIUM_PASSWORD=<password> node .claude/skills/measure-startup-requests/capture-requests.mjs <out.json> [baseUrl]1314# 2. Analyze captures:15node .claude/skills/measure-startup-requests/analyze-requests.mjs summary <capture.json> [--top N]16node .claude/skills/measure-startup-requests/analyze-requests.mjs probe <capture.json> [name ...]17node .claude/skills/measure-startup-requests/analyze-requests.mjs diff <before.json> <after.json> [--filter <regex>]18```1920## Prerequisites2122- The dev server must already be running (`pnpm server:start`, http://localhost:8080 by default).23 Note which checkout it serves: the capture reflects the tree the *server* runs from, not your cwd24 (verify with `curl` on a file that only exists in one tree if unsure).25- `TRILIUM_PASSWORD` env var if the instance has a password. **Without it the capture still26 "succeeds"** — it just never gets past the login page, yielding a plausible-looking ~178-request27 file that measures nothing. Validate every capture before drawing conclusions: a real one28 contains `/api/` requests (`/api/tree`, `/api/options`, ...). Zero `/api/` hits means you29 captured the login screen.30- Playwright is resolved from `packages/trilium-e2e`; the script prefers system Edge/Chrome, so no31 `playwright install` is needed.3233## Workflow for lazy-loading work34351. Capture a **baseline** before changing anything: `capture-requests.mjs baseline.json`.362. Make the change (Vite dev picks it up automatically; a fresh headless session has no HMR state).373. Capture again and compare: `analyze-requests.mjs diff baseline.json after.json`.384. `probe` confirms specific heavy deps stayed off the boot path.3940## Timing one module graph, without logging in4142The capture above needs a session. To weigh a *single* entry point instead — "what does reaching43CKEditor actually cost?" — skip the login entirely: Vite serves module URLs unauthenticated.4445- URL form: `http://localhost:8080/assets/v<version>/@fs/<absolute repo path>/…/file.ts`.46- Navigate a blank page to that origin, `setContent("<html><body>")`, then take `performance.now()`47 around `await import(url).catch(() => {})`.48- **The `.catch` is what makes this work.** An ES module graph is fully fetched and instantiated49 before any of it is evaluated, so a module whose *evaluation* throws without a session ("Logged in50 session not found") still yields a valid download-and-instantiate measurement.51- Constructing an editor in that page needs `{ licenseKey: "GPL" }`, or `create()` throws52 `license-key-missing`.5354This is how the "first text note is slow" delay was attributed to the module graph rather than to55the editor (dev server, 2026-08): `packages/ckeditor5/src/index.ts` ~290 ms / 216 modules and56`EditableText.tsx` ~355 ms / 328 modules, against `PopupEditor.create()` at 83 ms first and ~25 ms57after. That is why the idle preload (`preloadCommonNoteTypes` in `note_types.tsx`) is the lever —58and why the 428 KB emoji `definitionsUrl` fetch is not a suspect: `EmojiRepository.init()` fires it59without returning the promise, so it never blocks `create()`.6061## Interpreting results6263- **Dev-mode numbers, not production.** The dev server serves unbundled ES modules (~500+ script64 requests is normal), so sizes are uncompressed and per-module. The *module sets* and import65 chains are what matter; production chunk sizes differ.66- **Request order ≈ import discovery order.** To find what triggers a heavy load, look at the67 `seq` of the first module of that package and at the `/src/...` modules requested just before it,68 then confirm the chain by grepping for static importers.69- **Sessions are stateful.** Open tabs / the active note change what loads (e.g. a text note pulls70 CKEditor legitimately). Totals between two captures are only comparable for the same session71 state; prefer the `diff` of targeted module sets, and treat full-MB totals as indicative.72- **Never filter raw URLs.** Dev URLs embed the absolute checkout path via `/@fs/...`, so a73 worktree named e.g. `lazy-ribbon` makes every request match `/ribbon/`. The analyzer normalizes74 paths (strips host, `?v=`/`?t=` params, `/assets/vX.Y.Z`, and the `/@fs/<checkout>` prefix) —75 rely on that.76- Vite's hash-named shared chunks (`dist-XXXX.js`) are identified by their `.js.map` in77 `.cache/vite-<port>/deps/` (each dev instance has its own): `grep -o '"[^"]*node_modules/[^"]*"'78 <chunk>.js.map | ...` and count by79 package. (The 800 KB `es-toolkit`+`mdast`/`hast` chunk is CKEditor's internals, for example.)8081## Reference8283The default `probe` list is the set of heavy deps that were deliberately made lazy (CKEditor,84highlight.js, KaTeX, codemirror-vim, snapdom, force-graph, the LLM chat graph, ...) — if one of85them reports `LOADED` on a plain board/empty note startup, a regression sneaked in. After the862026-06 lazy-loading work the new-layout baseline was ~557 requests / 3.75 MB / 500 scripts87(down from 810 / 8.02 MB / 745).8889Both eager-load offenders this file used to list have since been fixed (re-verified 2026-08):90`applyModals` in `layout_commons.tsx` now wraps every dialog in `LazyDialog` (a dynamic `import()`91on first summons), keeping only `PopupEditor`, `CallToAction`, `Toast` and `ShortcutHintsPanel`92eager — each with a comment saying why. The Inter font is served as woff2. Don't re-report either93as a finding; measure first.