Skip to content

App speed plan

Make Home feel immediate on iPhone and desktop, following the owner's McMaster-like speed preference. Start with compression, dependable asset caching, fewer eager scripts, and less waiting during startup. Several heavy libraries already load on demand; the large icon font and sequential data reads also need attention. Target useful content in under one second warm and 2.5 seconds cold on a defined 4G test profile. Ship small measured releases through the existing gates; keep private data and sign-in responses out of caches.

Status: proposal Decision owner: Owner; implementation and verification: Engineering. Related: embeddedera-app.

  1. First investment: recommend a 2–3 hour baseline followed by 4–8 engineering hours for compression, asset headers/versioning, and remaining lazy vendors; approve this bounded phase before larger restructuring.
  2. Build approach: recommend retaining no-build JavaScript for the first release, then native route modules if parsing remains costly; choose release-time esbuild only when measured savings justify a build dependency.
  3. Caching boundary: recommend immutable nonpersonalized assets now; defer service-worker HTML caching until authenticated navigation and update tests pass. No offline business data or shared personalized caches.
  4. Freshness: recommend 15–30 second caches for safe task/reference reads and the existing 60 second overview snapshots, with mutation invalidation and visible freshness. Stock availability, permissions, prices used for transactions, and confirmations must be revalidated live.
  5. Acceptance: recommend the targets and release budgets below, plus a physical installed-iPhone check. Keep Cloudflare Access session policy unchanged unless measured login overhead warrants a separate owner decision; buy no performance service.

Engineering evidence and limits

Read-only inspection on 2026-10-04: /home/angel/assistant, commit b9d23e6, VERSION=2026.10.11; inspected app.py, app_shell.py, static/app.html, app.js, app.css, sw.js, API handlers, installed Starlette, app-release, and app-ui-check. The root handler dispatches home.* to app_shell.app_page; that function reads static/app.html. Public Home uses /; /app remains the sandbox/UI-gate route and returns 404 on the Home host.

Item Independently verified from files Interpretation
Main JS/CSS app.js 468,902 B; app.css 121,531 B Matches supplied raw sizes. Python gzip level 9, mtime=0: 127,933 / 23,109 B; level 6: 128,477 / 23,393 B. Supplied gzip figures 128,246 / 23,401 B use an unspecified encoder/settings; approximate saving is 74% combined.
Headers No GZipMiddleware found; main assets use bare FileResponse; vendor routes mostly specify public, max-age=86400; HTML uses no-store, must-revalidate Supplied origin observation reports no encoding/cache-control/ETag. Cache-control omission agrees with code, but installed FileResponse.set_stat_headers adds ETag and Last-Modified: reconcile that discrepancy before claiming validators are absent.
Vendor footprint du -sh static/vendor: 8.2M, including brand assets as well as the named packages Disk footprint is not startup transfer. Mermaid ~3.57 MB and Phaser ~1.19 MB are not eager shell downloads.
Eager CSS Tabler 635,394 B; Tabler Icons 212,469 B; app CSS 121,531 B Render-blocking styles; icons additionally reference WOFF2 504,296 B (~502,258 B gzipped).
Eager JS Tabler 135,181 B; pulltorefresh 8,614 B; Sortable 46,292 B; simplewebauthn 14,544 B; app JS 468,902 B Five ordinary bottom-of-body scripts, without defer/async. CSS + JS + WOFF2 total 2,147,223 raw B, excluding HTML/brand/data; actual requests require a browser waterfall.
Existing lazy loading loadMermaid at app.js:561; loadApex at 2899; emoji library/data at 4632 Mermaid for diagrams, ApexCharts for marketing charts, emoji-mart on picker open. Phaser and lucide are absent from shell script tags; do not count them as new Home savings.
Existing version/cache behavior HTML adds a combined JS+CSS hash as ?v=; _version() rereads both files per call Hash omits HTML, vendor changes and future chunks, and is independent of CalVer. Version checks bypass cache on the client, run on resume/pageshow and a timer, throttled to once/minute; reload waits for safe UI state.
PWA/UI Worker has push/click/install/activate handlers only; /app/sw.js has no-cache, root scope via Service-Worker-Allowed: / No fetch caching. Skeleton helper/CSS and overview placeholders already exist; silent refresh preserves old content and scroll.

