Performance field notebook

What we measured, changed, declined and learned making Bristlenose open faster. Two days of work (4 to 5 November 2026), written so it can be distilled later into a long-form piece on how to do performance work without fooling yourself.

Source of truth for every number: docs/design-perf-ledger.md and docs/design-standby-sidecar.md. App timings are from a Debug build of the macOS app, sandboxed, on one Apple Silicon Mac; a Release re-run at the end showed no difference to the web-side numbers (see "The biggest caveat, and the check we then ran").

Summary

−0.6 soff every study opened from the Welcome screen: standby sidecar, measured in the app (1,824 → 1,238 ms click to report)
−30%sidecar start from one lazy import (inflect): 3.19 → 2.24 s cold, in the app
−30%spawn to first health check from loading the MCP endpoint on first use: 1,454 → 1,005 ms
75%off the first click on Sessions: 361 → 86 ms (preload the chunk when idle)

The shape of the result: the big wins were not tuning. They were finding work that did not depend on the thing the researcher had just asked for (importing a plural library, building an agent endpoint nobody had called, starting a Python process that could have been started earlier) and moving it out of the click. What is left is smaller, harder, and mostly in two places we have deliberately not touched yet: the server's bind step and the web view's own startup.

Two things also went wrong in instructive ways. A fix to return the window to its lens after an analysis passed its unit tests and did nothing in the real app (a run replaces the page and records the wrong lens). And a "regression" in the standby turned out to be a changed macOS setting under the test harness. Both are in What we learned.

Method

The loop, every time: instrument, change one thing, re-measure, stop at a number you wrote down before you started. The rest is discipline about not fooling ourselves.

Rules we held to

  1. Label every number: measured a repeatable run; estimate reasoned from a measured part, with the reasoning shown; unmeasured no number, and none guessed.
  2. Interleave A/B. Alternate the arms (off, on, on, off…) so drift in machine load hits both. Report the spread, not just the median.
  3. Same binary, one switch. The standby was toggled by an environment variable on one app build, never by comparing two builds.
  4. Measure in the real thing. A dev-server number is a hypothesis. The sandboxed app's own signposts decide.
  5. Write the stop number first. "Good enough" is a figure, so a pass can end by succeeding or by declining.
  6. Declined is a result. A change that does not pay is recorded with its number, so nobody re-proposes it.

Instruments we built

  • Signposts in the Mac app (os_signpost): launch, project select, serve ready, and one named step per stage between the click and the report being ready.
  • Server stamps: BRISTLENOSE_STARTUP_TRACE and Server-Timing on routes.
  • Harness scripts: a launcher that drives the real app through Accessibility, an interleaved A/B runner, a bare WKWebView loader, a Chromium timeline script.
  • A spend guard: the harness aborts if the study's LLM call log grows, because some trial launches start a paid job.
  • Isolated Swift verification: build in a throwaway worktree with only your own files, so a concurrent session's half-saved edit cannot flip your result.
  • Mutation proof: revert the fix and see that exactly the new test fails. A test you have not seen fail is a guess.

What we did, in order

