StatsHub Docs
Reports

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.

The rebuilt website is competitive on a dynamic player page at low load, but it does not yet match the legacy site's serving capacity. The largest gaps appear on public pages that legacy can return as compact, reusable HTML while the rebuild generates a larger, private response for every request.

Across 91,909 requests to five matched pages, the two sites returned 85,064 complete HTML documents. Legacy completed 73,426; the rebuild completed 11,638. Despite that difference, the rebuild transmitted 7.90 GB, compared with legacy's 6.62 GB, because each rebuilt document is substantially larger.

Executive results

MeasureLegacy websiteRebuilt websiteResult
Home peak without failures18.5 req/s7.6 req/sLegacy 2.4× higher
100 Club peak without failures1,007.4 req/s70.1 req/sLegacy 14.4× higher
Player Stats peak without failures790.6 req/s48.7 req/sLegacy 16.2× higher
Player detail p50, one connection26.5 ms22.4 msRebuild 15.5% lower
Fixture detail p50, one connection18.6 ms24.4 msLegacy 23.8% lower
Memory after cooldown169.5 MiB1,188.9 MiBLegacy uses 7.0× less

What the measurements support

The rebuild's dynamic data path can match or beat legacy latency when the queue is shallow. Its current document layer is the constraint: public responses are 3.8× to 8.0× larger on four of the five routes, public pages are marked private/no-store, and the server retains materially more memory after extreme load. Caching and response size should be addressed before increasing web capacity with more instances.

Public-page throughput

The widest gap is on routes that can be prepared once and reused. Legacy serves /100-club at roughly 1,000 requests per second from 128 through 2,048 connections. The rebuild plateaus around 70 requests per second.

At 2,048 connections, legacy completes every /100-club request with a 2.16 s median. The rebuild completes 2,035 of 2,048 with a 30.83 s median. Both processes remain available after the run; the difference is queue length, not a crash.

/player-stats has the same shape:

The zero at 2,048 is deliberate: all rebuilt requests crossed the 30-second timeout, so counting completed timeout errors as throughput would manufacture a success from failed work. Legacy completed all 6,144 responses at that level with a 2.78 s median.

Dynamic pages are closer

The player detail page is the rebuild's strongest result. At one connection it returns in 22.4 ms median, compared with 26.5 ms for legacy. It sustains 52.0 requests per second without failures at 32 connections, against 70.3 for legacy.

RouteLoadLegacy p50Rebuild p50Legacy req/sRebuild req/s
Player detail126.5 ms22.4 ms35.942.3
Player detail8123.1 ms156.5 ms63.750.3
Player detail32448.4 ms602.2 ms70.352.0
Fixture detail118.6 ms24.4 ms52.739.2
Fixture detail8101.8 ms175.0 ms77.744.4
Fixture detail32393.7 ms680.0 ms81.046.0

This is an important distinction. The rebuild is not uniformly slow, and the Go API behind it is not the general bottleneck. The low-load player result shows that its server-side data path can be faster. The larger regression starts when the application has to serialize and queue many full App Router documents.

The response is carrying more work

Every request asked for uncompressed HTML so the comparison measures the bytes each application generates before a proxy or browser compresses them.

RouteLegacy HTMLRebuilt HTMLMultiple
Home1,050 KB2,980 KB2.8×
100 Club62 KB395 KB6.3×
Player Stats85 KB601 KB7.1×
Player detail123 KB985 KB8.0×
Fixture detail110 KB415 KB3.8×

The byte result rules out a simple runtime comparison. The rebuild is doing more work per response: it renders the HTML shell and serializes React Server Component data for hydration. On the player detail page, that extra work still produces a lower low-load median. On the public content pages, it combines with request-time rendering and becomes the dominant cost.

Cache behavior compounds the origin result

Header probes after warm-up returned the same policy on all five routes:

WebsiteCache-Control
Legacypublic, max-age=300, stale-while-revalidate=3600
Rebuildprivate, no-cache, no-store, max-age=0, must-revalidate

The benchmark connects directly to each application container, so no CDN or reverse-proxy cache answered these requests. Legacy's public header therefore does not explain its origin throughput advantage. It does mean that a cache in front of the two sites can reuse legacy documents for five minutes while it must forward every rebuilt request to the application.

The source shows why some rebuilt routes become request-time documents. The 100 Club route reads the signed-in reader's watchlist through getPrivateApi inside the same server component that fetches the public board; that helper reads cookies and disables caching. Public layouts also resolve locale from a cookie and pass the selected catalogue into client providers. Personal data is valid, but placing it in the full document path makes the public shell private.