Local socket requests to 127.0.0.1:8200 failed with Operation not permitted in this session. Therefore live headers, endpoint latency, edge compression, authenticated Cloudflare behavior, and browser timings were not independently remeasured. The HTTP baseline above is explicitly owner-supplied; no performance gain is claimed. Starlette's documented FileResponse headers support the ETag discrepancy.

Existing solutions first

Use the installed framework, browser primitives, existing loaders/caches, and existing Playwright image before adding infrastructure. This follows /home/angel/.claude/CLAUDE.md and store-performance-preference.md: optimize measured p50/p95/p99, failures and weight; prefer free/OSS; keep personalized state out of shared caches. The proposals below reuse maintained implementations; bespoke work is limited to app-specific route boundaries, cache keys, and measurement assertions that generic tools cannot supply.

Compression and immutable assets

  • First use Starlette GZipMiddleware for suitable text responses, starting around minimum_size=1000, compresslevel=6, benchmarked against the installed version. Prefer a static-response scope initially; test HTML/JSON separately and preserve SSE streaming, downloads and already-encoded responses. Do not assume newer documented options exist locally. Check Content-Encoding, Vary: Accept-Encoding, MIME, identity fallback, body integrity and range behavior.
  • Release-time .br/.gz sidecars avoid repeated CPU work. Existing choices include Caddy precompressed file serving and NGINX gzip_static. WhiteNoise has sidecar support but is WSGI, so is not a drop-in ASGI middleware. Recommend runtime gzip first; adopt an existing static-serving layer only if already available or measurements justify its operational cost. Generating sidecars alone does not make FileResponse negotiate them.
  • Cloudflare can compress an uncompressed origin response; origin headers cannot establish visitor transfer size. Its compression documentation lists gzip/Brotli/Zstandard, depending on MIME, request encodings, rules and plan; current Free default is Zstandard, not a universal promise of Brotli. Check authenticated GET responses at the public hostname with browser-supported encodings, status/final URL, Content-Encoding, transferred bytes, Vary and CF-Cache-Status. Do not mistake Access login HTML for app.js or compression for an edge cache hit.
  • Replace advisory query strings with an immutable release directory or hashed files, e.g. /static/releases/2026.10.N/app.<hash>.js. A generated manifest maps CalVer + content digest to all assets, lazy chunks, vendor CSS/JS and relative font URLs. Reuse native hashing and existing release metadata; no bundler is needed for this step. Preserve narrow static allowlists and traversal protections when extending routes.
  • Only byte-stable, nonpersonalized asset URLs get Cache-Control: public, max-age=31536000, immutable; an initially browser-only private policy is acceptable until edge Access enforcement is verified. No “Cache Everything” rule. Unversioned compatibility URLs revalidate; /, auth/lock responses and authenticated APIs remain no-store; /app/api/version gains explicit server-side no-store; the manifest and worker revalidate. Cache-Control semantics.
  • Do not simply add immutable to the present ?v= handler: arbitrary/old query values currently return today's file. Retain old release assets for at least the supported stale-client window (proposed 30 days and at least two releases), including during rollback. Stage assets before switching the HTML/manifest pointer; never reuse a URL for changed bytes.
  • Publish one release identity, such as CalVer plus a manifest digest, into HTML metadata and /app/api/version; replace the selector that currently extracts script[src*="/static/app.js?v="]. Cache the manifest identity at startup, preserve safe reload deferral for edits/dialogs/selection/chat, and keep API compatibility through the supported client window. Missing old chunks must lead to a recoverable update prompt, never a reload loop or discarded form.

