Skip to content

Spec 033 — Frontend performance: route code-splitting and staleTime ​

Status: implemented Branch: 033-frontend-perf

Status is set by the human, never by the agent. It moves draft → approved → implemented. (Transcribed draft → approved, then approved → implemented, on the human's explicit instruction, 2026-08-22.)

Part of the gw2.app alignment epic (Wave 1, "Spec 4 · frontend-perf"). Client-only; independent of the two sibling Wave-1 specs (031 backend-edge-caching, 032 recipe-graph-cacheable).

Problem ​

The web client is a pure Vite SPA with two performance gaps that are entirely client-side — neither depends on the backend, so both are fixable now, in parallel with the backend caching work:

  1. No route code-splitting. main.tsx statically imports every feature's route table, and each table statically imports its page component (apps/web/src/main.tsx:6-13, features/*/routes.tsx). So the initial JS bundle contains every route's code; the browser downloads and boots all of it before #root shows anything, on a route the visitor may never open.
  2. staleTime: 0 everywhere. The QueryClient is constructed with no options (apps/web/src/api/ApiProvider.tsx:4), so every query is stale the instant it resolves. Navigating away from a route and back, or any remount, fires a background refetch — even for data that does not change from second to second. gw2.app, by contrast, ships per-route chunks and lets cache lifetimes keep repeat views from refetching (docs/gaps/gw2app-caching-comparison.md).

Separately, it is unknown whether the SPA's first paint is acceptable once Spec 031 removes the free-tier cold start, or whether server-side rendering is warranted. That is a large, speculative build to guess at; it should be a measurement, not an assumption.

User stories ​

Ordered by priority. Each story is independently testable and shippable.

P1 — Route-level code-splitting ​

As a visitor opening one page of the app, I want the initial download to carry only the shell and the route I am on, so the page becomes interactive without first downloading every other route's code.

Independent test: build the app for production. Each feature route page/layout module is emitted as its own chunk and is absent from the entry chunk; opening a route fetches that route's chunk on demand; the route still renders inside the App-shell <Suspense>; and the P1 #4 route-naming guard still detects violations.

Acceptance scenarios

  1. Given the production build, when the entry (initial) chunk's module graph is inspected, then no feature route page/layout module (*Page / *Layout) is part of it — each is a separate, lazily-loaded chunk.
  2. Given the app loaded on one route, when the user navigates to another route, then that route's page chunk is fetched on demand and renders inside the existing App-shell <Suspense> boundary (no new boundary is introduced).
  3. Given the code-split route tables, when the conventions guard suite runs, then the P1 #4 rule (every route element ends in Page or Layout) still detects a violation — splitting the routes does not blind the guard.

P2 — A staleTime matched to each query's volatility ​

As someone moving between pages, I want data I just viewed to stay put when I navigate away and back, so the UI does not flash and refetch on every mount — while genuinely volatile data still refreshes.

Independent test: mount a query hook, unmount it, remount it within its staleTime, and assert no second network request fired; assert every hook's staleTime is greater than 0; assert a query past its staleTime still refetches.

Acceptance scenarios

  1. Given a query whose data is in cache and within its staleTime, when its component remounts, then no network request is fired (0 fetches).
  2. Given each query hook, when its resolved options are inspected, then its staleTime is greater than 0 and matches its volatility tier (reference / priced-structure / per-user).
  3. Given a query whose staleTime has elapsed, when its component mounts, then it refetches — staleness still works; no tier is set to Infinity.

Requirements ​

  • R1 — Each feature's routes.tsx loads its page component lazily (dynamic import()), so the page's module is not part of the entry chunk. The build must emit one chunk per lazily-imported page. (Confirmed — research V3: Vite 8 / Rolldown emits a separate chunk per React.lazy dynamic import, and the build manifest shows route pages absent from the entry chunk.)

  • R2 — Code-splitting preserves the P1 #4 route-naming guard's coverage. The guard reads element: <Name> only — React Router's lazy: and Component: props bypass it (docs/architecture/react.md, "route-naming guard"). The intended mechanism is a React.lazy wrapper kept behind the existing element: <XxxPage /> shape (so the guard still reads a Page/Layout name); the exact wiring is a plan decision, but the guard must still detect a violation afterward.

  • R3 — The one QueryClient (constructed only in ApiProvider, react.md R11) sets a default staleTime greater than 0, and each query type carries a staleTime matched to its volatility tier. No query is left at 0. The tiers and their queries:

    • reference / static — useLegendaries (/api/legendaries), useHealth (/api/health); and the recipe-tree structure.
    • priced structure — useRecipeTree (/api/recipe-graph/:itemId): user-agnostic structure with trading-post prices folded in server-side (~60 s TTL) today, so its life tracks price volatility until Spec 3 makes the tree priceless.
    • per-user — useAccount, useMaterials, useWallet, useLegendaryRanking (each keyed by a hash of the API key), which change across play sessions.

    Exact values (set in research V2): reference / static 1 h, priced structure 60 s, per-user 5 min, with a 60 s default floor so nothing sits at 0.

  • R4 — Staleness still functions: every tier's staleTime is a finite value, never Infinity; a query past its staleTime refetches on mount. This spec tunes refetching, it does not freeze it.

  • R5 — First-paint spike (measurement only, no SSR built). Measure the code-split production build's first paint / time-to-interactive served against a warm backend (a stand-in for post-Spec-031, since Spec 031 removes the cold start), and record in research.md: the number, the method, a go/no-go threshold, before/after entry-bundle sizes, and a verdict on whether SSR is warranted. No SSR is implemented. (Verdict — research V1: SSR is not needed; against a warm origin the cold-cache first paint is ~0.77 s, well inside the "good" band, so the ~42.6 s cold start — Spec 031's job — was the whole problem.)

  • R6 — Client-only, no HTTP surface. No backend endpoint, controller, DTO, OpenAPI document, or generated API client is added or changed. Under the epic's surface rule, this spec belongs to neither Surface A nor Surface B — it is client bundle + cache-config only, and changes no response body (that is Spec 2 / Spec 3).

