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.
Download the raw CSV
All 59 measured rows: concurrency, requests, errors, bytes, throughput, and p50/p90/p99/maximum latency.
Download the 2026-08-30 rerun
The complete 60-row follow-up after the public response and provider changes.
Read the API benchmark
The matching client report for the Go and legacy API implementations.
Executive results
| Measure | Legacy website | Rebuilt website | Result |
|---|---|---|---|
| Home peak without failures | 18.5 req/s | 7.6 req/s | Legacy 2.4× higher |
| 100 Club peak without failures | 1,007.4 req/s | 70.1 req/s | Legacy 14.4× higher |
| Player Stats peak without failures | 790.6 req/s | 48.7 req/s | Legacy 16.2× higher |
| Player detail p50, one connection | 26.5 ms | 22.4 ms | Rebuild 15.5% lower |
| Fixture detail p50, one connection | 18.6 ms | 24.4 ms | Legacy 23.8% lower |
| Memory after cooldown | 169.5 MiB | 1,188.9 MiB | Legacy 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.
| Route | Load | Legacy p50 | Rebuild p50 | Legacy req/s | Rebuild req/s |
|---|---|---|---|---|---|
| Player detail | 1 | 26.5 ms | 22.4 ms | 35.9 | 42.3 |
| Player detail | 8 | 123.1 ms | 156.5 ms | 63.7 | 50.3 |
| Player detail | 32 | 448.4 ms | 602.2 ms | 70.3 | 52.0 |
| Fixture detail | 1 | 18.6 ms | 24.4 ms | 52.7 | 39.2 |
| Fixture detail | 8 | 101.8 ms | 175.0 ms | 77.7 | 44.4 |
| Fixture detail | 32 | 393.7 ms | 680.0 ms | 81.0 | 46.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.
| Route | Legacy HTML | Rebuilt HTML | Multiple |
|---|---|---|---|
| Home | 1,050 KB | 2,980 KB | 2.8× |
| 100 Club | 62 KB | 395 KB | 6.3× |
| Player Stats | 85 KB | 601 KB | 7.1× |
| Player detail | 123 KB | 985 KB | 8.0× |
| Fixture detail | 110 KB | 415 KB | 3.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:
| Website | Cache-Control |
|---|---|
| Legacy | public, max-age=300, stale-while-revalidate=3600 |
| Rebuild | private, 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.
| Route | Legacy first timeout level | Rebuild first timeout level |
|---|---|---|
| Home | 512 | 512 |
| 100 Club | None | 2,048 |
| Player Stats | None | 2,048 |
| Player detail | None | 2,048 |
| Fixture detail | None | 2,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.
| Point | Legacy | Rebuild | Rebuild / legacy |
|---|---|---|---|
| Before | 71.2 MiB | 384 MiB | 5.4× |
| Immediately after | 171.6 MiB | 3,196 MiB | 18.6× |
| After cooldown | 169.5 MiB | 1,188.9 MiB | 7.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:
| Variable | Setting |
|---|---|
| Host | 16 CPU cores, 61.85 GiB memory |
| Targets | Production-built Next.js 14 legacy container and Next.js 16 rebuild container |
| Network | Direct loopback connection to each application; no TLS, Caddy, or CDN |
| Data | Same Postgres mirror; each website uses its deployed data path |
| Routes | Home, 100 Club, Player Stats, one fixed player, one fixed fixture |
| Order | One target at a time to avoid direct CPU competition |
| Warm-up | Eight complete requests before each route |
| Load levels | 1, 8, 32, 128, 512, and 2,048 keep-alive connections |
| Duration | Six seconds per level, plus time to drain in-flight requests |
| Client timeout | 30 seconds |
| Encoding | Identity; raw HTML bytes, without compression |
| Recorded fields | Requests, 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.
Recommended order of work
- Separate public and private rendering. Render cacheable board and entity documents independently from watchlists, sessions, locale preferences, and other reader-specific islands.
- 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.
- 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.
- Profile retained memory. Capture a heap snapshot after the 512-connection level and compare it with the cooled 1.16 GiB state.
- 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.
| Measure | Aug 29 rebuild | Aug 30 rebuild | Aug 30 legacy | Aug 30 result |
|---|---|---|---|---|
| Home peak without failures | 7.6 req/s | 26.8 req/s | 23.3 req/s | Rebuild 15.0% higher |
| 100 Club peak without failures | 70.1 req/s | 178.7 req/s | 997.8 req/s | Legacy 5.6× higher |
| Player Stats peak without failures | 48.7 req/s | 169.0 req/s | 849.6 req/s | Legacy 5.0× higher |
| Player detail peak without failures | 52.0 req/s | 102.6 req/s | 79.9 req/s | Rebuild 28.4% higher |
| Fixture detail peak without failures | 46.0 req/s | 101.4 req/s | 87.8 req/s | Rebuild 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=3600The 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:
| Route | Candidate peak | Legacy peak | Remaining origin gap |
|---|---|---|---|
| 100 Club | 322.6 req/s | 1,060.9 req/s | 3.3× |
| Player Stats | 337.5 req/s | 827.5 req/s | 2.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.
Go API performance against the legacy API
A client-ready comparison of throughput, latency, and memory from 46 controlled load-test runs against the Go and legacy Next.js API implementations.
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.