---
title: Mobile performance (making the phone app open fast, and stay fast)
---

# Mobile Perf Regression Prevention (auto-build dev hook + bundle-budget CI gate + rolling-window alerts)

## What it is

### What it is

Omniscio exposes its UI to your phone over a Tailscale Funnel. Every byte shipped on first load goes over a cellular link, so a few-hundred-KB regression in the JavaScript bundle shows up as several extra seconds of "staring at a blank page" on mobile. Two regressions in one week (the SearchModal leak in April 2026, and a follow-up catching the same pattern elsewhere) were both caught only because the user noticed — by the time a human notices, the bad build has already shipped. Then in late April 2026 a third failure mode showed up: `out/renderer/` was simply missing on the running dev instance, so `src/main/services/web/web-access-server.ts` silently fell back to proxying the Vite dev server (~20 s cold load on cellular vs ~2 s for the prebuilt bundle).

This feature adds three independent safety nets so future regressions are caught automatically:

1. **Auto-build dev hook (+ opt-in watcher).** The instant before `npm run dev` launches Electron, a `predev` script checks whether `out/renderer/` exists and is fresh — if not, it runs the renderer-only Vite build first. This always-on step is what guarantees mobile gets a current production-shaped bundle at launch, never the slow dev proxy fallback. A sibling background watcher can _additionally_ rebuild `out/renderer/` on every renderer/shared edit _during_ the session so mobile picks up live changes — but it runs a full ~3 GB build that competes with app startup, so it is **OFF by default** (it once fired during session-recovery and froze the dev launch, 2026-06-01). Opt in with `MOBILE_HOT_RELOAD=1` while iterating on the phone UI; otherwise just restart `npm run dev` to refresh the mobile bundle.
2. **Bundle-budget CI gate.** A test that runs on every pull request inspects the freshly-built renderer bundle and fails if any chunk exceeds a hard-coded Brotli-compressed size budget, if a previously lazy-loaded module becomes sync-preloaded, or if specific lazy chunks (like the markdown renderer or SearchModal) get rolled back into the entry bundle. Because this test only runs against a full build (which happens infrequently), an **always-on merge-gate guard** (`tests/unit/lint/renderer-first-paint-heavy-imports.test.ts`) now backs it up: with no build required, it statically checks on _every_ merge that the heavy markdown engine can't reach the mobile startup path. This was added in July 2026 after a build made shortly before a markdown-lazy-loading fix landed served the heavy engine to a phone for hours — the build-gated test above simply never ran in time to catch it.
3. **Rolling-window live alert.** The phone already reports its own load timings back to the server. The main process keeps the last 10 reports in a ring buffer and, when the rolling median `totalMs` exceeds 3500 ms, emits a warning log plus a `mobile-perf:alert` push event AND raises a **deduped inbox card** ("Mobile is loading slowly") so the degradation reaches you where you already look. It fires once per episode (silent until the median recovers), and the card is additionally capped at one per 24 h (persisted — survives restarts and dismiss-then-slow-again) — no alert fatigue.

Together they cover all three failure modes: the dev hook stops missing/stale bundles from ever reaching mobile, the CI gate stops bad-but-present builds before they ship, and the alert catches regressions caused by anything the static bundle size can't see (slow network conditions, server-side latency, device-specific issues).

## Where to find it

There is no screen for this — it is how the phone app loads. The optional reliability checks are gated by their own settings, and the numbers behind it are observable from the app's own diagnostics.

## How it behaves

### Every open instant: the app-shell cache (instant shell)

The safety nets above keep the load lean. A separate mechanism makes opening the app _instant_: the service-worker app-shell cache (`src/renderer/public/sw.js`), reworked at the end of July 2026 into an **instant shell**. Opening Omniscio on your phone now paints immediately from the last downloaded version — no waiting on the network at all — and the app then checks for a newer build in the background. When one exists, it quietly downloads ALL of that build's startup files and switches over atomically only once everything is present, so your next open is the new version. You are never more than one open behind, and a half-finished download can never leave the app broken — it simply keeps serving the version it has and finishes the download next time.

Why this rework mattered: previously the document was always fetched network-first, and every rebuild invalidates every fingerprinted file — so with builds landing all day, nearly every phone open was a full multi-megabyte re-download over the Funnel. Real phone reports measured 12–44 second loads; with the instant shell those opens paint from cache immediately.

