Research 033 — Frontend performance: route code-splitting and staleTime
Status: complete
Step 1.5 output. One entry per [NEEDS VERIFICATION] marker in spec.md, plus findings that turned up alongside. Evidence is cited, not recalled.
Verified against: branch 033-frontend-perf off origin/main @ d87e587; apps/web on Vite 8.1.5 (Rolldown/Oxc), @tanstack/react-query 5.101.4, react-router 8.3.0, React 19.2.8. Browser measurements on headed Chromium via Playwright, fresh context. All dates 2026-08-22. The deployed origins measured: web https://gw2priory-l21x-7m5b.onrender.com, API https://gw2priory-api-l21x-lbn5.onrender.com (Render free tier).
All spike code (a lazy-route edit, curl warming, browser runs) was throwaway and has been discarded; nothing from it is in the working tree.
V1 — Is SSR needed at all once the cold start is gone? (spec R5, SC6)
Question. The epic defers SSR to a measurement: only build it if, after Spec 031 removes the free-tier cold start, the SPA's first paint is still too slow. Measure against a warm origin (a stand-in for post-Spec-031) and decide.
Verdict. Confirmed — SSR is not needed. Against a warm origin the SPA's first paint is well inside Google's "good" band, and this spec's code-splitting only improves it. The ~42 s the app is slow today is entirely the cold start (Spec 031's job), not the render model. SSR stays unbuilt.
Evidence. All 2026-08-22.
- The cold start is the whole problem. First (cold) API hit:
GET /api/legendaries→HTTP 200 in 42.63 s. Immediately warm:0.116 s, then0.111 s. Warming the origin removes ~42 s at a stroke — dwarfing anything the render model could contribute. - Cold-cache first paint of the SPA (landing
/, warm origin), fresh browser context:- First Paint 636 ms, First Contentful Paint 768 ms.
- Single JS bundle
index-*.js149 kB transfer, 378 ms to load; CSS 9 kB. domContentLoaded/load701 ms. No/apicall — the landing route (AccountPageat/,features/account/routes.tsx) is the keyless connect-account prompt, so first paint is pure shell-boot, no data wait.
- Data page (
/legendaries), warm origin, JS already cached (repeat visit): FCP 412 ms; thelegendariesfetch ran 362 → 473 ms (111 ms warm); catalog content present by ~473 ms. - Derived cold-cache data-page estimate: cold shell-boot (~768 ms FCP) + one warm API round-trip (~111 ms, fired after first render) → content in ~0.9–1.2 s. FCP 0.77 s and content ~1 s are both comfortably under Google's "good" FCP (<1.8 s) / LCP (<2.5 s) thresholds.
Caveat. Measured against the warm free-tier origin without the CDN Spec 031 will add, so real post-Spec-031 numbers should be equal or better. The /legendaries run had the JS cached, so its FCP is a repeat-visit number, not cold; the cold data-page figure above is derived from the cold landing boot + the warm fetch, not directly observed. Re-measure checkpoint: after this spec's code-splitting merges and after Spec 031's CDN lands, confirm a cold-cache load of a heavy page (e.g. a deep legendary detail tree) still paints inside the "good" band; only then would SSR be worth reopening.
V2 — What staleTime fits each query's volatility? (spec R3, R4, SC4)
Question. Each query type needs a finite staleTime > 0 matched to how fast its data actually changes. What values, and on what evidence?
Verdict. Confirmed — three tiers, values below. All finite (R4 holds: staleness still fires), all > 0 (SC4 holds). These are the proposed values; the plan wires them (a QueryClient default + per-hook overrides through suspenseOptions) and a reviewer may tune within the tier.
Evidence. The seven facade hooks and their volatility drivers (apps/web/src/api/use*.ts), against ArenaNet's own cache lifetimes (docs/gaps/gw2app-caching-comparison.md; docs/architecture/gw2-api.md) and the server's in-memory TTLs (gw2-client.ts BoundedCache: static = no expiry, prices = 60 s, account = 5 min — per caching-comparison.md).
| Tier | Queries | Volatility driver | Proposed staleTime |
|---|---|---|---|
| reference / static | useLegendaries (/api/legendaries), useHealth (/api/health) | Legendary catalog is committed static data; ArenaNet /v2/items/{id} is public, max-age=3600. Effectively immutable within a session. | 1 h (3_600_000) |
| priced structure | useRecipeTree (/api/recipe-graph/:itemId) | User-agnostic tree with trading-post prices folded in server-side. ArenaNet /v2/commerce/prices = public, max-age=120; server price cache = 60 s. Life is bounded by the prices, not the structure. | 60 s (60_000) — tracks the server price TTL. Rises to the reference tier once Spec 3 makes the tree priceless (not this spec's change). |
| per-user | useAccount (/api/account), useMaterials (/api/account/materials), useWallet (/api/account/wallet), useLegendaryRanking (/api/legendaries/ranking) | Account inventory / wallet / ranking change across play sessions; server account cache = 5 min. | 5 min (300_000) |
The QueryClient's default staleTime should be a non-zero floor (proposed 60 s) so any query without an explicit override is never left at 0 (today's new QueryClient() with no options → staleTime: 0, apps/web/src/api/ApiProvider.tsx:4).
Caveat. gcTime is deliberately left at its default (5 min). It governs when an inactive query is evicted from memory, independent of staleTime, and with persistence cut (see spec Out of scope) there is no persister maxAge for it to interplay with — so the epic's "IndexedDB persister + gcTime" verification item is moot and dropped.
V3 — Does Vite 8 / Rolldown emit a separate chunk per React.lazy import, guard-safe? (spec R1, R2, SC1, SC3)
Question. Route code-splitting assumes (a) a dynamic import() becomes its own chunk in this repo's exact Vite 8 / Rolldown build, and (b) it can be done without blinding the P1 #4 route-naming guard.
Verdict. Confirmed — both hold. A React.lazy(() => import('./XxxPage')) behind an element: <XxxPage /> route emits its own chunk and keeps the guard's element: shape.
Evidence.
- Baseline build (
VITE_APP_ENV=prod pnpm build,apps/web): one JS chunkdist/assets/index-B5xUFjYM.js492.39 kB (151.10 kB gzip), CSS 33.45 kB (8.85 gzip). No splitting — every route is in the entry chunk (matchesmain.tsx:6-13eager imports). - Spike (throwaway, reverted): rewrote
features/health/routes.tsxtoconst HealthPage = lazy(() => import('./HealthPage').then((m) => ({ default: m.HealthPage }))), keepingelement: <HealthPage />. Rebuild emitted a separatedist/assets/HealthPage-BZ1Z0XIf.js(0.30 kB) chunk. Rolldown names lazy chunks after their module. - Guard stays green. The
P1 #4guard is a regex/element:\s*<([A-Z]\w+)/gover eachroutes.tsx(apps/web/src/__tests__/conventions.test.ts);react.md(§"route-naming guard readselement: <Name>only") warns thatlazy:/Component:route props bypass it. Keeping theelement: <XxxPage />form (aReact.lazyconst namedXxxPage) means the regex still reads aPage/Layoutname — no guard change needed. - Suspense boundary already exists.
App.tsxrenders exactly one<Suspense fallback>inside aQueryBoundary, wrapping<Outlet />. Lazy route elements suspend into it — R1 adds no new boundary.
Caveat. The HealthPage chunk is tiny (0.30 kB), so the entry chunk barely moved (492.39 → 492.31 kB): the ~151 kB gzip entry is vendor-dominated (React, react-router, TanStack Query, Panda styled-system, @base-ui/react) — see F1. For the SC1 test, enable build.manifest: true (emits dist/.vite/manifest.json with the imports / dynamicImports graph) or scan dist/assets/*.js for one module-named chunk per page and assert no page module sits in the entry chunk — a plan decision.
F1 — The entry bundle is vendor-dominated, so code-splitting trims but does not slash it
Finding. The 151 kB-gzip entry chunk is mostly shared libraries, not route code (V3: splitting a whole page moved 0.30 kB). Route-level splitting will lift the page-specific weight — the legendary detail tree/shopping-list, materials grid, assistant view — out of the initial download, but the vendor core stays in the entry chunk regardless.
Why it matters. Sets an honest expectation for SC1's before/after: the entry chunk shrinks by the page-specific delta, not by half. If a larger cut is ever wanted, that is a separate vendor-chunking / tree-shaking effort (out of scope here — YAGNI until measured). Do not write an SC1 test that asserts an absolute KB ceiling; assert the structural outcome (no route page module in the entry chunk) plus a recorded before/after delta.
Measured after implementation (T2, commit 9beaae2). The entry chunk fell 492.89 → 296.75 kB (151.24 → 88.56 kB gzip) — a ~40 % initial-download cut, larger than F1's estimate: the page-specific weight was more than the 0.30 kB HealthPage spike implied (the legendary-detail tree splits into its own ~19 kB chunk, plus Coins ~36 kB, the assistant and materials views). The pages are now nine separate on-demand chunks. F1's guidance still holds — the codeSplitting test asserts structure, and this delta is recorded as evidence, not asserted as a ceiling.
F2 — The cold start is a ~42 s cliff, confirming Spec 031 is the dominant lever
Finding. Measured cold /api/legendaries = 42.6 s; warm = 0.11 s. The free-tier spin-down (deploy.md) is the single biggest perceived-latency source, and it is Spec 031's to remove — wholly independent of this spec.
Why it matters. Frames this spec's scope honestly: code-splitting + staleTime are real but secondary wins next to the cold start. It also validates the V1 method — measuring against a warm origin is the only way to see whether the render model (not the cold start) is the bottleneck, and it isn't.
Refuted claims
None. Every [NEEDS VERIFICATION] marker was confirmed. (The spec's IndexedDB-persistence idea was cut in brainstorming on evidence that gw2.app uses HTTP Cache-Control, not IndexedDB — recorded in the spec's Out of scope, not a refutation here.)
Graduation
Candidates to lift into docs/architecture/ or docs/gaps/ at step 6 so the next spec need not re-measure:
- The warm-origin front-end performance baseline (V1 numbers: cold 42.6 s, warm-origin cold-cache FCP 0.77 s, data content ~1 s) — a
docs/gaps/record, so Spec 031's after-numbers have a before to compare against and the SSR "not needed" verdict has a durable home. - The
staleTimetier table (V2) — belongs indocs/architecture/react.mdalongside the existing Suspense/QueryClient conventions once implemented, so future hooks inherit the tiers rather than reinventing them. - The
useSuspenseQuery1000 msstaleTimefloor (MIN_SUSPENSE_TIME_MS, found during T1 implementation): react-query clamps every Suspense query's effectivestaleTimeto ≥ 1 s. Irrelevant to our tiers (all ≥ 60 s), but note it beside the tier table inreact.mdso no future hook author expects a sub-secondstaleTimeto work literally.