WhenWhatOutcome
4 NovWindow resize, sidebar hold, selector fix10 → 59 window updates per second; web content main thread 4,145 → 1,705 ms (−58%).
4 NovLedger opened; lazy inflect, libyaml codebook, Keychain cache, export skip, gzip levelCold sidecar start −30% from the plural library alone; codebook import 130 → 40 ms; gzip changed nothing at real size, and was kept only because it is free.
4 NovSessions grid chunk preloaded when idle; WebKit has no idle callback, so a 250 ms timer fallbackFirst click on Sessions 361 → 86 ms in Chromium; 366 → 101 ms in WebKit.
4 NovMCP endpoint built on first request; Screens availability check off the event loopSpawn to first health 1,454 → 1,005 ms; with Screens on, 1,450 → 1,170 ms. A code review caught that the health route still imported the SDK.
5 NovStandby sidecar: plan, independent review, build, second review, fixSpare Python process booted ahead and claimed over stdin. Review found a blocker no unit test saw: nothing owned the adopted process, so it was freed after the claim.
5 NovMeasured the standby in the sandboxed app, interleavedserve-ready 1,140 → 516 ms cold; click to report 1,824 → 1,238 ms. Sandbox lease inheritance works.
5 NovRestored-launch trialFound a correctness bug (restored windows came back empty on macOS 27) and declined the "earlier spare" idea: the window asks for its sidecar 0.13 s after launch.
5 NovWeb view pre-warmMeasured, declined.
5 NovBusy-aware reap; lens restore across relaunch and across a finished analysisA study with paid work in flight is no longer reaped for lack of a window. The lens fix first passed tests and failed live, then was corrected and verified on two lenses.
5 NovAttributed the time after the port answers; bundle and mount passFive new signposts, two page-load harnesses; no cheap win in the bundle (see below).

The numbers

Before and after, per change

beforeafter
Per occurrence, in milliseconds, as measured for each change (the ledger row has the exact method). Different moments, so read each pair on its own: the scale only shows that the early changes are large and the late ones are not. Midpoints are used where the ledger gives a range.

Where the time goes between click and report

sidecar boot and bindport poll web view startpage loadReact mount to readyother / native
Cold open of a real-size study in the sandboxed Debug app. Top bar: standby off, medians of 6 launches. Bottom bar: standby claimed, from the A/B (516 ms to serve-ready, then the web side, with about 110 ms not attributed). The standby removes the boot but not the bind or the web side.

The law behind the standby

Waiting after the click = max(boot − head start, 0) + bind. Boot is 0.64 s, bind 0.75 s. A spare with a head start of 0.64 s or more saves the whole boot; claiming it early gives the study exactly the head start the spare had and nothing more, because boot and bind are in series. This is why "claim while it is still booting" was dropped.

The web side: our app against bare WebKit

load call to navigation startnavigation start to did-finishdid-finish to ready
The same report, a real-size study. Bare WKWebView outside the app: warm process 57 / 67 / 46 ms; first load of a process (which pays about 110 ms of content-process launch) 166 / 134 / 57 ms. In the app: 168 / 272 / 148 ms, 14 launches, plus 23 ms of our own view setup before the load call. Against the equally cold bare load the app is about 230 ms slower, and the gap sits in the load phase (272 vs 134 ms) and the mount phase (148 vs 57 ms), not in process start (168 vs 166 ms). A Release build did not close it, and a main-thread sample points at our own UI work at a project switch (see the caveat section).

A restored launch

Launch to report ready on a restored window, 5 launches per arm, interleaved. The standby does not cost a restored launch (+5 ms; an earlier +73 ms did not reproduce). It also does not help it: the window asks for its sidecar 0.13 s after launch, before any spare could be ready.

Why the standby cannot help a relaunch. macOS restores windows by default, so most relaunches skip the Welcome screen. The standby earns its 0.6 s on the first study opened from Welcome and on every later open in the session (a replacement spare is started after each claim).

The thing that looked like a regression was this: the harness had been written while the system switch for restoring windows was off, so every launch landed on Welcome and then clicked. With the switch back at its factory default, every launch restored the study at once and no spare existed in time. The build was fine. The test had changed under us.

Declined, tried and not kept