Loading, icons and perceived speed

  • Keep the existing Mermaid/Apex/emoji lazy behavior; memoize in-flight Mermaid/Apex promises as emoji already does, clear failed promises to permit retry, and use manifest URLs. Load Sortable on the task board only when desktop drag/drop is available; load simplewebauthn when lock/passkey functionality needs it, prefetching after lock status if appropriate. Pull-to-refresh is small: defer it to standalone mode only if the waterfall warrants the complexity. Phaser stays confined to its separate game/office consumer.
  • Confirm Tabler JS is needed by the initial shell; if it remains eager, ordered defer is a low-risk option after checking inline dependencies. Do not replace proven libraries with custom UI to save a few KB. Acceptance: no chart/diagram/emoji/Phaser downloads on Overview; first feature open succeeds; parallel opens load once; failed downloads offer retry.
  • Main route handlers live together inside app.js's IIFE. Native ES modules plus dynamic import can separate shell/auth/helpers from marketing, chat, tasks, workflows and record pages without a build. Extract explicit shared interfaces, preserve route cancellation/disposal, and avoid many tiny module requests; the initial import graph must not statically import every route.
  • Alternative: pinned esbuild (MIT) at release time, with ESM format, splitting, minification, hashed entry/chunk names and a manifest; see its code-splitting API. Keep source editable without a dev server and build artifacts reproducible. Bundling alone will not split the existing IIFE: route extraction is still work. Preserve license notices and choose a tested Safari target. Recommend this only after the no-build phase misses the JS/CPU budget.
  • Fonts already use the system sans stack; JetBrains Mono is a fallback name, with no external font stylesheet in the shell. The major font cost is Tabler Icons. Compare a subset of the existing font against an SVG sprite built from the existing Tabler icon set; keep icon appearance/semantics. A sprite eliminates the 504 KB font if all uses migrate. Start with an inventory of dynamic ti-* names and a complete subset, or budget a full migration; loading the full font as fallback would erase the saving. Verify missing-glyph, high-DPI, light/dark, keyboard and accessible-label behavior. Do not preload the entire icon font by default.
  • Startup currently awaits lock status, then Promise.allSettled(tiles, now, me, about), then profile, then routing/Overview. Preserve the lock and identity gates, but render usable navigation as soon as required identity/permissions arrive; let optional about/profile/now complete independently. Fetch selected-route data as soon as authorized, not after cosmetic metadata. Keep existing skeletons with stable geometry; render a neutral shell placeholder before app.js runs, without exposing business content before unlock. Measure useful data separately from skeleton paint.

API latency and cache correctness

These are code-based suspects, not measured slow endpoints. Add monotonic request and upstream spans first, then rank p50/p95/p99 by route template, role, result/error, cache hit/miss and upstream call count. Existing Server-Timing plus structured logs is enough; do not add a paid APM. Separate FastAPI time from InvenTree/Kanboard time and network wait; keep cookies, tokens, emails and business payloads out of artifacts.

Page/endpoint Verified behavior Proposed measured change
Overview, /home/api/overview (cockpit.py:645) Sequential _kpi calls; _build_summary gathers more sections; existing successful KPI cache TTL = 60 s. _fetch_finished repeats location/stock lookups; _fetch_can_build reads requirements once per eligible assembly. Reuse the cache; deduplicate request-local location/stock reads and bound concurrent independent sections. Batch where upstream supports it; cap per-assembly work and load secondary cards later if necessary. Measure cold expiry bursts and warm hits separately.
Part page, /app/api/part/{pk} (objects.py:246) Base record followed by parameters, stock, supplier, manufacturer, used-in and build reads, then assembly BOM when applicable. Keep authorization/base existence first, parallelize independent side reads with timeouts, and defer unopened tabs where it improves useful content. Cache reference metadata briefly; live-check mutable transaction inputs.
Tasks, /app/api/tasks/board (tasks_api.py:170) Project discovery and users precede an existing JSON-RPC batch of open/closed tasks and columns, followed by a batch of tags/comments/files for every wanted task. Preserve _calls batching; avoid sending full comments/files merely to count them when supported, limit enrichment to visible rows, short-cache authorized project/user metadata. No claim that each task already incurs a separate HTTP round trip.
Area page, /home/api/area/{area_id} _area_todos fetches projects/users and loops over project task RPCs. Reuse the task API batch pattern after department filtering; bound scope and measure large project sets.
Startup /home/api/tiles, /home/api/now, /app/api/me, /app/api/about, /app/api/profile First four run concurrently but routing waits for the group plus profile; now is a local snapshot read. Instrument each and remove optional waits; do not blame local snapshot routes for upstream ERP delays without timings.