Safety valves: if the running version ever hits a missing file (the classic just-rebuilt symptom), the recovery reload automatically goes to the network for a fresh copy instead of looping on the cached one; anything unexpected falls straight through to the network; and the whole behaviour has an off switch — **Settings → Web Access → "Instant app shell on phone"** (on by default; off restores always-fetch-fresh opens). It registers on mobile/web only (desktop loads from disk and gains nothing). Behaviour is locked by `mobile-service-worker-cache-contract.md`.

**Same-session precache (July 2026).** The cache above only saves a file once it's specifically been asked for — so a second visit could still wait on the live app for anything it hadn't loaded before. Now, right after your phone finishes loading Omniscio, it quietly tells the on-device cache exactly which files it just used, so a later visit is protected even for those files, instead of only for ones a previous visit happened to touch one-by-one. This was added after a real incident: a phone reload eleven minutes after a fast one took 49 seconds instead of 3, because Omniscio's main process was briefly overloaded and the second load still needed to ask it for files. It narrows this exposure — it can't help with a part of the app you're opening for the very first time during an overload.

### How to observe it

### When you run `npm run dev`

- The very first lines of output are from the auto-build hook: `[ensure-renderer-build] out/renderer/ is fresh (checked in 60ms)` on a no-op, or `[ensure-renderer-build] rebuild needed — <reason>` followed by a Vite build (typically 20–35 s cold) when the bundle is missing or stale.
- When the watcher is enabled (`MOBILE_HOT_RELOAD=1`) and running, lines prefixed `[renderer-watch]` appear when source changes trigger an incremental rebuild — most non-cosmetic edits emit one `built in <ms>` line, errors and warnings are surfaced verbatim. With the watcher off (the default), you'll instead see a one-line `[electron-dev] mobile hot-reload watcher OFF (default)` notice at launch.
- The watcher is **OFF by default** — set `MOBILE_HOT_RELOAD=1` to enable live mobile rebuilds while you edit. The `predev` freshness build still runs at every launch regardless, so the mobile bundle is always current as of the last `npm run dev`; only mid-session live-rebuild needs the opt-in.

### In CI

- On every PR: GitHub Actions runs a job named `test-bundle-budgets` after the `build` job. Open the PR on GitHub and look in the **Checks** tab.
- On failure, the test output names the offending chunk, its current size, the budget, and the specific remediation (e.g. "lazy-load this via `React.lazy()` in every caller" or "add to `manualChunks` in `electron.vite.config.ts`").

### On the phone in prod

- Open **Debug Logs** in the Omniscio toolbar (terminal icon). Lines prefixed `[mobile-perf]` are the per-connect reports; lines prefixed `[mobile-perf-alert]` are the rolling-median alerts. Both live alongside normal app logs in `%APPDATA%\omniscio\logs\main.log`.
- When an alert fires, a `mobile-perf:alert` IPC push event goes out with shape `{ medianMs, thresholdMs, windowSize, sampleCount, timestamp }`, and (since 2026-07-04) a **"Mobile is loading slowly" inbox card** is raised through the standard alert primitive — deduped to one active row, capped at one card per 24 h, with the usual Start-session button. The warning line also appears in the Debug Log Viewer the moment it fires.

### When it fires

### Auto-build dev hook

The `predev` hook (`scripts/ensure-renderer-build.js`) triggers a rebuild on either of two conditions:

- `out/renderer/index.html` is missing entirely (fresh checkout, manual `rm -rf out/`, or a prior failed build).
- Any tracked source file's `mtime` is newer than `out/renderer/index.html`. Tracked sources are everything under `src/renderer/`, `src/shared/`, and the eight build-config files: `electron.vite.config.ts`, `electron.vite.renderer-shared.ts`, `vite.renderer.config.mts`, `package.json`, `package-lock.json`, `tailwind.config.js`, `postcss.config.js`, `tsconfig.web.json`. `node_modules/`, `out/`, `.git/`, and `dist/` are walked-but-skipped so they never trigger spurious rebuilds.

