A mostly-static route downloads nearly as much script as your most interactive route. How would you find out why?
answer
- one visitor pays one route
- split baseline from route delta
- a shared shell prices every page
- one interactive leaf pulls its subtree
- compare against the route with script blocked
basics
~20 sSplit the per-route build output into the baseline every route pays and what this route adds. Usual causes: whole-route hydration scope, an interactive element dragging its subtree in, or a shared layout that widens scope for every page beneath it.
solid answer
~50 sStart from the build output rather than the browser: it reports what each route must download before it is interactive. Split that into the **shared baseline** every route pays — framework runtime, the shell, anything a global layout hydrates — and the route's **own** addition. A large baseline with a small delta means the problem is not this route at all; something in the shared shell became interactive and widened hydration scope for everything under it. A large delta on a route with almost no controls usually means scope, not size: under whole-route hydration every component in the tree ships whether or not it has behaviour, and one small interactive leaf can pull an entire subtree and its transitive imports along. Then confirm in the browser, and fix by narrowing scope: mark only the interactive parts, push heavy dependencies behind the boundary, or keep the region out of hydration.
go deeper
Learn to look at the per-route line in the build output and notice that part of it is shared by every route. One visitor pays for one route, not for the whole application.
Explain the two usual causes: whole-route scope includes components with no behaviour, and an interactive leaf drags its subtree and imports along with it.
Diagnose in order — baseline versus delta, confirm in the browser with a cold cache, then narrow scope or defer the dependency — and say what each fix costs in authoring constraint.
Own the drift: decide where the per-route number is visible, which changes get reviewed for scope, and how much the team is willing to constrain shared layouts to keep every page cheap.
## Read the budget per route, not per application Hydration cost is a **per-route number**. The application total tells you nothing about what a given visitor pays, because a visitor lands on one route. Most build pipelines report, for each route, the script that must arrive before that route is interactive, along with the portion shared by every route. Those two numbers are the whole diagnosis: - **shared baseline** — framework runtime, the root shell, anything a global layout hydrates, shared utilities pulled in by more than one route; - **route delta** — what this route adds on top. ## Decide which number is the problem 1. **Large baseline, small delta.** The route is innocent. Something in a shared layout or shell became interactive, so hydration scope widened for every page beneath it. This is the most common and the most easily missed, because the change that caused it was made in a file no one associates with the affected route. 2. **Small baseline, large delta.** The route's own tree is expensive. Either it hydrates far more than it needs, or one genuinely interactive part is dragging heavy code behind it. 3. **Both large.** Treat them separately; they have different owners and different fixes. ## Why a static-looking route can carry an application-sized payload - **Whole-route hydration scope.** Every component the route renders must exist in the browser, so prose, layout chrome and footers ship alongside the controls. Interactivity is not a factor in what is included. - **Transitive pull-in.** One small interactive leaf drags its subtree and everything its module graph imports. A date picker, a rich-text renderer or a charting dependency inside an otherwise static region prices the whole region. - **Boundary placement.** Marking a region interactive at too high a level includes everything below it. The boundary is normally best pushed as far down as the interactivity actually goes. - **Eagerly imported heavy modules.** Code reached only on a rare branch still ships if it is imported statically; a dynamic `import()` at the point of use removes it from the initial cost. - **Data-shaped payload.** The state embedded for takeover can rival the code. It is a different budget line with a different fix, but it lands in the same total, so separate them before concluding. ## Confirm before fixing Build output is a static estimate; verify in a browser that the script is actually fetched and executed on the path to interactivity. - Load the route with a cold cache and watch which scripts are requested before the page responds. - Check main-thread work after the paint: a long block of execution with no new DOM is takeover. - Compare against the route with script blocked. Whatever still works is what did not need hydrating in the first place — often more of the page than the team assumed. ## The fixes, in the order usually worth trying | Finding | Fix | What it costs you | |---|---|---| | Shared shell hydrates for one small control | Narrow the interactive region to that control | An explicit boundary in the shell | | Boundary drawn too high | Push it down to the interactive leaf | More boundaries to maintain | | Heavy dependency behind a rare branch | Load it on demand at the point of use | A first-use delay and a loading state | | Region has no behaviour at all | Exclude it from hydration | No client state in that region | | Route is read-only end to end | Ship no client script for it | Any in-place update must be re-expressed | ## Make it stick A route's scope drifts upward one feature at a time, and nobody notices, because each change is small and lands in a different file from the page it affects. Two habits catch it: record the per-route number as part of the build output the team actually looks at, and review changes to shared layouts and shells specifically for whether they widen hydration scope — that is where a one-line addition prices every page in the application. ## Where frameworks differ The granularity of this reporting varies. Some meta-frameworks print a per-route table with a shared line by default; others require an analysis step over the build; a few make scope explicit enough in the source that the boundary can be read directly from the code. Where the tooling is thin, the substitute is the blocked-script comparison above: it is crude, but it answers the only question that matters — how much of this route needed the second delivery at all.
- Why does an interactive element added to a shared layout affect routes that never use it?Because the layout wraps every route beneath it. Making it interactive puts it in the hydration scope those routes inherit, so its code and its imports join the shared baseline that every one of them downloads. The fix is to narrow the interactive region to the element itself rather than the layout that contains it.
- How do you separate code cost from embedded-state cost in the same total?Look at what is served: script resources versus the serialised state carried in the response. They have different shapes and different fixes — narrowing hydration scope shrinks the first, while the second is governed by how much data the render is allowed to hand across the boundary. Diagnose them separately or you will apply the wrong fix.
- What does loading the route with script blocked actually tell you?It shows the part of the route that never needed the second delivery. Content that still reads, links that still navigate and forms that still submit are all working without hydration, so any script attached to them is scope you could remove. It is a fast sanity check on how much of the page is genuinely interactive.
saying these in an interview costs you the question
- Judges payload from the application total instead of per route
- Blames the route without checking the shared baseline first
- Assumes a small component cannot pull in a large dependency
- Confuses embedded state cost with component code cost
- Believes rarely used code is free if it is imported statically
- Treats one measurement as fixed rather than something that drifts