Classic web performance convergence
What was shared with the experimental UI, what remains separate, and the measured result of the PPR, cache, Nuqs, Suspense, and client-controller pass.
The classic interface now runs on the same performance foundation as the experimental interface. The large audited tools—Build Ups, Outliers, Player Trends, PropHunter, Bet Builder, and all three Value Bets versions—also share their headless controllers. Their requests, URL parsing, loading and access states, derived data, and error behavior come from the same modules; only the renderers are different.
That does not make the entire website one application with two complete visual skins. Several smaller screens still have presentation-specific controllers or different route shapes. The accurate claim is controller parity for the audited tools, not byte-for-byte behavioral identity across every page.
The measured public-document result is nevertheless material. Using the same six-second load levels as the earlier website report, the final artifact reached 272.1 successful requests per second on 100 Club and 340.1 on Player Stats, with no failed requests at any level through 2,048 concurrent connections.
Download the focused raw CSV
Twelve rows for 100 Club and Player Stats across the published 1, 8, 32, 128, 512, and 2,048 connection ladder.
Read the full website comparison
The 104,310-request rebuilt-versus-legacy rerun that supplies the preceding production baseline.
Measured result
The focused final run reused the earlier harness and its production settings: identity encoding, eight warm-up requests, six seconds per level, a 30-second timeout, keep-alive connections, and the same concurrency ladder. It ran against an isolated Next.js 16.3.2 production artifact on loopback.
| Route | Earlier Aug 30 peak | Final peak | Change | Final failures |
|---|---|---|---|---|
| 100 Club | 178.7 req/s | 272.1 req/s | 52.3% higher | 0 |
| Player Stats | 169.0 req/s | 340.1 req/s | 2.0× | 0 |
The final HTML responses were 96,182 bytes for 100 Club and 114,170 bytes for Player Stats. Those are 3.9% and 3.3% larger than the preceding production artifact, so the throughput change is not another payload-reduction result. The meaningful change is that these documents now bypass request-time session work, come from a prerendered PPR route, and advertise a reusable public policy.
The cacheability gate passed all 11 final probes: the nine English public documents plus representative Spanish and Portuguese variants. Each returned:
Cache-Control: public, max-age=300, stale-while-revalidate=3600
CDN-Cache-Control: public, max-age=300, stale-while-revalidate=3600
x-nextjs-postponed: 1x-nextjs-postponed: 1 matters. These are partial prerenders, not fully static
documents. The static shell is prepared at build time and dynamic content can
stream into it. The public cache policy lets the application edge reuse the
result for five minutes and serve stale content while a replacement is
generated for up to one hour.
The two benchmark runs happened at different times on the same shared host. Their settings and harness match, but the comparison does not isolate one line of code or eliminate host-load variation. It supports the final capacity result; it does not prove that cache headers alone caused the increase.
What changed
Rendering and cache boundaries
- Next Cache Components, partial prefetching, and the React Compiler are enabled
together in
apps/statshub-web/next.config.js. - Every classic route built as a partial prerender in the final 2,752-page production artifact.
- 100 Club, Player Stats, About, Contact, Features, Free Tools, Odds Converter, Privacy Policy, and Stats Explained now live under the reusable public route group. Spanish and Portuguese variants are generated for the same paths.
- Public document requests skip Supabase session refresh in the proxy. Private account and betting routes retain the private session path.
- Request-dependent metadata, history, locale restoration, and viewer-local dates sit below nearby Suspense boundaries rather than turning the whole document into a blocking render.
Navigation and retained data
Both presentations now call the same navigation preloader in
src/lib-client/navigation-preloader.ts. It warms six priority destinations in
idle time, batches the remaining graph after load, immediately prefetches links
on pointer, keyboard, or touch intent, avoids speculative work on data-saving
and 2G connections, and refreshes invalidated priority entries.
Both provider trees also use sharedSWRConfiguration. Existing data stays on
screen during revalidation, while the global activity signal reports the
background request. A filter change no longer has to blank a board merely
because the replacement response has not arrived.
URL state and shared controllers
Twenty classic screens now expose navigable state through Nuqs: Home, 100 Club, AGS, Build Ups, Data Visualiser, Favourites, Lineups, Player Cards, Prop Screener, Referees, Super Sub Heroes, Team Screener, Team Trends, Outliers, Player Trends, PropHunter, Bet Builder, Value Bets, Value Bets V2, and Value Bets V3. Typed parsers reject malformed page, date, boolean, set, and enum values. Default values are removed from the URL, and shallow replacement avoids a server navigation for client-only filter changes.
Build Ups is the clearest version of the target architecture. Classic and
experimental views both call useBuildUpsController; the controller owns Nuqs
state, validation, Poisson calculation, progress, and timer cleanup. The two
components only map that result into different markup.
AGS and Player Cards share their date, fixture, team, competition, and tab state. Their default date is resolved from the viewer's time-zone cookie in a streamed server boundary, so server rendering does not guess from the container's UTC clock. Home likewise shares its server loader and URL state with the experimental implementation.
Outliers now has one server loader and one URL controller. The classic page no longer runs a second filter-options request, tournament request, or imperative board fetch in the browser, and it no longer appends a timestamp that defeats HTTP and data-cache reuse. Both renderers receive the same cached server data and map the same state into different markup.
Player Trends now uses one three-key SWR chain in both renderers: events, fixtures, and the result board. This removed 442 lines of classic request and effect choreography, including the separate manual Find Trends request. The URL is the shared filter input and retained SWR data is the shared transition behavior.
PropHunter's 29 URL parameters, fixture cache, player search, sorting, pagination, and odds derivation now live in one injected controller. The classic page kept its markup and saved-preset controls while dropping 1,043 lines of duplicated controller code. Its preset list is now a keyed SWR resource rather than effect-driven mirror state.
The original Value Bets view now shares its 19-field Nuqs controller with the experimental view and receives its initial filter options and board through a streamed server loader. The classic page no longer owns an 18-argument fetch function, request counter, mutable timeout debounce, filter-options effect, or board-fetch effect.
Value Bets V2 and V3 now follow the same rule. Each presentation calls the same versioned controller for Nuqs state, keyed SWR requests, access checks, table derivations, and mutations. Bet Builder likewise has one controller for URL state, correlation, projections, lineup fallback, and slip construction. The three classic page files lost 2,606 net lines of duplicate controller code while keeping their existing markup.
The shared controllers are presentation-neutral. Tracker helpers, Bet Builder
state, Build Ups state, and Value Bets V3 state live under src/lib rather than
inside either UI tree. A boundary test rejects runtime imports from classic or
experimental components, preventing a headless controller from silently
pulling the other presentation into its client bundle.
The old imperative board choreography was removed from 100 Club and Prop Screener. Their public boards are keyed SWR resources: the URL is the request input, previous rows remain usable during revalidation, and response pagination no longer writes back into the filter state that triggered it. Team Screener's page handler follows the same single-owner rule and no longer issues a second request beside its effect.
Is classic now only a different skin?
No. The result is closer, but the answer depends on the layer:
| Layer | Parity now? | Evidence |
|---|---|---|
| Cache Components, PPR, React Compiler | Yes | One Next.js configuration and one production route graph |
| SWR retention and request activity | Yes | One shared configuration |
| Navigation prefetching | Yes | One shared idle-and-intent engine |
| Home server loader and URL behavior | Yes | Shared loader/state modules feed both renderers |
| Build Ups interaction logic | Yes | One controller, two views |
| Outliers server loader and URL behavior | Yes | One cached loader and one controller feed both views |
| Player Trends request and URL behavior | Yes | One keyed three-stage request engine feeds both views |
| PropHunter request and URL behavior | Yes | One injected 29-parameter controller feeds both views |
| Value Bets V1, V2, and V3 | Yes | One controller per version feeds both presentations |
| Bet Builder | Yes | One URL, request, correlation, projection, lineup, and slip controller |
| AGS and Player Cards navigable state | Partial | Classic pair shares a controller; experimental uses date route segments |
| 100 Club, Prop Screener, Referees, Team tools | Partial | Behavior is aligned, but classic and experimental controllers are still separate |
The practical rule after this pass is that new performance infrastructure and audited feature behavior belong in shared modules. The classic page files still contain their visual composition, so deleting every classic client page would delete the classic design. The duplicate request and state machinery for the audited large tools, however, has been removed.
Verification
| Check | Result |
|---|---|
| TypeScript | Passed |
| Vitest | 1,053 tests passed across 69 files |
| ESLint | 0 errors in every changed-file run; legacy warnings remain |
| Focused cache audit | 11 of 11 routes passed |
| Production build | Passed; 2,752 pages generated |
| Classic route output | Every listed classic route reported as a partial prerender |
| Focused load run | 12 rows, 0 failed requests |
The production build exposed one integration fault before the final run: 100 Club had gained Nuqs state after moving under the public provider, but that provider did not mount the Nuqs adapter. The build failed while prerendering the route. The public provider now mounts the adapter, and a source-level boundary test pins that requirement.
Remaining performance work
No known controller gap remains in the audited large-tool set. The next parity work is narrower: extract shared controllers where the smaller classic and experimental screens still own equivalent state separately, especially 100 Club, Prop Screener, Referees, and the team tools. Route-shaped differences in AGS and Player Cards should remain explicit unless both presentations adopt the same URL model.
The final build also reported that several league-season player-stat responses are larger than Next's 2 MB data-cache entry limit; the largest observed body was 8.2 MB. The league page renders only 12 leaders and two aggregate fields, so caching that full payload is wasted work. Fixing it safely needs a projected leader response from the API, or a web cache function that fetches uncached raw data and stores only the reduced shape. This pass records the issue rather than silently making league data dynamic.
Finally, the remaining lint warnings include effect-driven state synchronization and render-time ref writes in both old and experimental chart code. They are not new failures, but they are a concrete backlog for reducing avoidable renders after controller convergence is complete.
Reproduction
The final focused run used:
node apps/statshub-web/scripts/bench-vs-legacy.mjs \
--only=new \
--new-origin=http://127.0.0.1:3902 \
--endpoints=hundred-club,player-stats \
--levels=1,8,32,128,512,2048 \
--duration=6 \
--cooldown=2 \
--timeout=30The implementation is split across small checkpoints: e2c7a027 for Nuqs and
controller changes, 9e8d57c3 for shared navigation boundaries, dea250ee
for public prerender routes, 6eaa0385 for streamed fixture-tool defaults,
692e72e1 for the public Nuqs boundary, and 2cecc377 for the final public
cache set. The controller convergence continued in a37d0cc2 for Outliers,
4075d8df for Player Trends, 9e847e73 for PropHunter, and 2f7e470f for
the original Value Bets view. The final large-controller extractions are
40ed79ad for Value Bets V2, f53ad7af for Value Bets V3, and dfca7cef for
Bet Builder. cf604af5 pins the presentation-neutral controller boundary.
Website rebuild performance against legacy
A client-ready comparison of HTML throughput, latency, payload size, saturation, and memory across 91,909 requests to five matched pages.
Go API query optimization, measured
A complete query-site audit, the changes made from its execution plans, and a production-mode comparison with the legacy API on the same 35 GB Postgres mirror.