跪拜 Guibai
← Back to the summary

Measured Time Budgets Show SSR Moves the Cost Into an FCP-to-TTI Canyon

SSR/SSG/ISR (Part 1): The Canyon and the Time Budget of Rendering Strategies

1. Technical difficulties

Day 26 covered the discovery segment (when the browser learns about resources), and Day 27 covered the transfer and connection segment (what happens after bytes are on the wire). This part covers the segment before them: who generates the HTML.

Conclusion first: rendering location is not a 'performance optimization'; it is a coordinate transformation. It moves the two periods 'can't see anything' and 'can't click anything' to different places on the time axis, but it does not change the lower bound of their sum, nor does it change what decides the width of either period. The three premises this part removes are phrases teams treat as common sense:

  1. 'SSR makes pages faster' - measurements from render-budget.mjs show SSR moves FCP from 945.8ms to 541.4ms, while TTI moves from 1065.8ms to 1888.2ms (822.5ms later, +77.2%). The same JS, server-rendered, is faster to look at and slower to click.
  2. 'Static generation solves performance problems' - measurements from render-budget.mjs show SSG moves TTFB, FCP, and TTI 264ms earlier relative to SSR, and the canyon does not move: delta canyon = 0.0ms. It shifts the whole curve left; it does not change the curve's shape.
  3. 'revalidate = 60 means 60 seconds' - measurements from freshness-ledger.mjs show the Top1 page has an average content staleness of 30s and the coldest page has 45.5 minutes (90.9x difference). Among 10000 pages, 9933 have exactly as many origin revalidations as SSR (saving 0), while adding an average of 24.3 minutes of staleness.

All numbers come from real output of three zero-dependency Node scripts, each with invariant assertions (317 total). A failed assertion exits with code 1. Like Day25 to Day27, these are deterministic simplified models (parameters and boundaries are specified in the 'Running environment' section), designed for comparing strategies in one coordinate system, not for predicting absolute milliseconds on a machine.

Boundary: this part only covers rendering location and interactivity - server-side rendering, static generation, incremental regeneration, streaming, islands, resumability. Caching and transfer were Day27 (done), code splitting is Day30, images and fonts Day31, runtime performance and INP detail Day29. Mixing all of them usually results in none of the four being explained well.

Difficulty 1: SSR does not make the page faster; it swaps 'blankness' for a 'canyon'

Take one page in eight rendering modes. Page model: 2400 components, 9000 DOM nodes, full hydration requires 700KB raw / 230KB gzip JS, HTML is 180KB raw / 59KB gzip, link is 1.5KB/ms (roughly 12Mbps), RTT is 40ms, client CPU throttle is 4x.

