StatsHub Docs
Reports

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.

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.

RouteEarlier Aug 30 peakFinal peakChangeFinal failures
100 Club178.7 req/s272.1 req/s52.3% higher0
Player Stats169.0 req/s340.1 req/s2.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: 1

x-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.

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:

LayerParity now?Evidence
Cache Components, PPR, React CompilerYesOne Next.js configuration and one production route graph
SWR retention and request activityYesOne shared configuration
Navigation prefetchingYesOne shared idle-and-intent engine
Home server loader and URL behaviorYesShared loader/state modules feed both renderers
Build Ups interaction logicYesOne controller, two views
Outliers server loader and URL behaviorYesOne cached loader and one controller feed both views
Player Trends request and URL behaviorYesOne keyed three-stage request engine feeds both views
PropHunter request and URL behaviorYesOne injected 29-parameter controller feeds both views
Value Bets V1, V2, and V3YesOne controller per version feeds both presentations
Bet BuilderYesOne URL, request, correlation, projection, lineup, and slip controller
AGS and Player Cards navigable statePartialClassic pair shares a controller; experimental uses date route segments
100 Club, Prop Screener, Referees, Team toolsPartialBehavior 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

CheckResult
TypeScriptPassed
Vitest1,053 tests passed across 69 files
ESLint0 errors in every changed-file run; legacy warnings remain
Focused cache audit11 of 11 routes passed
Production buildPassed; 2,752 pages generated
Classic route outputEvery listed classic route reported as a partial prerender
Focused load run12 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=30

The 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.

On this page