HTTP surface ​

None — client only. This spec adds and changes zero endpoints, so there is no OpenAPI or generated client change. Stated explicitly because endpoints + OpenAPI are first-class spec content in this project: the deliberate answer here is that there are none.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — In the production build, no feature route page/layout module is in the entry chunk; each loads as its own chunk. (Enforced by a build-manifest test; before/after entry-bundle sizes recorded in research.md as evidence.)
  • SC2 — Opening a route loads its chunk on demand and renders inside the existing App <Suspense> boundary; no new boundary is added.
  • SC3 — After code-splitting, the P1 #4 route-naming guard still detects a violation (the guard is not blinded by moving to a lazy form).
  • SC4 — No query hook has staleTime: 0; each has a finite, per-tier value greater than 0, set from research.md.
  • SC5 — A component remounting within its query's staleTime fires 0 network requests, and a query past its staleTime still refetches.
  • SC6 — research.md records the measured first-paint / TTI against a warm origin, the threshold, before/after bundle sizes, and a verdict on SSR — with no SSR code built.
  • SC7 — Typecheck, lint, the full test suite, the production build (where the React Compiler runs), and docs:build are all green.
  • SC8 — Every acceptance scenario and success criterion maps to a named test in the traceability table.

Out of scope ​

  • Building SSR. Deferred to the R5 spike; only measured here. YAGNI — and research V1 measured it as not needed (see R5).
  • A persistent client cache (IndexedDB / persistQueryClient). Deliberately cut (agreed with the human, 2026-08-22) — a deviation from the epic's Spec-4 sketch, which listed it. Rationale: our measured evidence shows gw2.app uses HTTP Cache-Control: max-age (browser HTTP cache), not IndexedDB, for reload-survival; Spec 031 adds those headers, so a reload's refetch is served from the browser HTTP cache; and P2's staleTime already covers in-session repeat-nav. The only residual benefit — no Suspense-fallback flash on a hard reload — is marginal and unproven; revisit with evidence if the R5 spike shows first paint is a problem. Removing it also drops the epic's "IndexedDB persister + gcTime" verification item.
  • Where data is fetched. Moving prices and account reads to browser-direct ArenaNet calls is Spec 3 (client-direct-anet, Wave 2). This spec changes the bundle and cache config only, not the fetch source.
  • Any response-body change. The shape of every query response is untouched (that is Spec 2 / Spec 3).
  • Prefetching or preloading route chunks on hover/idle — a later optimisation, not needed to split the bundle.