The watcher (`scripts/renderer-watch.js`) is a separate process spawned alongside `electron-vite dev` by `scripts/electron-dev.js` — **only when `MOBILE_HOT_RELOAD=1`** (off by default; both the spawn gate in `electron-dev.js` and the watcher's own self-guard share the exported `mobileWatcherEnabled()` predicate). When enabled it runs `vite build -c vite.renderer.config.mts --watch` and emits incremental rebuilds whenever Rollup detects a renderer or shared-source change. The renderer-only config (`vite.renderer.config.mts`) shares its build options with the production renderer block in `electron.vite.config.ts` via `electron.vite.renderer-shared.ts` — so the watcher can never drift from production chunking, and what you test on mobile in dev is what ships in `npm run package`.

### CI gate

The bundle-budget test fails on any of these conditions (all measured after the production `electron-vite build`):

- **Entry chunk grows past its Brotli limit** (a hard const in the test — raised only with a justifying commit message; see the test header for the current number and its recalibration history). A failure here means something heavy got pulled into the initial-load graph.
- **Any non-entry, sync-preloaded chunk grows past 500 KB Brotli.** Sync-preloaded chunks are the ones Vite injects as `<link rel="modulepreload">` in the emitted HTML — they block first paint just like the entry chunk. Lazy-loaded chunks (user-navigation-gated, like `AhkEditor` or modals) are explicitly exempt because they don't affect mobile first load.
- **Too many sync chunks.** A count tripwire (see MAX_SYNC_CHUNKS in the test) catches a gross explosion of sync-preloaded chunks — fine-grained chunks from deliberate lazy-boundary work are expected and recalibrate the limit.
- **`markdown-vendor` becomes sync-preloaded.** That bundle (~116 KB Brotli / ~348 KB raw) is only needed when markdown actually renders — it must stay lazy. Enforced (un-skipped) since 2026-07-04: `AgentMarkdown.tsx` is a lazy façade over `AgentMarkdownImpl.tsx`, chat pins the Impl inside the lazy SessionPanel chunk, and the always-mounted Council panel is `LazyCouncilSessionPanel` — see `agent-markdown-facade-contract.md`. A companion gate keeps the `AgentMarkdownImpl` chunk itself out of the sync set.
- **`SearchModal` chunk disappears from the build.** The SearchModal regression postmortem (April 2026) locked this in: the modal must be split out and lazy-loaded by every caller, not just the root container.

### Live alert

- Rolling median of `totalMs` across the last 10 phone connect reports exceeds 3500 ms.
- Fires once when it crosses upward, then stays silent until the median recovers (drops back to or below 3500 ms). Re-fires only after a full recovery + re-exceed.
- **The 10-sample window survives app restarts** (July 2026): every phone report is also saved to the local `perf_samples` telemetry table, and after a restart the monitor re-seeds its window from the most recent saved reports. Before this, the window lived only in memory — the app restarts roughly daily and the phone connects fewer than 10 times per run, so the alert could never arm and sustained 14–44 s loads raised nothing.
- A companion **runtime bundle tripwire** (`src/main/services/web/mobile-budget-sentinel.ts`) checks the served bundle's sync-chunk count against the shared budget (`src/shared/mobile-build-budget.ts`) once per new build and raises a deduped inbox card when it explodes past the limit — closing the "the build-gated budget test rarely runs, so the budget rots silently" gap for good.
- The inbox card layered on top adds one more gate: a persisted once-per-24h cap keyed on the `mobile-perf-slow` dedupKey (`src/shared/alert-features/mobile-perf-alert.ts`) — a flappy day coalesces into at most one card.

### The bootstrap payload (the WebSocket half of first load)

The JS bundle is only half of a cold mobile load; the other half is the `WEB_BOOTSTRAP` WebSocket payload the phone fetches right after the bundle mounts (sessions, projects, quick replies, agent board, per-session preview seeds). It rides ONE round-trip, but at high session counts it had grown to ~700 KB — and, unlike the SW-cached bundle, it is re-fetched on every connect and focus-resume, so it is pure network time on every wake.

Two bounds keep it lean:

- **Quick-reply library** — shipped as a metadata list + a lookup slice rather than the full snippet text of every reply, so a single 100 KB SOP snippet no longer rides every bootstrap; the picker fetches a reply's body lazily. (Landed with the cold-path bundle work in "Speed up mobile initial load".)
- **Per-session preview seed (LAM)** — the last settled agent message is seeded so an inbox tap paints instantly instead of waiting on a lite-history fetch. The **attention** seed (needs_you / error / stalled — the sessions users actually tap from the inbox) is UNBOUNDED, but the **live** seed (running / starting, which can be dozens at once and whose tap latency is dominated by spawn/API traffic anyway) is capped to the `MOBILE_LIVE_LAM_SEED_LIMIT` (12) most-recently-active; the rest ship `null` and fall back to the existing on-tap lite-history fetch — no regression for the sessions users tap. See `capLiveLamSeed` in `src/main/ipc/web-access-handlers.ts`, locked by `tests/unit/web-bootstrap-handlers.test.ts` (cap / attention-unbounded / under-limit cases).

Both bounds are locked against regression by an **always-on guard**. `tests/unit/web-bootstrap-handlers.test.ts` runs in the cross-cutting guard lane (`scripts/lib/guard-tests-manifest.mjs`) — so it runs on EVERY merge, not just when a branch happens to touch the bootstrap files — and it carries a **total-payload byte budget**: a deterministic power-user worst-case (100 live sessions capped + 12 attention uncapped, ~2 KB seed each) must serialize under 160 KB, with a _teeth_ assertion proving the cap is what keeps it under (the same fixture uncapped blows past 160 KB). So a future change that bloats the payload another way — a new large field, another uncapped array — fails HERE at the merge gate, not only in the scheduled cloud build. The 160 KB ceiling is coarse (it catches ballooning / a cap removal, not micro-growth); re-baseline it deliberately in a commit if a legitimately larger field lands.

The deeper cause of a SLOW bootstrap round-trip is usually not payload size but main-process contention: the same single main process serves the phone, so a STARVED freeze (see `performance.md`) stalls the bootstrap for seconds regardless of how small it is. These bounds lower the floor and shrink the re-download-on-resume cost; they do not fix the contention multiplier.

### Inbox rows: warmed at idle, not in the bootstrap

The inbox ROWS themselves — agent alerts, drips, scheduled messages, the approval queues, the Jira/Linear/Granola/Notion trackers — are deliberately NOT in `WEB_BOOTSTRAP`. Folding ~20 integrations' lists into first paint would bloat the exact round-trip this page keeps lean. Instead, `prefetchInboxSources()` warms the WHOLE inbox via the registry-driven `loadAllInboxSources()` fan-out (`src/renderer/src/stores/inbox-items.ts`). As of 2026-07-29 this fires IN PARALLEL with the `WEB_BOOTSTRAP` RPC (the **inbox-first cold-load**, default ON) rather than after it — so the cheap, session-independent inbox round-trips (alerts / drips / scheduled ≈ tens of ms) race the slow, session-dominated bootstrap instead of waiting behind its ~1 MB session payload, and the inbox headline paints roughly a round-trip in rather than only after the whole bootstrap settles (the "inbox should be instant and prioritized" fix). It is gated on a client-cached flag read SYNCHRONOUSLY at boot (`src/renderer/src/lib/mobile-fast-inbox.ts`) — the authoritative value rides `WEB_BOOTSTRAP` as `mobileInboxPriorityEnabled`, too late for the early fire, so the last-seen value is cached and refreshed each load; the emergency kill is `AMC_DISABLE_MOBILE_INBOX_PRIORITY=1`, which reaches the phone on its next refresh. When off, the warm fires from the bootstrap `.then()` as before, so the inbox still populates within a beat of first paint instead of sitting empty until a live push. Each source warms only when its feature is enabled, so a turned-off feature costs no cellular round-trip; desktop is untouched (it eager-seeds at boot). Before this (July 2026) the per-integration eager seeds were gated off mobile (to avoid N separate cellular round-trips) and the bootstrap never carried inbox rows, so pre-existing rows — agent **alerts** worst of all, which weren't even in pull-to-refresh — appeared only after a live push. The warm is STAGED — the core triage set (agent alerts, drips, scheduled messages) fires first via `loadInboxSourcesStaged`, the long tail behind it, so the items you triage appear a beat sooner and the opening burst is smaller. (The ~15 warm requests already fire in PARALLEL over the one connection — about one round-trip, not fifteen — and each source already streams into the inbox as its own request returns, so staging is about ORDER + burst, not saving round-trips; a hung core load can't hold up the tail, which fires after a 1.5s cap. Pull-to-refresh stays flat.) Locked by `mobile-inbox-prefetch-contract.md`.

### When to update budgets

Budgets live as plain `const` declarations at the top of `tests/unit/build-output/bundle-budgets.test.ts`. They are hard-coded on purpose: raising one should be a deliberate act documented in a commit message, not a silent sliding window.

- **Lowering a budget is routine.** If you just shipped a fix that made the entry chunk smaller, lower the number to lock in the gain.
- **Raising a budget needs justification.** "I added Feature X which is used on every load, ~40 KB Brotli increase is expected, new budget is …" — put that in the commit message. The gate isn't there to block real new features, it's there to make the cost of each one visible.
- **Alert threshold (3500 ms).** Sits in `src/main/services/mobile/mobile-perf-monitor-singleton.ts`. Tuned for cellular Tailscale Funnel conditions; "definitely noticed slow" on a real phone. If you run Omniscio only on LAN / Wi-Fi, this threshold will feel too loose — adjust only if you genuinely want a tighter bar.

### Cold-load parse cost: lazy feature code (opt-in)

The mobile cold load is CPU/PARSE-bound, not download-bound — 500 KB of JavaScript is trivial to download on Wi-Fi, but a phone chokes PARSING and EXECUTING it before React can mount. So a second lever, alongside the byte budgets above, is to keep eager FEATURE code off the first-paint parse path: heavy feature bodies are split into lazy chunks that the phone parses at idle after mount (or on demand when you actually open the feature), instead of before first paint.

Because whether a chunk is eager or lazy is decided when the app is BUILT, this optimization is controlled by a toggle that takes effect on your phone without waiting for a new build: **Settings → Session → Mobile → "Faster mobile load"**. It landed **off** (matching the "perf changes land off until you flip them on" rule) and has been **ON by default since 2026-09-24** — existing installs were switched on once by a one-time settings migration, and a later switch-off sticks. Turn it off and the phone loads everything up front at startup, the original behavior, because the app force-preloads every one of those feature chunks at boot. The trade-off of leaving it on: the first time you open a screen after an update, the phone downloads that screen's code; on a silently dropped connection that wait shows "Waiting for your computer…". It takes effect on the next refresh; no rebuild, no redeploy. The `AMC_MOBILE_EAGER_BOOT=1` environment variable on the desktop is an emergency override that forces everyone back to loading up front even if the toggle is on. The whole mechanism has one owner, `src/renderer/src/lib/mobile-eager-boot.ts`, and every newly-lazy feature keeps the mobile stale-chunk recovery pattern (so a mid-session desktop rebuild still recovers gracefully) — locked by `mobile-lazy-boot-contract.md`.

**How it's measured.** The north-star is React **mount time** under a phone-class CPU throttle, measured by the committed harness `node scripts/checks/mobile-load-profile.mjs out/renderer 4` (reports mount / first-paint / main-thread-blocking). Its companion `node scripts/checks/mobile-eager-chunks.mjs out/renderer` lists the eager (sync-preloaded) chunks by raw size — the deterministic driver of parse cost — so a conversion is kept only when its chunk actually LEAVES the eager set. First landed: the Quick Launch action tabs (the email / calendar / KMS / SMS composers behind the mobile ⚡ sheet) went lazy, taking ~242 KB of tab code plus a ~84 KB cascaded emoji-picker off the eager graph.

### Reliability: catching a broken or non-booting mobile app (opt-in, both OFF by default)

The nets above catch a SLOW mobile load. Two newer nets (2026-07-28) catch a mobile app that does not load AT ALL — the failure the perf monitor is blind to, because a phone that never finishes loading never sends a timing report, so the user is the only detector (exactly the "I broke Mobile and had to turn it off" incident). Both are **opt-in and OFF by default** (the same "changes land off until you flip them on" rule), so they add zero behavior until enabled.

1. **Broken-phone watchdog (runtime).** When a real phone/remote client connects over the web-access WebSocket, the main process starts a ~30 s watch. A successful load clears it — the phone sends its `perf-report` the moment it finishes bootstrapping + hydrating; a disconnect also clears it (gone ≠ broken, so it never false-alarms on a closed tab). If the window elapses with no successful load, it raises ONE deduped inbox card — "A device may not be loading Omniscio" — that clears itself the instant a load reports. It arms only for a token-authed remote client (never desktop, which uses IPC, and never a dev Vite socket). Turn it on at **Settings → Session → Mobile → "Alert me if my phone can't load"** (off by default). Owner: `src/main/services/web/mobile-load-watchdog-singleton.ts` over the pure `src/main/services/web/mobile-load-watchdog.ts`.
2. **Boot-smoke code-break detector (build).** The scheduled cloud build proves the mobile bundle COMPILES, never that it BOOTS in a browser. The existing headless boot-smoke (`scripts/checks/mobile-boot-smoke.mjs`) already loads the built bundle in real Chromium and fails if React never mounts; a new classifier (`scripts/checks/mobile-boot-smoke-classify.mjs`) turns its verdict into an alert DECISION — a real "won't boot" is a code break that alerts, while a browser-less runner, a timeout, or a missing bundle is infra that stays silent (so it never cries wolf). The classifier + verdict CLI are the durable core; wiring it onto the build VM (run it after a green build where the bundle lives, provision Chromium there, gate on `AMC_ENABLE_MOBILE_BOOT_SMOKE=1`, and alert from the build net) is a deliberately-separate follow-up, because it touches the shared cloud-build path and can only be confirmed by a live cloud run.

Both nets are locked by `mobile-reliability-contract.md` and covered by 23 unit tests (watchdog arm/clear/fire/dedup, the opt-in-default-off gate, and the infra-vs-code classification).

### Remaining mobile-load work (tracked 2026-07-08)

A full mobile cold-load investigation (2026-07-08) shipped the payload bounds above and confirmed the dominant driver of the WORST loads is main-process contention (the STARVED-freeze class + `rate-limit-recovery` churn), whose durable cure is the DB-off-UI-thread migration — a separate, deliberately-deferred effort (`performance.md`). Note also that HTTP/2 is NOT a lever: the Tailscale Funnel edge already serves the phone HTTP/2 regardless of the local HTTP/1.1 server, so the "many-chunk waterfall" is largely a phantom on the phone path — the bundle matters for its BYTES and parse time, not its request count. These contained follow-ups were scoped:

- **Firebase off first-paint — LANDED 2026-07-21.** The `firebase-vendor` chunk (~529 KB raw / ~104 KB brotli) was riding the mobile first-paint graph through a SINGLE static edge: the `QUICK_DM_SCHEDULE_REQUEST` listener in `src/renderer/src/app/useAppPushListeners.ts` statically imported `quick-dm-scheduler` → `src/renderer/src/lib/firebase/web-app.ts` → `firebase/*`. The listener now `import()`s the scheduler dynamically, so Firebase loads only when a scheduled DM actually fires (it's a lazy cloud/Team-Chat feature). Locked by `tests/unit/lint/renderer-first-paint-heavy-imports.test.ts` — Firebase (`firebase/*` + `web-app.ts`) must not be eagerly reachable from `main.tsx`, plus a bite test so the guard can't pass vacuously.
- **Bundle bytes (need a build-measure loop):** drop the `lucide-vendor` manual chunk in `electron.vite.renderer-shared.ts` so boot pulls only its ~15 icons; and lazy-load the settings search index (`settings-link-target.ts` statically imports it — check the resolver's callers can go async first). (The three `import * as … from 'lucide-react'` namespace importers flagged here on 2026-07-08 are ALREADY fixed — no longer present on master as of 2026-07-21.)
- **Bundle boot edges — RE-SCOPED 2026-07-21 (more entangled than the 2026-07-08 note implied).** The demo/tour edges (`DEMO_QUICK_REPLIES` via `QuickReplyComposerControl`, `tour-loader`'s `resolveSceneRefs`) are NOT in the main-window eager graph (already lazy). `GmailInboxPane` IS still eager, but the `../gmail` barrel bypass alone does NOT remove it: `GmailThreadSkeleton` is co-located INSIDE `GmailInboxPane.tsx`, and `src/renderer/src/features/dashboard/SessionsSidebar.tsx` ALSO imports `GmailInboxPane` directly — so evicting it from first-paint needs a broader change (extract the skeleton to its own file + lazify BOTH the `MobileGmailContent` and `SessionsSidebar` edges). Deferred as its own scoped follow-up rather than ballooning the Firebase change; the `tests/unit/lint/renderer-first-paint-heavy-imports.test.ts` is ready to lock it when done.
- **Contention (contained):** memoize the per-poll DB reads in `src/main/services/rate-limit-recovery-service.ts` (`getLiveSessionCountsByAccount` / `getStreamingSessionCountsByAccount` run several times per usage poll).
- **Hardening (H3, still deferred):** extend the `src/main/services/mobile/mobile-perf-monitor-singleton.ts` to alert on a `bootstrapRoundTrip` / `jsDownloadAndParse` rolling-median regression, not just `totalMs`. (The merge-gate bootstrap-payload byte budget — H2 — LANDED: `tests/unit/web-bootstrap-handlers.test.ts` is now in the guard lane with a 160 KB total-payload budget + teeth test; see "The bootstrap payload" above.)

## For agents

### Related code

- `scripts/ensure-renderer-build.js` — the `predev` freshness check (~50 ms no-op when fresh, shells out to `npx vite build -c vite.renderer.config.mts` when stale or missing)
- `scripts/renderer-watch.js` — background `vite build --watch` runner spawned by `electron-dev.js` (output filtered to high-signal lines, **OFF by default — opt in via `MOBILE_HOT_RELOAD=1`**; gated by the shared `mobileWatcherEnabled()` predicate)
- `scripts/electron-dev.js` — orchestrates `electron-vite dev` and the renderer watcher as siblings; propagates the dev process's exit code, kills the watcher on shutdown. Exports `mobileWatcherEnabled(env)` (the opt-in predicate, default off) + `isStartupFailure`, both unit-tested in `tests/unit/scripts/electron-dev.test.ts`
- `vite.renderer.config.mts` + `electron.vite.renderer-shared.ts` — shared renderer build config so the watcher and `npm run build` produce identical chunking
- `tests/unit/scripts/ensure-renderer-build.test.js` — locks in the freshness logic, the ignored-subdir set, and graceful handling of missing files
- `tests/unit/build-output/bundle-budgets.test.ts` — the CI gate (5 tests, all `skipIf(!hasBuild)` so they run only when `out/renderer/` exists)
- The old `.github/workflows/ci.yml` `test-bundle-budgets` job is gone — authoritative checks moved to the cloud fleet (commit `8e0ba08a`). The budgets need a build, so they run on the cloud, never at `npm run gate` → `performance-gate-contract.md`
- `src/main/services/mobile/mobile-perf-monitor.ts` — factory + ring buffer + median + debounce (`createMobilePerfMonitor(opts)`)
- `src/main/services/mobile/mobile-perf-monitor-singleton.ts` — production instance, thresholds, `onAlert` hook (log + push + the deduped inbox card)
- `src/shared/alert-features/mobile-perf-alert.ts` — the card's byte-stable dedupKey + pure raise gate (`shouldRaiseMobilePerfAlert`: the persisted 24h cap)
- `agent-markdown-facade-contract.md` — the lazy-façade invariants that keep markdown-vendor out of first paint (I1–I5)
- `src/main/services/web/web-access-ws.ts` — the `perf-report` WebSocket frame handler calls `recordMobilePerfTimings(t)` (seed-from-DB + record + persist) after writing the existing `[mobile-perf]` log line
- `src/renderer/src/lib/precache-loaded-assets.ts` — the same-session precache: tells the service worker which `/assets/*` this load fetched, once a controller exists
- `src/shared/ipc-channels/index.ts` — `IPC.MOBILE_PERF_ALERT` push channel (defined in the `diagnostics.ts` domain map, re-exported through this barrel)
- `tests/unit/build-output/non-registry-lazy-load.test.ts` — the bundle-output test that enforces the SearchModal lazy-load chunk split that inspired this gate
- Design: [2026-04-24-mobile-perf-prevention-design.md](../plans/2026-04-24-mobile-perf-prevention-design.md)
- Implementation plan: [2026-04-24-mobile-perf-prevention-plan.md](../plans/2026-04-24-mobile-perf-prevention-plan.md)
- Upstream postmortem: `mobile-search-startup-perf-postmortem.md`

## Related

### Related

- [mobile-remote-access.md](mobile-remote-access.md) — the Tailscale Funnel setup this feature protects
- [logs-and-debugging.md](logs-and-debugging.md) — where `[mobile-perf]` / `[mobile-perf-alert]` lines show up and how to export them
- [lazy-content-load.md](lazy-content-load.md) — lite cold-mount (desktop AND mobile) removes the underlying cause of the long-session-mobile-blank class of regressions by shipping a small payload to start with, rather than working around a multi-MB one after the fact