Use native asyncio.gather with a semaphore for async clients or a bounded standard-library executor for independent synchronous calls. Never run blocking ERP calls directly on the event loop or assume shared client instances are thread-safe; use documented pooling/lifecycle and cap upstream concurrency (initially 4). Keep dependent reads and writes ordered; test upstream timeout/error isolation.

Apply authorization before any cache lookup/return. New cache keys include environment/ERP target, permission scope or user as appropriate, normalized parameters and schema version. Reuse the existing cache helper with bounded storage and single-flight refresh instead of introducing Redis. Cache only successful safe GET computations: reference lists 15–30 s; overview retains 60 s with an “as of” value. Keep per-user task filtering outside any shared raw cache; invalidate affected keys after writes, including other workers, and account for changes made directly in ERP/Kanboard via short TTL. If cross-worker invalidation cannot be guaranteed, do not cache transaction-sensitive reads. Test two roles/users, expiry, edits, concurrent misses and upstream failures.

Service worker, Cloudflare and sign-in

  • Default phase: keep navigations network-only and use normal immutable HTTP caching. Evaluate Workbox strategies before adding custom fetch logic; its maintained strategies are a free/OSS option, but auth/version policy remains app-specific. Static versioned assets suit cache-first; stale-while-revalidate (SWR) suits an explicitly nonpersonalized, revision-managed shell only after the gate design is proven.
  • A naive SWR response for / bypasses the network Access gate and can replay stale HTML forever across location.reload(). If shell caching is pursued, require a successful network-only authenticated identity/lock validation for each new navigation before releasing cached shell content; expired sessions go through normal sign-in. On failure/offline, show only a neutral offline/sign-in screen, never cached business UI/data. This limits the latency win; defer HTML caching if it cannot preserve the gate cleanly.
  • Strictly allowlist same-origin static GETs and the designated generic shell. Never cache /app/api/*, /home/api/*, lock/auth/logout routes, /cdn-cgi/access/*, POSTs, SSE, uploads, or private documents. Admit only expected MIME, status 200, nonredirected same-origin responses with the expected release marker; reject opaque responses and Access HTML returned under asset URLs. CacheStorage needs these explicit checks; HTTP headers alone are not a sufficient safeguard.
  • Revision caches must update atomically. A changed version must fetch a fresh shell and its manifest from network before a safe reload; do not serve old SWR HTML during the upgrade. Retain old assets for active tabs; prune after migration. Preserve push handlers; replace unconditional activation behavior only with tested client coordination. Logout/account change clears app-owned private state without destroying push settings unnecessarily. Test cache eviction, suspended/background tabs, offline recovery, push and two-tab updates on an installed iPhone; WebKit storage can be evicted.
  • Verify negotiated browser-edge HTTP/2 or HTTP/3, connection reuse and resource priorities. Cloudflare HTTP/2 is available on Free; protocol optimization also covers HTTP/3. Origin HTTP/1.1 does not establish edge protocol. Enable free features only where the authenticated waterfall shows benefit; no paid Argo/APO recommendation.
  • Cloudflare 103 Early Hints is available on Free over HTTP/2/3; test hints for only the small critical CSS/entry script after URLs stabilize. Browser/version behavior varies, so promise no iPhone gain. Hints can precede authentication and contain cached Link headers: never include user-specific resources; verify logout/deploy behavior and avoid speculative heavy-vendor preloads.
  • Record Access redirect count, DNS/TLS/edge TTFB, IdP selection, token refresh and manual login separately from app loading. Compare valid-session warm/cold, expired session and fresh login on Safari, installed iOS Home Screen and desktop. Access session management explains application/policy/global durations. Fix accidental redirect chains first; consider the configured single-IdP shortcut only if applicable. Do not weaken Access or extend sessions simply to improve a benchmark.

Repeatable measurement and release gate

Proposed implementation, not created or run here: tests/ui/performance_webkit.py and a small app-perf-check wrapper that reuses the throwaway-server lifecycle and ee-ui-check image from app-ui-check (Playwright Python 1.62.0, official v1.62.0-noble base). A wrapper is justified only for this app's lifecycle, authentication fixtures, readiness markers and budgets; measurement uses Playwright APIs and standard JSON/statistics. Keep it in the existing UI test family and document the procedure there, cross-referencing the applicable SOP rather than duplicating one.

  1. Fixed harness: record Git commit, CalVer/manifest, browser/image digest, device profile, role, fixture version, network profile and server cache state. Run iPhone SE (375 px), iPhone 13 (390 px), and desktop WebKit, one performance job at a time. Retain app-ui-check for layout; its mocked responses do not measure upstream latency.
  2. Cache-valid fixtures: serve synthetic API fixtures at the throwaway server/dependency boundary, with outbound provider access disabled. Do not use page.route, context.route or HAR routing for warm HTTP-cache measurements: Playwright routing disables HTTP cache. Run HTTP-cache tests with SW blocked and a separate SW-enabled upgrade/cache suite. Never put test identity bypasses on production.
  3. Cold/warm definition: each cold sample starts a fresh context with empty HTTP/cache storage and no SW, but equivalent valid test identity. Warm sample revisits in that same context after initial requests settle, preserving cache, cookies and worker state for the applicable suite; avoid forced reload that changes revalidation behavior. Separately measure route transitions and a resumed installed app. Declare whether DNS/TLS connections and server TTL caches are warm; test cold server cache independently.
  4. Network: label localhost runs as unthrottled. For WebKit, use an external OSS shaper such as Toxiproxy in front of the disposable server (initial profile: 150 ms RTT, 1.6 Mbps downstream, 750 Kbps upstream); do not use Chromium-only CDP throttling. Toxiproxy bandwidth is per connection, so record/calibrate aggregate traffic and label this a proxy approximation, not faithful cellular emulation. Confirm headline targets on an actual iPhone with a controlled aggregate 4G profile. Capture device/iOS/network details.
  5. Readiness and results: mark shell-visible, authorized-nav-ready and primary-data-ready explicitly; wait for the selected route's required content and two paint frames, not networkidle (chat/SSE can stay open). Record Navigation/Resource Timing, available paint/LCP metrics, request timing/sizes, response encodings/cache headers, errors, requests, transferred/decoded bytes and server spans. Mark unsupported WebKit metrics unavailable instead of inventing zeros. Infer cache hits using sizes plus server request logs; byte totals must reflect transferred bytes, not just decoded body length.
  6. Sampling: 30 paired cold/warm samples per device for the baseline and each material optimization, saving raw JSON and summary CSV with p50/p95/p99 and failures. Treat p99 from 30 runs as descriptive; use at least 100 observations or passive server samples to assess tails. Compare admin, sales and production datasets, including realistic large boards/parts, and collect per-endpoint p50/p95 across cache expiry boundaries.
  7. Boundary checks: an authenticated, read-only sandbox edge run measures Cloudflare separately from direct origin. Save redacted headers, not cookies or login tokens. A valid-session performance run and a manual Access login run are separate artifacts; human login time is reported but excluded from the app-ready budget. Use production only for approved post-release read-only smoke checks, never development testing.

Proposed repeatable entry point after implementation:

app-perf-check /home/angel/assistant-sandbox --devices iphone-se,iphone-13,desktop --samples 30 --cache cold,warm --network 4g --output /tmp/app-perf/2026.10.N
Proposed budget Gate and interpretation
Useful primary data Valid-session p75 cold <2.5 s and warm <1.0 s on the declared 4G profile; p95 cold <4 s / warm <1.5 s. Initial goals, not measured achievements. Skeleton paint alone cannot pass.
Visible shell / interactions Warm shell p75 <300 ms; already-loaded route feedback <100 ms; primary route data still has its own timer. No blank screen or layout regression.
Startup bytes Phase 1: main app JS gzip ≤130 KiB and CSS ≤25 KiB; all eager assets ≤850 KiB encoded, then ≤350 KiB after icon/route work. Measure actual transfer and fixture exclusions explicitly.
Lazy loading Zero Mermaid/Apex/emoji/Phaser bytes on Overview; Sortable absent until its desktop task-board use; required feature requests complete correctly.
API Initial goal: cached read p95 <200 ms origin; critical uncached aggregate p95 <800 ms; report p50/p95/p99 and errors per endpoint. Revise scope, not authorization/freshness, if upstream sets the floor.
Regressions Fail deterministic byte/header/import budgets and missing measurements; fail controlled p50 or p95 timing >10% above the accepted baseline. Use one confirmation batch for a timing-only failure, not repeated retries until green.

Modify app-release only in the later implementation: it currently runs pytest and app-ui-check before assigning CalVer. Compute the candidate version first, generate staged assets/manifest, and run tests/UI/performance budgets against that exact staged release; only then commit/tag and fast-forward production. --dry-run must validate disposable staging without modifying source or production. Keep budgets and accepted baseline versioned; budget increases require explicit review, not silent baseline replacement. Publish the full 30-pair comparison for performance releases; a smaller fixed sample gate may be calibrated later if runtime is excessive.

Phases, estimates, tests and rollback

Phase Engineering effort Deliverable / acceptance
0 — establish truth 2–3 h Reproduce origin headers including ETag discrepancy; authenticated edge waterfall; first cold/warm/API distributions. No unsupported latency promises.
1 — first release 4–8 h after baseline Runtime gzip, immutable release URLs/headers/version identity, Sortable/passkey loading, cheap startup-wait removal. Header/body tests, old/new URL identity and safe-reload tests, existing pytest + WebKit UI gate, before/after bytes/timing. Estimate assumes small release-script changes; full retention/staging integration may spill into phase 2.
2 — measured bottlenecks 1–2 days Icon subset/sprite, bounded API concurrency and safe cache/invalidation changes; finish automated harness/budget integration (roughly 4–6 h of this phase). Role isolation, expiry bursts, read-after-write, missing icons, all route/feature tests and real sandbox timings.
3 — conditional splitting 1–2 days Native route modules; esbuild only if selected. Verify deep links, shared state, cancellation, CSS ordering, stale tabs and missing chunks. Stop here if targets are met.
4 — optional PWA/edge tuning 0.5–1.5 days Auth-safe shell-cache experiment and HTTP/3/Early Hints evidence. Ship only if measurable benefit survives Access expiry, offline, update and installed-iPhone tests; assets-only caching remains acceptable.

For every implemented phase: work in /home/angel/assistant-sandbox on the existing cockpit release branch, against sandbox ERP or isolated fixtures; preserve the established workflow. Run focused meaningful tests, full pytest, app-ui-check for both light/dark on phones and desktop, then the budget gate. Manually check installed-iPhone resume, keyboard, pull-to-refresh, push, Access reauthentication and a safe update with unsaved input. Docker WebKit emulation does not establish installed-iOS correctness; use Playwright device profiles plus the real phone.

Ship through app-release with the performance artifact attached to release evidence and concise What's-new lines. Verify root/version/assets after restart and compare a small authenticated edge smoke sample. Roll back via the established release procedure to the previous known-good application and manifest/assets; do not purge every cache. For a faulty future SW, serve a tested cleanup worker at the same /app/sw.js URL, remove only app-owned fetch caches and preserve push behavior; server rollback alone does not remove an installed worker. Test this recovery before enabling fetch caching.

Planning scope: this note is the only file written; no app changes, benchmark script, deployment, Cloudflare setting, sync, memory update or new schedule was executed. Model used: primary Codex session only; no helper model ran. ai-capacity status --json returned missing provider readings, and the available helper interface could not enforce SOP-025's read-only audit scope, so research and verification stayed local. Remaining owner choices are numbered at the top; all timings and implementation estimates are proposals.