Assumptions ​

  • App.tsx already owns the one <Suspense> + error boundary the layout route wraps every feature route in (react.md; App.tsx). Lazy route components suspend into it, so R1 needs no new boundary — route-level code-splitting is called out there as "the expected next step for this app."
  • The QueryClient is constructed in exactly one place (ApiProvider, react.md R11), so R3's default lives there, with per-tier overrides through the existing suspenseOptions facade (apps/web/src/api/suspenseOptions.ts) — the facade hooks are all useSuspenseQuery.
  • Spec 031 is the source of HTTP cache headers, not this spec. Code-splitting and staleTime deliver their value whether or not Spec 031 has merged, so this spec has no cross-dependency (Wave 1).
  • The React Compiler runs only in vite build (react.md; memory: React-Compiler build gate), so verification runs pnpm build, and any React.lazy wrapper avoids the destructured-default + inline-type props pattern that trips the compiler.
  • Volatility, for the R3 tiers: trading-post prices are short-lived (~2 min at ArenaNet, ~60 s in the server cache); per-user account/materials/wallet change across play sessions; the legendary catalog and recipe-tree structure are effectively static for hours. Exact staleTime values are verified in research.md, not assumed here.

Traceability ​

Each acceptance scenario and success criterion maps to a named test. Filled in (implementation complete). Web tests: the query-behaviour tests in apps/web/src/api/__tests__/, the code-split test in apps/web/src/__tests__/, the route-naming guard in apps/web/src/__tests__/conventions.test.ts.

CriterionTest
P1 #1codeSplitting.test.ts — SC1: no route page module is in the entry chunk; SC2: each route page is its own on-demand chunk
P1 #2codeSplitting.test.ts — same case (each page has its own manifest entry/chunk and is absent from the entry's static-import closure — a BFS over static imports)
P1 #3conventions.test.ts — P1 #4: every component a route renders is named <Name>Page or <Name>Layout (still scans routes.tsx, via the extracted routeElementOffenders) and P1 #4: the rule catches a violation (the synthetic-violation companion case)
P2 #1staleTimeBehavior.test.tsx — P2 #2: a remount within staleTime serves cache, no second request
P2 #2staleTimeBehavior.test.tsx — SC5: <hook> registers its tier as the query staleTime (per-hook it.each, all 7); staleTimes.test.tsx — SC4: every tier is a finite number greater than zero
P2 #3staleTimeBehavior.test.tsx — P2 #3: a remount past staleTime refetches (staleness still fires)
SC1codeSplitting.test.ts — SC1: no route page module is in the entry chunk; SC2: each route page is its own on-demand chunk (real vite build + manifest; entry 492.89 → 296.75 kB / 151.24 → 88.56 kB gzip, recorded in research.md)
SC2codeSplitting.test.ts — same case (page has its own manifest entry/chunk, not the entry's static-import closure)
SC3conventions.test.ts — P1 #4: every component a route renders is named <Name>Page or <Name>Layout and P1 #4: the rule catches a violation (still catches a synthetic violation)
SC4staleTimes.test.tsx — carries the researched tier values, SC4: every tier is a finite number greater than zero, SC4: the QueryClient default floor is a non-zero staleTime (rendered against the real ApiProvider client via a probe child)
SC5staleTimeBehavior.test.tsx — P2 #2: a remount within staleTime serves cache, no second request, P2 #3: a remount past staleTime refetches (staleness still fires), SC5: <hook> registers its tier as the query staleTime
SC6research.md V1 first-paint record (warm-origin cold-cache FCP ~0.77 s) + human review at step 5 — no unit test (real-browser metric)
SC7CI — pnpm typecheck, pnpm lint, pnpm test (728 pass, 1 pre-existing expected-fail), pnpm build (React Compiler), pnpm docs:build — all green
SC8this table