Saturation and failures

The high levels distinguish a deep queue from a dead process. Neither site stopped responding, and both were healthy after the run.

RouteLegacy first timeout levelRebuild first timeout level
Home512512
100 ClubNone2,048
Player StatsNone2,048
Player detailNone2,048
Fixture detailNone2,048

Legacy's home page records 41 timeouts at 512 connections, then times out 2,048 queued requests at the final level. The rebuild reaches the same condition at 512, so its 2,048 level is skipped. On the other four routes, legacy completes every request through 2,048 connections. The rebuild completes most player detail requests at that level, but fully times out on Player Stats and Fixture.

Memory after load

Container memory was sampled before the run, immediately after it, and after the servers had cooled without receiving benchmark traffic. These are snapshots, not a continuous peak measurement.

PointLegacyRebuildRebuild / legacy
Before71.2 MiB384 MiB5.4×
Immediately after171.6 MiB3,196 MiB18.6×
After cooldown169.5 MiB1,188.9 MiB7.0×

The rebuild releases most of the immediate surge, but it settles about 805 MiB above its starting point. This run is too short to call that a leak. It is enough to make retained memory a measured capacity concern and to justify a heap profile during the next pass.

Test design

The comparison ran on 2026-08-29 with the following controls:

VariableSetting
Host16 CPU cores, 61.85 GiB memory
TargetsProduction-built Next.js 14 legacy container and Next.js 16 rebuild container
NetworkDirect loopback connection to each application; no TLS, Caddy, or CDN
DataSame Postgres mirror; each website uses its deployed data path
RoutesHome, 100 Club, Player Stats, one fixed player, one fixed fixture
OrderOne target at a time to avoid direct CPU competition
Warm-upEight complete requests before each route
Load levels1, 8, 32, 128, 512, and 2,048 keep-alive connections
DurationSix seconds per level, plus time to drain in-flight requests
Client timeout30 seconds
EncodingIdentity; raw HTML bytes, without compression
Recorded fieldsRequests, successes, failures, bytes, throughput, p50, p90, p99, maximum latency

The fixed player and fixture ensure both applications render the same subjects. The response bodies are not byte-identical because the products do not ship the same document architecture; payload size is therefore a measured result, not a controlled constant.

What the report does not measure

  • It measures the initial HTML document, not JavaScript, CSS, images, hydration, interaction latency, or Core Web Vitals in a browser.
  • Identity encoding exposes server serialization cost. Real clients negotiate compression, which reduces transfer size but adds compression work somewhere in the delivery path.
  • Client and servers share one host. At high concurrency, the load generator competes with the target for CPU and memory bandwidth.
  • Six-second levels establish throughput and saturation. They do not establish long-term garbage-collection behavior or a memory leak.
  • The legacy public cache policy may be too broad for personalized pages. The report identifies its capacity effect; it does not recommend caching private user data.
  1. Separate public and private rendering. Render cacheable board and entity documents independently from watchlists, sessions, locale preferences, and other reader-specific islands.
  2. Restore public cache semantics. Keep authenticated fragments private, but allow shared page shells and public API reads to be reused by the application and edge cache.
  3. Set an HTML/RSC payload budget. Start with the 100 Club and Player Stats routes, where the rebuild emits 6.3× and 7.1× the legacy bytes.
  4. Profile retained memory. Capture a heap snapshot after the 512-connection level and compare it with the cooled 1.16 GiB state.
  5. Rerun the same matrix. The checked-in harness and raw CSV make caching, payload, latency, and failure changes directly comparable with this baseline.

Full rerun on 2026-08-30

Two production-artifact changes reduced public response work without mixing reader data into cacheable content:

  • 100 Club now loads its public board and private watchlist through separate browser SWR resources. Its standalone HTML/RSC response fell from the 395 KB baseline to 340,678 bytes (13.8% lower) and no longer serializes game ids, screener rows, or watchlist data.
  • The public provider layout no longer renders every page twice around a request-time locale-cookie boundary. Player Stats fell from 601 KB to 106,070 bytes (82.4% lower), bringing it from 7.1× legacy payload to 1.25×. Team Stats measured 95,851 bytes and Odds Converter 91,265 bytes on the same artifact.

The complete five-route, six-level matrix was rerun against fresh production containers. It issued 104,310 requests: legacy completed 75,638 of 77,586, and the rebuild completed 24,777 of 26,724. Legacy's 1,948 failures and the rebuild's 1,947 failures occurred only on Home at 2,048 connections. Every other route completed without a failure through the final level.