Strategy TTFB FCP TTI Canyon Blank JS shipped (gzip)
CSR client rendering 40.0 945.8 1065.8 120.0 945.8 230KB
SSR string rendering 304.0 541.4 1888.2 1346.8 541.4 230KB
SSR streaming + Suspense 75.0 312.4 1659.2 1346.8 312.4 230KB
SSG build-time static 40.0 277.4 1624.2 1346.8 277.4 230KB
ISR hit 40.0 277.4 1624.2 1346.8 277.4 230KB
SSG + islands (same framework runtime) 40.0 277.4 429.3 151.9 277.4 69KB
SSG + RSC (server components don't run JS) 40.0 277.4 368.6 91.1 277.4 29.6KB
Resumability 40.0 277.4 316.4 39.0 277.4 9.9KB

Separating blank period (navigation to first content) from canyon (content to actually clickable), the table says one simple thing:

The net effect can be computed. For SSR vs CSR TTI:

delta TTI = TTI(SSR) - TTI(CSR)
          = server render + hydration - client DOM creation - attach listeners
          = 264.0    + 1215.0 - 540.0      - 120.0
          = 822.5ms

SSR's TTI disadvantage is a conditional quantity, not a law. The script finds the crossover: when the unit cost of creating DOM nodes from JS exceeds 0.15 ms/node (baseline 0.06), CSR's TTI beats SSR. Conversely, a page that is heavy in DOM and light in components (lots of static structure, few interactive components) can let SSR win TTI back. So 'SSR is always slower' and 'SSR is always faster' are both wrong; the question is which of the two terms is larger.

Difficulty 2: Canyon width is decided by JS, not by where rendering happens

This is the main thread. All times in the script go into four accounts, locked down by three identities (all hold across an 81-parameter sweep, tolerance 1e-6):

HTML account = HTML transfer + HTML parse + paint            = 39.4 + 90.0 + 108.0 = 237.4ms
JS ready     = preload overlap + JS transfer + JS parse      =  5.9 + 153.3 + 210.0 = 369.2ms
Hydration    = component count x 0.45 + node count x 0.015   = 1080 + 135        = 1215.0ms

FCP  = TTFB + HTML account
TTI  = TTFB + max(HTML account, JS ready) + hydration
Canyon = max(HTML account, JS ready) + hydration - HTML account

Substituting full hydration: canyon = 369.2 + 1215.0 - 237.4 = 1346.8ms, exactly matching the run. This explains three things at once:

  1. Static generation moves TTFB, not the canyon. SSG moves TTFB from 304.0ms to 40.0ms (264.0ms less). Since TTFB is an addend, FCP and TTI also move 264.0ms earlier, but none of the three quantities in the canyon (HTML account, JS ready, hydration) changes. Delta canyon = 0.0ms.
  2. Streaming moves the left edge of TTFB, not the canyon. Streaming flushes the shell first (75.0ms), FCP moves from 541.4ms to 312.4ms, and TTI moves from 1888.2ms to 1659.2ms (because preload is also discovered with the first byte). Again, delta canyon = 0.0ms. Streaming shortens the wait to see, not the wait to click.
  3. The dominant cost is execution, not size. The three parts of the JS account are 153.3 : 210.0 : 1215.0. Transfer is 9.7%, parse is 13.3%, hydration is 76.7%. Hydration is 7.9x transfer. This explains a common confusion: 'I cut the bundle in half, why didn't it get faster?' You cut half of the 153.3ms (76.7ms); the TTI head is the 1215ms execution.

One detail: gzip only saves transfer, not parse. Cutting gzip in half in the script drops jsTransfer from 153.3ms to 76.7ms (roughly half), while jsParse is exactly unchanged, because parse runs on decompressed bytes. Compression is a network-layer benefit; it does not touch the main thread.

Difficulty 3: When JS gets small enough, TTI's floor changes from JS to HTML

The max(HTML account, JS ready) is not decoration. With full hydration, JS ready (369.2ms) beats HTML account (237.4ms), so TTI is pinned by JS. Once JS is small enough, TTI's floor becomes the HTML account and the canyon degenerates to hydration itself:

Strategy JS ready Hydration Canyon Canyon = hydration?
Full hydration 369.2 1215.0 1346.8 no (JS-pinned)
Islands 114.9 151.9 151.9 yes
RSC 52.6 91.1 91.1 yes
Resumability 21.5 39.0 39.0 yes

Practical meaning: once JS is already small, continuing to remove JS has a suddenly disappearing marginal return, because the floor becomes HTML parsing and painting, which cannot be removed. 'Islands everywhere' is not an infinite direction; its ceiling is HTML account minus its own hydration, and ends at 0.

But in the opposite direction, when JS is large, islands are huge: canyon from 1346.8ms to 151.9ms (8.9x), RSC to 91.1ms (14.8x), resumability to 39.0ms (34.5x).

Difficulty 4: What actually happens when you click inside the canyon

This is a segment almost nobody quantifies in SSR/SSG discussions. hydration-ledger.mjs splits the page into 24 regions and samples 4000 clicks in the [FCP, full-hydration TTI] window of 1346.8ms, weighting target regions by interactive area:

Mode Nothing happens Queued replay Immediate response Queued average wait P95 Worst
Full hydration, single bundle 9.8% 36.4% 53.9% 417.6ms 827ms 1179ms
Full hydration, split bundles (wait for all) 0% 46.1% 53.9% 434.9ms 889ms 1280ms
Selective hydration (edge-to-edge Suspense hydration) 0% 31.5% 68.6% 379.5ms 691ms 1082ms
Islands (hydrate only interactive components) 0% 0% 100% 0ms 30ms 30ms
Resumability (no replay) 0% 0% 100% 0ms 30ms 30ms

Three things:

  1. In the earliest part of the canyon, clicks are completely invalid. 9.8% is exactly equal to the proportion of the canyon before the root listener is attached (the script asserts this). At that point the page has no event handlers; clicking does nothing and nothing is queued. This is not 'slow'; it is missing functionality.
  2. After the root listener is attached, clicks are queued and replayed, but users wait real time: average 417.6ms, P95 827ms, worst 1179ms. This time is exactly the input delay counted by INP in Core Web Vitals. So 'SSR pages have good LCP and bad INP' is not a mystery; it is the histogram of this canyon.
  3. Selective hydration (edge-to-edge Suspense boundaries) improves the first half, not the second. Average immediate coverage goes from 53.9% to 68.5%, TTI from 1624.2ms to 1426.1ms (saving hydration CPU and split-bundle download overlap). But total hydration CPU is unchanged (1215.0ms; asserted), and the canyon remains at least 90% of that CPU. It hides the wait inside the download; it does not make the work disappear.

By contrast, with islands and resumability, 100% of clicks in this 1346.8ms canyon respond immediately, because their TTI (277.4ms) falls inside the first frame (FCP is also 277.4ms). They do not narrow the canyon; they make it nonexistent.

Difficulty 5: Island gains are a function of interaction ratio, and the island floor is the runtime floor

Island architectures (Astro's client:*, React RSC + client components) claim 'run JS only for the parts that need interaction'. The claim holds, but the gain curve has a shape, and its floor is outside your control.

The gain function: full hydration JS is runtime + all components x unit price; islands are runtime + interactive components x unit price (runtime is 140KB raw / 46KB gzip, unit price 0.233KB per component):

Interactive component ratio Island JS raw Island JS gzip vs full hydration Saved
0% 140KB 46KB 20% 80%
2% 151.2KB 49.7KB 21.6% 78.4%
12.5% (this page) 210KB 69KB 30% 70%
25% 280KB 92KB 40% 60%
50% 420KB 138KB 60% 40%
75% 560KB 184KB 80% 20%
100% 700KB 230KB 100% 0%

Both boundaries are exact: at 100% interactive, island JS equals full hydration (asserted); at 0%, 46KB gzip of runtime remains (20% of total JS). Islands are not 'zero JS'; they are 'constant + ratio', and the constant (the framework runtime) is not replaceable.

The most practical result: the multi-framework runtime floor.

Framework count Runtime floor raw gzip % of full hydration JS
1 140KB 46KB 20%
2 280KB 92KB 40%
3 420KB 138KB 60%
5 700KB 230KB 100%
6 840KB 276KB 120%

Three frameworks' runtime floor (138KB gzip) already exceeds all JS of a single-framework island page (69KB); five frameworks' runtime alone equals the full-hydration payload. In practice, 'progressive migration' often means new pages use Vue islands, old components stay React islands, marketing pages pull in a Svelte island: three runtimes, which consume all 70% of what islands saved and still go negative.

Cost growth with page size. Scaling components by 1x, 2x, 4x, 8x (DOM and HTML unchanged, modeling the real degradation of deeper component trees and more wrapper layers):

Scale Components Full hydration CPU / TTI Islands CPU / TTI Resumability CPU / TTI
1x 2400 1215ms / 1624.2ms 151.9ms / 429.3ms 39ms / 316.4ms
2x 4800 2295ms / 2704.2ms 286.9ms / 564.3ms 63ms / 340.4ms
4x 9600 4455ms / 4864.2ms 556.9ms / 834.3ms 111ms / 388.4ms
8x 19200 8775ms / 9184.2ms 1096.9ms / 1506.1ms 207ms / 484.4ms

At 8x, full hydration TTI is 9184.2ms, islands 1506.1ms, resumability 484.4ms. Resumability should be described accurately: it is not 'zero cost', it is 'zero replay'. It has a fixed startup of 15ms, plus a smaller coefficient per interactive component (0.08 ms/component vs hydration's 0.45), so its cost is O(interactive components), not O(all components). On this page at 8x scale it is 2.4% of full-hydration CPU.

One model boundary must be marked: the real cost of resumability lives in serialized state, and this model does not make it grow with the page. Qwik serializes listeners, component tree/boundary info and subscriptions, and app state into HTML and QRLs; this part is O(page size) (both HTML and serialized state grow), and it still preloads some chunks at startup. 'Resumable startup is constant' holds only for eagerly executed JS, not for total cost.

Difficulty 6: Hydration mismatch cost does not enter averages; it enters tails and development time

React hydration mismatch has two fallback levels, spelled out in React's error codes: error 422 is 'start client rendering from this Suspense boundary', error 423 is 'render the entire root on the client' (because the error occurred outside a Suspense boundary). This two-level structure exists in both React 18 and 19. React 19 only changes the log to a single error with a diff, and forces a client re-render for content inserted by third-party scripts. So the claim 'React 19 turns mismatch into per-subtree recovery' is wrong: per-subtree recovery requires the error to happen inside a Suspense boundary.

Quantifying the two levels (using the full-hydration TTI = 1624.2ms as baseline):

Fallback level Extra cost Affected users' TTI
Root-level (throw away the whole HTML, redraw 9000 nodes, re-attach 2400 components' listeners) +660ms 2284.2ms
Subtree-level (redraw only that 400-node / 90-component subtree) +28.5ms 1652.7ms

The difference is 23.2x. The statistical shape matters more:

Mismatch rate Expected page-level cost Affected users at 100k PV/day
0.1% 0.7ms 100
1% 6.6ms 1000
5% 33ms 5000
100% (systematic) 660ms 100000

A 1% mismatch rate makes the page slower by 6.6ms on average; no aggregate dashboard will see it. But those 1000 users see a full-page flash, state reset, and TTI delayed by 660ms. Mismatch cannot be accounted on averages: it is a variance problem, and performance dashboards only look at means.

Systematic mismatch is different. Reading client time, Math.random(), typeof window, or browser locale in the render path makes every render mismatch (100% mismatch rate). The accounts:

Systematic-mismatch SSR route TTI = 1624.2 + 660.0 = 2284.2ms
Same page, CSR route TTI        = 1177.2ms

Server rendering was paid for and the client redraws the whole page anyway, 1107ms worse than never using SSR. This is one of the easiest causes of 'SSR became slower after launch' to miss: it does not error, it does not alert, it only flashes on the user's screen.

One structural fact related to islands: React never checks the HTML of regions that are not hydrated. Under island architecture, a server/client output mismatch does not trigger a fallback; the cost degrades from 'redraw + 660ms' to 'visual misalignment'. That is both a benefit (no catastrophic fallback) and a problem (misalignment does not expose itself; only users see it).

Difficulty 7: ISR's staleness window is inversely proportional to traffic: one config, two lives

'revalidate = 60' sounds like 'refresh every 60 seconds'. Its actual semantics is: after expiry, wait for the next request to trigger regeneration (stale-while-revalidate; Next.js docs and the response-cache source do exactly this: return the old copy to the caller, then regenerate in the background). Since triggering is 'next request', trigger time depends on request interval, which depends on traffic:

Dense requests (lambda * R >= 1): average staleness = R/2, worst = R
Sparse requests (lambda * R < 1): average staleness = R/2 + 1/(2*lambda), worst = R + 1/lambda + render time

With 10000 pages, 1M requests/day, Zipf alpha = 0.9, same revalidate = 60:

Page lambda (req/s) Requests/day Origin revalidations/day Revalidations / requests Avg staleness Worst staleness vs 60s
Top 1 0.74 63739 1440 2.3% 30s 1min 1x
Top 5 0.17 14974 1440 9.6% 30s 1min 1x
Top 10% 1 per 11.3min 127 127 100% 6.2min 12.3min 12.3x
Top 25% 1 per 25.8min 56 56 100% 13.4min 26.8min 26.8x
Top 50% 1 per 48.2min 30 30 100% 24.6min 49.2min 49.2x
Top 75% 1 per 1.2h 21 21 100% 35.2min 1.2h 70.4x
Top 99% 1 per 1.5h 16 16 100% 45.1min 1.5h 90.1x
Coldest 1 per 1.5h 16 16 100% 45.5min 1.5h 90.9x

The same config averages 30 seconds of staleness on a hot page and 45.5 minutes on the coldest page: a 90.9x difference.

The product of the last two columns is worse: from Top 10% downward, revalidations/requests = 100%. On 9933 of 10000 pages, ISR saves zero origin hits compared to SSR (revalidation count exactly equals request count), while adding an average of 24.3 minutes of staleness. The script calls these 'net loss' pages.

One counterintuitive corollary: on these low-traffic pages, raising revalidate from 60s to 3600s saves no origin hits, and only increases staleness from 45.5 minutes to 1.2 hours (worst from 1.5h to 2.5h; asserted). Because the request interval (1.5h) is already much larger than the window, every request finds the entry expired. At that point traffic decides staleness, not config.

The distribution of savings: at whole-site scale, almost all of ISR's origin savings come from the top 5% of pages (the script measures this as 100% of the savings). So:

Savings land on popular pages, which were least stale anyway; costs land on long-tail pages, which are most stale. Dashboards show an average, and the traffic-weighted average filters out exactly the users who suffer most.

The script calculates both views: for ISR R=60, whole-site average staleness is 24.2 minutes page-weighted and 7.7 minutes traffic-weighted (3.2x difference); p95 page-weighted is 43.4 minutes. Your release process lives in page-weighted numbers; your users live in traffic-weighted numbers. Either can legitimately prove 'no problem'.

A self-diagnostic number: 8223 pages (82.2%) have average staleness over 10 minutes, and they carry 25.4% of traffic. If your site has a similar shape, 'revalidate = 60' will never show up as a problem in reports.

Difficulty 8: Revalidation storms belong to event traffic, not average traffic; SWR and request coalescing save two different populations

ISR's origin cost is rendering (unlike a static site, which sends a file), so it is fragile to concurrency at the moment of expiry. First compute when it happens:

Concurrent requests during one regeneration ~= lambda * render time

Under average traffic it never happens: Top1 page lambda = 0.74/s, expected concurrency in a 300ms render window is only 0.22 requests; no storm. Storms belong to event traffic: at lambda = 200/s (flash sale, promotion, hot search), concurrency in the window is 60 requests.

Config Origin revalidations/day Users stuck in render/day
No request coalescing 86400 -
Coalescing (only one regeneration at a time) 1440 -
No SWR (users wait for this render) 1440 86400

The difference is 60x, exactly lambda * render time (asserted). The two mechanisms save different people:

Publish-driven regeneration (on-demand revalidate: invalidate when content changes, instead of waiting for time expiry) wins on both dimensions. For a hot page:

Strategy Revalidations/day Avg staleness Worst staleness
ISR R=60 + SWR 1440 30s 1min
ISR R=3600 + SWR 24 30min 1h
ISR + publish-driven 24 1s 2s
Full SSG rebuild x 24/day 0 (request side) 0s 0s

Publish-driven invalidation uses the same origin hit count as R=3600 (24/day) and reduces worst-case staleness from 1 hour to 2 seconds, because staleness is no longer determined by when traffic arrives, but by when content changes. This is also what flattens the 90.9x spread above: swap the trigger from time to event, and staleness decouples from traffic.

Difficulty 9: Full static generation has three accounts: build time, key space, and Vary: Cookie

'Then why not statically generate everything?' Because full pre-rendering costs are not linear; they carry multipliers.

Account 1: build time.

Page count Full build 24 releases/day Incremental build (0.5% changed)
1,000 4.3min 1.7h 1.1s
10,000 38min 15.2h 11.3s
100,000 6.3h 150.2h 1.9min
1,000,000 62.5h 1500.2h 18.8min

A 10000-page full build is 38 minutes; at 24 releases/day that is 15.2 hours/day, and a pipeline only has 24 hours. This is the real reason ISR exists: not to be 'faster', but to decouple build time from release frequency. Incremental builds (rebuild the changed 0.5%) compress 38 minutes to 11.3 seconds, but require a reliable 'which pages are affected' mapping, which is a dirty-tracking/dependency-tracking engineering problem, not a config problem.

Account 2: key space. As soon as personalization lands on the server-render path, it explodes by product of dimensions:

Locale/region x8 * currency x5 * A/B bucket x4 * login state x2 * membership level x4 = 1280 combinations
1280 * 10000 pages = 12.8 million objects * 26KB gzip = 317.4GB (edge budget 50GB)
Pre-rendering all 12.8M combinations = 800 hours

Account 3: Vary: Cookie. This is the most expensive, because its key count equals the user count (1M), so hit rate for the same URL approaches zero. A claim from 2023 needs updating: this is no longer just 'lower hit rate'. Vercel stopped caching responses whose Vary contains Cookie on September 30, 2026 (the response is still returned, but not entered into cache; diagnostics are x-vercel-cache: MISS and vary_key_denied:cookie). The correct statement: high-cardinality request headers (Cookie, Authorization) make responses never enter the cache. The fix is to remove them from Vary (or set Cache-Control: private explicitly), not to 'expect a lower hit rate'.

2. Complete solution

All nine difficulties reduce to one sentence: rendering location is a coordinate transformation, and you need to know which axis you are transforming. The three zero-dependency scripts measure that axis: time budget at design time, interactivity at review time, freshness and origin revalidations after launch. They share the same unit costs, so a page's numbers can be cross-checked across the three ledgers.

2.1 Tool 1: Turn 'rendering strategy' into a computable time budget

render-budget.mjs is the design-time ruler. It abstracts a page into component count / DOM node count / interactive component count / HTML bytes / JS bytes, and the budget into four addends (TTFB, HTML account, JS ready, hydration). It answers three questions: if this page switched rendering modes, which time period changes, by how much, and did the curve shape change?

Its core is not the strategy table; it is the three identities. With them, 'will SSR be faster here?' stops being an empirical guess:

FCP = TTFB + HTML account
TTI = TTFB + max(HTML account, JS ready) + hydration
Canyon = max(HTML account, JS ready) + hydration - HTML account

The script also solves a crossover: when the unit cost of 'creating DOM nodes from JS' exceeds 0.15 ms/node (baseline 0.06), CSR's TTI beats SSR. This solution is not found by tweaking parameters by hand; it comes from setting delta TTI = server render + hydration - client DOM creation - attach to zero. Being able to solve for the crossover means you understand the tradeoff; not being able to means you are only memorizing conclusions.

To counter 'you made up the unit costs', the script runs an 81-parameter sweep (hydration unit 0.15/0.45/0.90 ms per component * parse unit 0.15/0.30/0.60 ms per KB * DOM creation unit 0.03/0.06/0.12 ms per node * lazy resume unit 0.04/0.08/0.16 ms per component) and asserts five ordering conclusions hold in all 81 combinations:

  1. Static generation and streaming do not change the canyon (delta canyon is always 0).
  2. Static generation vs streaming has delta TTI exactly equal to delta TTFB.
  3. Islands have a smaller canyon than full hydration; RSC has a smaller canyon than islands.
  4. Islands/RSC/resumability canyons always equal their own hydration cost (HTML account is the floor).
  5. The identity TTI = TTFB + max(HTML account, JS ready) + hydration always holds.

A conclusion is only worth an architecture decision if it holds for every parameter value. Absolute milliseconds only matter as comparisons within the same coordinate system.

The full source of render-budget.mjs and its run output are part of the original article; the measured output tables above reproduce the key numbers and assertion summary: 115/115 PASS, plus the 81-parameter sweep, all passed.

This part is the first half of 'Rendering Strategies and Rendering Architecture' (technical difficulties + Tool 1: time budget for rendering strategies). The second half continues with Tool 2 (interactivity curve), Tool 3 (freshness and origin-revalidation ledger), and a change checklist plus real-world scenarios.