Deferred Hydration — Deciding What Is Allowed to Wait
Photo by Guohua Song on Pexels
Part 13 pinned down the complaint: the document page looks finished while the revision switcher sits there ignoring clicks. The cause is that hydration hasn’t reached it yet. And the obvious response, hydrate less and hydrate later, is right for some components and actively harmful for others. So this article is about telling the two apart.
The examples come from the fictional document application. I’m not giving any timings, because they depend entirely on the page, the device, the network and the cache state.
Table of Contents
- Two Components With Opposite Requirements
- Visible Is Not Interactive
- Choosing a Trigger per Component
- What an Idle Callback Promises
- Preload Hints Interact With the Choice
- Deferring the Whole Application
- What to Measure
- The Two Components Resolved
- What’s Next
Two Components With Opposite Requirements
Our document page contains, among other things:
- the revision switcher, sitting at the top next to the title. It’s the reason people open the page at all, and a visitor may well click it the instant they see it.
- the change-volume chart, well below the fold, which visualizes how much the document changed between revisions. It’s nice to have, it’s the heaviest component on the page by dependency weight, and most visits never scroll far enough to see it.
Hydrate both eagerly and the chart’s code competes with the switcher’s during startup, on a device that may have very little capacity to spare. Defer both and the switcher is inert at precisely the moment it’s most likely to be used. Neither uniform policy is defensible, and that’s the whole argument for treating hydration as a per-component decision.
Visible Is Not Interactive
Between the moment server-rendered HTML paints and the moment the components respond, there’s a window during which the page is really just a picture of an application.
Let me be precise about what that window is not. It isn’t Total Blocking Time, which measures the blocking portions of long main-thread tasks inside a lab window. A page can have very little blocking work and a long unusable window, simply because the code that would make a control work hasn’t even been requested yet. Optimizing for the metric and optimizing for the window are two different activities, and only one of them is what your user actually noticed.
One thing does work in your favour: not everything needs hydration in order to function. A server-rendered anchor navigates, a plain form submits, and a <details> element opens. Controls whose behaviour is entirely JavaScript, like a switcher that swaps content in place or a filter that re-queries, have nothing at all to fall back on. So they need either progressive enhancement, meaning you render them as something that works without JavaScript and enhance afterwards, or prompt hydration, or at the very least an honest loading state so the interface doesn’t claim a readiness it hasn’t got.
Choosing a Trigger per Component
Frameworks increasingly let you declare when a component hydrates, and the useful triggers map rather neatly onto recognizable component roles:
| Trigger | Suitable for | Risk if misapplied |
|---|---|---|
| Eager (default) | Primary controls, anything above the fold a visitor may use immediately | Startup contention when overused |
| On visibility | Below-the-fold charts, carousels, maps | A fast scroller arrives before it is ready |
| On idle | Secondary widgets that should work soon but not first | Idle may not come on a busy page |
| On media query | Components only present at some viewports | Resize behaviour needs testing |
| On interaction | Rarely used heavy components, e.g. an editor behind a button | The first interaction pays the load, so it needs feedback |
Whatever trigger you pick, two contracts have to survive it. The component’s state has to be the state the server rendered, because a deferred component still hydrates against existing markup, so Part 12’s rule applies completely unchanged. A deferred component that recomputes a value is just a mismatch that happens later and is correspondingly harder to catch. And its accessibility behaviour has to be intact when it arrives: a control that manages focus, announces changes, or implements keyboard semantics in JavaScript isn’t accessible while it’s waiting, so “on interaction” for a component like that needs care that the interaction reaching it doesn’t get swallowed.
It’s also worth stating what simply isn’t available. Vue provides no directive that excludes an arbitrary subtree from hydration, so a project-invented hydrate-never is a custom renderer integration which needs its own documentation and tests. And anything left permanently unhydrated is inert markup, which is only acceptable for content that genuinely has no behaviour at all.
What an Idle Callback Promises
requestIdleCallback is the trigger people misread most often. It asks the browser to run a function later on, at a point when the main thread has finished the work already queued up and has some capacity to spare. The optional timeout says: if no such quiet moment has come along within that many milliseconds, run the function anyway.
function scheduleOptionalWork(work, timeoutMs) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(work, { timeout: timeoutMs })
} else {
window.setTimeout(work, timeoutMs)
}
}
The misunderstanding is reading that timeout as a readiness deadline, as in “this component will be interactive within timeoutMs”. It won’t. The timeout bounds when the callback starts, and starting is the cheap part. Everything the component actually needs, so downloading its chunk, parsing it, evaluating the modules and hydrating the markup, all happens after the callback has begun, and it happens on the very same main thread whose busyness delayed the callback in the first place. So on a page that’s under load, the timeout expires, the work starts late, and then takes longer than it would have on a quiet page. The two effects add up rather than cancelling each other out.
There’s a second, smaller misreading in the code above: the setTimeout fallback isn’t an idle callback at all. It detects nothing whatsoever about the browser’s state, it just waits timeoutMs and then runs. So on browsers without requestIdleCallback, your “hydrate when idle” trigger is really “hydrate after a fixed delay”, which is worth knowing before you go and pick the number.
Related mechanism, related misunderstanding: awaiting a promise between two pieces of hydration work looks like it lets the browser breathe, and it doesn’t. Promise continuations run as microtasks, which the browser drains at the end of the current task and before it paints, so your two pieces execute back to back in one uninterrupted block. Part 13 works through exactly why. Giving the browser a genuine chance to paint and handle input means ending the task, via setTimeout, requestIdleCallback, or a scheduling API such as scheduler.yield(), and whichever fallback you settle on needs testing rather than assuming.
Preload Hints Interact With the Choice
Deferring hydration and deferring downloads are two different things, and preload hints are where the two of them meet.
<!-- Works: the stylesheet is fetched but not applied -->
<link rel="stylesheet" href="print.css" media="print">
<!-- Does not work: media is ignored, the module is preloaded anyway -->
<link rel="modulepreload" href="/_nuxt/entry.js" media="none">
That first line is a real technique with a fairly narrow basis: a stylesheet whose media doesn’t match the current output still gets fetched, at low priority, but it isn’t applied and doesn’t block rendering, so media="print" is a way of loading CSS you intend to enable later. The trick then gets generalized into the second line, and that generalization is simply wrong. media is defined for stylesheet links, and modulepreload never consults it, so the module gets fetched exactly as it would have been anyway. Attempts to defer downloads this way give you a page that behaves precisely as before, plus a line of code that everybody later assumes is doing something.
The inverse mistake is removing preload hints to make a waterfall look shorter. “Waterfall” here means the network panel’s staircase of requests, and its depth matters rather more than its width: the browser only learns that a.js needs b.js after it has downloaded and parsed a.js, so a chain of imports turns into a chain of sequential round trips. Preload hints exist to flatten that staircase by naming the dependencies up front, before anything has been parsed at all. Remove them and the requests show up later and in sequence, which can delay the very readiness you were trying to optimize. So if an experiment removes them, go and inspect the production network waterfall afterwards and confirm which other links or imports still pull the same modules. Fewer early requests isn’t the goal, and it’s awfully easy to mistake for one.
Deferring the Whole Application
Beyond per-component triggers sits the invasive option: rewrite the generated entry script so that the application itself starts on a trigger. It can cut early contention substantially, and it’s also a long-term maintenance commitment that comes with a fairly specific checklist:
- generated entry names and output shapes change with framework upgrades;
- script type, integrity, content-security-policy nonce and other attributes must be preserved;
- the trigger must be idempotent, so two events cannot start two startups;
- listeners and timers must be cleaned up after firing;
- existing navigation and resource hints must keep working;
- a failed entry download needs a defined outcome;
- and user actions occurring before listeners exist need an answer.
That last point is the one that decides whether this technique is acceptable on any given page. A click arriving while the entry script is still downloading doesn’t get automatically replayed as the intended action later on. Framework-supported interaction hydration may well provide replay for the boundaries it owns, but a hand-written entry loader inherits no such guarantee, and a button that swallows the first click is a worse experience than one which was merely a bit slow.
And one non-negotiable: don’t relax the content security policy to make an injected loader work. Use the application’s established nonce or external-script mechanism, and verify against a production build rather than against the dev server.
What to Measure
Because the goal here is a usable interface rather than a number, the evaluation is mostly just a list of scenarios:
| Scenario | What must hold |
|---|---|
| Pointer or touch input immediately after paint | The intended action happens, or is visibly queued |
| Keyboard navigation before hydration | Focus order and activation remain sensible |
| Slow connection | Nothing looks ready while silently ignoring input |
| Busy main thread | Idle triggers and their timeouts still produce acceptable behaviour |
| Navigation before hydration completes | No duplicate startup, no stale state |
| Client script fails entirely | Server-rendered content remains useful |
| Repeated audit runs | The application behaves as it does for ordinary visitors |
Alongside that, there are two quantitative comparisons worth keeping: early main-thread work, and the time from paint to the first successful interaction with the page’s primary control. The second of those is the one that corresponds to the original complaint, and it’s the one an audit score doesn’t contain.
And report your results as what they actually are. “Less early main-thread work, with interaction readiness for secondary components moved later” is supportable. “Removed unused JavaScript” usually isn’t, because deferred work is not removed work. It’s the same work at a different moment, which is a trade and ought to be described as one.
The Two Components Resolved
The switcher hydrates eagerly. It’s the page’s entire purpose, a visitor may click it immediately, and its behaviour exists only in JavaScript, so nothing else is defensible. And if its hydration turns out to be slow, then the fix is to make it lighter, with fewer dependencies and less work in setup, rather than to make it later.
The chart hydrates on visibility. It’s below the fold, most visits never reach it, and if a fast scroller does arrive before it’s ready, then a placeholder with its known dimensions keeps the layout stable and tells the truth about what’s going on.
The generalizable part here is the question rather than the two answers: for each interactive component, what happens if a user acts on it in the first second? If the answer is “the action works”, then it may wait. If the answer is “nothing happens and the page gives no sign at all”, then it may not, regardless of what deferring it would do to any metric you care to name. Hydration scheduling is well worth doing deliberately and component by component. It isn’t worth doing to move work outside a measurement window.
What’s Next
- Part 15: Security in a Nuxt SSR App — Which trust boundaries remain when rendering and data access move to the server.
- Part 16: Configuration Generation and Safe Releases — Getting one build artefact into several environments without divergent settings.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Nuxt in Production