MeasureAug 29 rebuildAug 30 rebuildAug 30 legacyAug 30 result
Home peak without failures7.6 req/s26.8 req/s23.3 req/sRebuild 15.0% higher
100 Club peak without failures70.1 req/s178.7 req/s997.8 req/sLegacy 5.6× higher
Player Stats peak without failures48.7 req/s169.0 req/s849.6 req/sLegacy 5.0× higher
Player detail peak without failures52.0 req/s102.6 req/s79.9 req/sRebuild 28.4% higher
Fixture detail peak without failures46.0 req/s101.4 req/s87.8 req/sRebuild 15.5% higher

The rebuild improved its peak zero-failure rate by 3.5× on Home, 2.5× on 100 Club, and 3.5× on Player Stats. Its dynamic player and fixture pages now sustain higher peak rates than legacy. The public-page gap narrowed substantially but remains material: 100 Club and Player Stats still deliver about one-fifth of legacy's peak origin throughput.

The warmed rebuild documents are also much smaller than in the first run. Home is 926,322 bytes, 100 Club 92,591 bytes, Player Stats 110,553 bytes, player detail 152,224 bytes, and fixture detail 142,797 bytes. The corresponding legacy documents are 831,681, 62,326, 84,827, 122,859, and 110,225 bytes. The rebuild is now 1.1× to 1.5× legacy size across the measured set, instead of 2.8× to 8.0×.

The routes still emit private, no-cache, no-store because the shared Next.js PPR shell remains postponed. Legacy continues to emit public, max-age=300, stale-while-revalidate=3600, so an edge cache can reuse legacy's public documents but must forward every rebuilt request to origin.

Cooldown memory improved but remains a capacity concern. The legacy web container settled at 172 MiB and the rebuilt web container at 856.3 MiB, or 5.0× legacy. The separate Go API container settled at 1.053 GiB. The snapshots establish deployment footprint after this run; they do not establish a leak.

A production build also tested Player Stats and Team Stats behind a minimal locale-and-theme provider boundary, without the query, user, history, sport, SWR, navigation-loader, or notification providers. Both routes still built as partial prerenders and returned x-nextjs-postponed: 1 with private/no-store headers; their responses were 105,631 and 95,400 bytes. The route split was reverted because it did not improve the cacheability gate. The remaining postponed boundary is therefore outside that public provider set.

Public cache and memory validation on 2026-08-30

The subsequent route split moved 100 Club and Player Stats into the public document tree while keeping browser-loaded session data outside the reusable response. Unprefixed, Spanish, and Portuguese variants now emit:

Cache-Control: public, max-age=300, stale-while-revalidate=3600
CDN-Cache-Control: public, max-age=300, stale-while-revalidate=3600
Cloudflare-CDN-Cache-Control: public, max-age=300, stale-while-revalidate=3600

The responses still include x-nextjs-postponed: 1; this is a reusable Partial Prerendering document, not a claim that the complete page is static. The proxy bypasses session refresh only for these public HTML documents. Private API requests and browser-loaded user data retain their authenticated cache policy.

A short differential check at 32 and 128 connections returned no failures:

RouteCandidate peakLegacy peakRemaining origin gap
100 Club322.6 req/s1,060.9 req/s3.3×
Player Stats337.5 req/s827.5 req/s2.5×

The candidate improves on the 178.7 and 169.0 req/s peaks from the complete rerun. Direct-origin throughput still trails legacy, but a conforming edge can now reuse the documents for five minutes instead of forwarding every request.

A separate controlled memory check compared two fresh containers from the same preview image. The uncapped container peaked at 433.3 MiB during the two-route load and the container with --max-old-space-size=384 peaked at 351.4 MiB. The cap reduced throughput by approximately 10% on 100 Club and 22% on Player Stats. Both containers settled to 157–161 MiB after cooldown, close to the 166.9 MiB legacy snapshot. The runtime cap was rejected because it reduced capacity without improving steady-state memory.

Client conclusion

The rebuild sustains a higher peak zero-failure rate than legacy on Home, player detail, and fixture detail while emitting documents close to legacy size. The public data pages now return reusable edge-cache policy and reach 322.6 and 337.5 requests per second directly from origin. Their direct-origin gap is 2.5×–3.3× rather than the earlier 5×–5.6×.

Fresh-container cooldown memory is now within the legacy envelope. A forced V8 cap is counterproductive. The remaining capacity evidence is operational: run the release image behind the intended edge, confirm cache hits for unprefixed and localized documents, and repeat the smoke after deployment. The measured code-path regressions that originally blocked the gate no longer remain.

On this page