IdeaResultWhy we stopped
Pre-bind the last-opened studyNot builtThe server's app construction is welded to the study (database, watchers, media, paid background jobs). A quiet bind is a core refactor, and risks starting paid work on a study nobody chose.
Claim the spare while it is still bootingDropped before buildingMeasured law: finishes at the same moment as waiting for ready.
Spawn the spare earlier, before the window asksDeclinedWindow asks at 0.13 s; the best case saves 0.1 to 0.2 s for a hook before window creation.
Pre-warm a web content processDeclined, in the appDid not pay when measured. Worth one re-test with the bare numbers (see backlog).
Delay the Screens job by 1.5 sDropped1,223 vs 1,191 ms: nothing.
Bulk-validate transcript wordsByte-identical output, a 300 → 266 ms first readNot kept: the interleaved numbers did not hold the gain.
Startup importer skip-when-unchangedAbout 70 ms at real sizeDeclined at real size.
Further import work on Python startAbout 0 gain left740 ms of diffuse imports; no single item over 35 ms except the MCP one, already done.
Playback follow paint costNo difference in WebKitThe layout-pass fix landed; the rest was headless software rendering.
Scene strips off to speed the first loadready 357 / 354 / 367 ms with them on, 386 / 399 / 397 ms off (bare view right after bind, 3 launches per arm)Turning them off did not help (off was slower; n = 3, nothing more claimed). The first-load penalty is not their contention.

What we learned

About the product

About doing the work

The biggest caveat, and the checks we then ran

Every app number above is from a Debug build (apart from the checks below), and the in-app web side runs about 230 ms slower than bare WebKit. We tested the obvious suspects in turn.

1. Release build. Same signing and sandbox entitlements as Debug, so optimisation was the only change. View made to ready was 596 ms (standby off) and 567 ms (on), against 604 ms in Debug; the mount phase may be about 40 ms faster (inside the spread). Debug is not the cause.

2. Settling the sidecar and warming the process (a busy Mac, load 4 to 13). After the sidecar had settled, loads made by switching away and back took 460 ms median (383 to 528), against 671 ms for the first load: about 210 ms of the first-load premium is the cold process and the sidecar's own startup work. But a floor stays: made to navigation start is still 200 to 230 ms in the app against 57 ms bare.

3. A main-thread sample. One sample of the app's main thread during a project switch: of 746 samples, about 460 are in the Core Animation transaction flush, about 330 of them AppKit layout (the sidebar creating row views, the toolbar's layout, NSHostingView layout) and about 90 to 150 SwiftUI view-graph updates. The web view cannot start navigating while the main thread is in that. The cause is most likely our own UI work at a project switch, not the sandbox. One sample, not repeated; the bare view was not run sandboxed, so the sandbox is not ruled out.

The Release app is a measurement build only: Debug entitlements, no hardened runtime. It says nothing about an App Store build's signing cost.

Backlog: opportunities, costs, pros and cons

Gains are per occurrence, with the label that says how much to trust them. Cost is engineering effort: S under a day, M a few days, L weeks. "Frequency" is how often a researcher meets the moment.

#OpportunityMoment, frequencyGainCostProsCons and risks
1Re-run the app harness on a Release build doneDecided items 3 and 4measured no change: 596 / 567 ms against 604 in Debug; about 40 ms at most in the mount phaseS (an hour)Removed the biggest caveat.Measurement build only (Debug entitlements, no hardened runtime).
2Idle CPU and energy measurement (powermetrics)App open all day, continuousunmeasured polls: agent pulse 1.5 s, screens 3 s, run 1 s, plus the web content processS (needs sudo)For an app that stays open, battery is felt more than a 100 ms open.Needs an idle machine and an elevated run; may find nothing to fix.
3Web view start-up: re-test pre-warm with the bare numbers (about 110 ms is content-process launch)First study open of a session, dailyestimate ≤ 110 ms, first open onlyS (a day)Small and contained; harness exists.Earlier in-app test said it does not pay. Hidden pre-emptive work must be invisible and failure our problem, never the user's.
4Free the main thread at a project switch: the web view cannot start navigating while the app is busy laying out the sidebar, toolbar and SwiftUI graphEvery study open and every study switch, dailyestimate up to about 170 ms of the made-to-start phase (228 ms in app against 57 bare); one main-thread sample, not repeatedM (profile, then change)Our own code, so it is actionable; helps every switch, not only the first open.One sample on a busy Mac. Needs a Time Profiler pass to find what rebuilds the sidebar rows and re-lays-out the toolbar; the cure may touch the AppKit sidebar, which is risky ground.
5QuoteCard memoisation and selector subscriptionsSearch, tag, star, keyboard moves; many a dayestimate search echo 170 → under 16 ms at 1,500 quotes. At real size the Quotes revisit render is only 15 ms of 117 (fetch is 92)S to MProtects large studies; low blast radius.At typical size the gain is small. Ownership overlapped another session's display-preferences work.
6Lens revisit from last-known data (stale while revalidate), with per-lens scroll positionSwitching lenses, many a dayestimate Quotes revisit about 117 ms → near 0 to 20 ms perceivedMThe most felt interaction gain; pairs with remembering scroll position.Staleness and invalidation bugs are the classic failure; owner's call, parked.
7Per-study warm worker / split bind from the studyEvery study open, dailymeasured bind is about 440 ms; and would let a restored launch benefit from a spareL (weeks)The biggest remaining lever; also fixes the restored-launch case.Core server refactor; paid-job risk; changes lifecycle and the sandbox lease story. Do as a designed project, not a patch.
8Performance regression gate (a ratchet on a few numbers)Protects every gainPrevents loss, not a gainMGains rot silently without it; the ratchet pattern already exists for other gates.Timing is noisy on shared CI; use ratios and wide margins, or run on a pinned machine.
9Startup importer skip-when-unchangedStudy start, dailymeasured about 70 ms at real sizeMCheap per study.Declined at real size; revisit only if studies grow.

Value against cost

Position is a judgement, not a measurement: horizontal is engineering cost in days (a log-ish scale), vertical is the gain a researcher would feel, from our estimates. Bubbles are the backlog numbers. The two top-left items are measurements that decide the rest.

Sequencing

Dependencies are the point: measure before building, and keep the large architecture item out of the critical path of anything shippable.
  1. Now (measure only): the Release-build re-run (#1) is done and ruled Debug out. Idle energy (#2) is next: it changes nothing in the product and can kill or confirm other items.
  2. Next (small, contained): web view start-up re-test (#3). The in-app gap (#4) is reopened with a better cause: the main thread is busy with our own layout at a project switch. It needs a profiler pass before any change. Memoisation and selectors (#5) when the display-preferences work has landed.
  3. After launch: lens revisit from last-known data together with per-lens scroll position (#6), since they share the same state model; then the regression gate (#8) once the numbers have stopped moving.
  4. As a designed project: the warm worker (#7). It deserves its own design note first, and its decision rule is the standby's law: it must beat bind (440 ms) by more than it costs in memory and lifecycle complexity.

Notes for the essay

Angles that came out of the work and could carry a piece on their own:

Appendix: the raw figures used above

FigureValueSource
Standby off, serve-ready cold / warm1,140 / 1,106 msapp A/B, 6 reps
Standby on, serve-ready cold / warm516 / 508 msapp A/B, 6 reps
Click to report ready, off, cold / warm1,824 / 1,752 msapp A/B
Click to report ready, on, cold / warm1,238 / 1,144 msapp A/B
Restored launch, launch to report, off / on1,915 / 1,920 ms5 launches per arm, interleaved
Standby spare boot in the app595 to 629 ms12 of 12 claimed in the A/B
In-app web side: setup / load to start / start to finish / finish to ready23 / 168 / 272 / 148 ms14 launches, medians
Port line to port open66 ms6 launches
Frontend bundle, entry52 to 55 JS files, 331 KB transferred (829 KB raw)Chromium resource timing
Bare WKWebView, warm: start / finish / ready57 / 124 / 170 msreal-size study copy, 7 runs
Bare WKWebView, first of process, right after bind166 / 300 / 357 ms3 runs, scene strips on