Deferred Hydration Done Right — The `requestIdleCallback` Trick and the `modulepreload` Pitfall
Hero image credit: Photo by Kampus Production on Pexels
Hero image credit: Photo by cottonbro studio on Pexels
Fourteenth in a series about migrating from legacy architectures to a modern Nuxt 4 stack.
The Hydration Dilemma
SSR gives you a fast first paint: complete HTML that the browser can render immediately. Then the framework’s JavaScript arrives, gets parsed, compiled, and executed to hydrate the page—wiring up event listeners and making everything interactive.
During hydration, the main thread is busy. Buttons do not respond. Forms do not accept input. This gap between “looks ready” and “is ready” is Total Blocking Time (TBT).
If SSR has already rendered the page, why rush to download and execute JavaScript before the user even finishes the first paragraph?
flowchart LR A[SSR Server] --> B["HTML Response<br/>Fully rendered markup"] B --> C[Browser parses HTML] C --> D["First Paint / FCP<br/>Looks ready"] D --> E["Hydration JS downloads<br/>parse & execute"] E --> F["Event listeners attached<br/>Interactive"] classDef paint fill:#e0f7fa,stroke:#006064; classDef js fill:#fff3e0,stroke:#e65100; classDef interactive fill:#e8f5e9,stroke:#1b5e20; class B,C,D paint class E js class F interactive
The Naive Approach That Does Not Work
The obvious idea: delay JavaScript modules with the media attribute, just like you can with stylesheets:
<!-- This works for stylesheets: -->
<link rel="stylesheet" href="print.css" media="print">
<!-- Browser downloads but doesn't apply until print -->
<!-- This does NOT work for modulepreload: -->
<link rel="modulepreload" href="/_nuxt/entry.js" media="none">
<!-- Browser IGNORES the media attribute and preloads anyway -->
The spec does not define media semantics for , so browsers ignore it. Module scripts are preloaded eagerly regardless.[7]
This is poorly documented. You only notice when performance metrics refuse to improve, even after you add media="none" to 90 modulepreload links.
flowchart LR
subgraph Stylesheet preload
S1["<link rel=#quot;stylesheet#quot;<br/>media=#quot;print#quot;>"] --> S2["Browser may delay applying<br/>until media matches"]
end
subgraph Modulepreload
M1["<link rel=#quot;modulepreload#quot;<br/>media=#quot;none#quot;>"] --> M2["Browser still preloads<br/>module eagerly"]
end
The Working Approach: Complete Removal
Because modulepreload hints cannot be meaningfully delayed, the solution is to remove them entirely.[7] A Nitro render:html plugin performs two transformations in a large SSR application.
Transformation 1: Remove All Modulepreload Links
Before (standard Nuxt SSR output):
<head>
<link rel="modulepreload" href="/_nuxt/entry.js">
<link rel="modulepreload" href="/_nuxt/chunk-abc.js">
<link rel="modulepreload" href="/_nuxt/chunk-def.js">
<link rel="modulepreload" href="/_nuxt/chunk-ghi.js">
... (90+ more)
</head>
After (deferred):
<head>
<!-- All modulepreload links removed -->
</head>
Without these hints, the browser no longer speculatively downloads chunks. It waits until it encounters actual tags.
flowchart TB
subgraph Before: Standard Nuxt SSR
BHead["<head>"] --> B1[entry.js modulepreload]
BHead --> B2[chunk-abc.js modulepreload]
BHead --> B3[chunk-def.js modulepreload]
BHead --> B4[chunk-ghi.js modulepreload]
BHead --> Bn[... 90+ more hints]
end
subgraph After: Deferred
AHead["<head>"] --> Acomment["<!-- All modulepreload links removed -->"]
end
Transformation 2: Replace the Entry Script
By default, the Nuxt entry script loads immediately:
<!-- Standard: loads and executes immediately -->
<script type="module" src="/_nuxt/entry.js"></script>
The replacement defers it via requestIdleCallback:
<script>
function load() {
var s = document.createElement('script')
s.type = 'module'
s.src = '/_nuxt/entry.js'
document.head.appendChild(s)
}
if ('requestIdleCallback' in window) {
requestIdleCallback(load, { timeout: 3000 })
} else {
setTimeout(load, 50)
}
</script>
requestIdleCallback tells the browser: “Run this function when you’re idle between frames.” The { timeout: 3000 } guard ensures the script still loads within 3 seconds, even if the browser never becomes truly idle.[5]
flowchart TB
A[Page load] --> B{requestIdleCallback<br/>supported?}
B -->|Yes| C["Schedule load() with<br/>requestIdleCallback<br/>timeout: 3000ms"]
B -->|No| D["Fallback to<br/>setTimeout(load, 50)"]
C --> E["Create <script type='module'>"]
D --> E
E --> F["Append to <head>"]
F --> G[Browser downloads /_nuxt/entry.js]
G --> H[Hydration starts]
What Happens at Load Time
timeline title Deferred Hydration Timeline 0ms : HTML arrives, browser starts parsing 50ms : HTML fully parsed, no JS to block 80ms : First Contentful Paint (FCP) — user sees complete page content 80-300ms : Browser mostly idle while user is reading 300ms : requestIdleCallback fires, browser starts downloading entry.js 500ms : entry.js downloaded and parsed; it imports route-level chunks via import() 700ms : Route chunk downloaded; Vue hydration begins 800ms : Hydration complete — page fully interactive
Compare this to the standard Nuxt flow:
timeline title Standard Hydration Timeline 0ms : HTML arrives, browser starts parsing 50ms : Browser discovers ~90 modulepreload hints and starts downloading all chunks 50-400ms : Network saturated downloading JS; main thread busy parsing/compiling; HTML rendering delayed 400ms : First Contentful Paint (FCP) — user finally sees content 600ms : All chunks downloaded, hydration begins 800ms : Hydration complete
The numbers above are representative for a large enterprise application with many route-level chunks; exact timings depend on bundle size and network conditions.
Measured Impact
On a representative complex SSR application (Nuxt 3/4, core + multiple feature modules) in Lighthouse mobile mode with simulated 4G throttling:
| Metric | Before | After | Reason |
|---|---|---|---|
| FCP | ~2.5 s | < 1.0 s | No JS blocking HTML paint |
| Speed Index | ~3.5 s | < 1.5 s | Visual content renders without waiting for JS |
| TBT | ~500 ms | < 100 ms | Main thread free during initial load |
| Unused JS | Flagged | Eliminated | Chunks only load when the entry bundle imports them |
These improvements align with what generic delayed-hydration techniques for Nuxt aim to achieve.[3][5]
graph LR
subgraph Before
FCPb[FCP ~2.5s]
SIb[Speed Index ~3.5s]
TBTb[TBT ~500ms]
UJSb[Unused JS flagged]
end
subgraph After
FCPa["FCP <1.0s"]
SIa["Speed Index <1.5s"]
TBTa["TBT <100ms"]
UJSa[Unused JS eliminated]
end
FCPb --> FCPa
SIb --> SIa
TBTb --> TBTa
UJSb --> UJSa
Why This Only Works with SSR
This technique assumes the server already sent fully rendered HTML. Without SSR, removing hints and deferring the entry script just yields a blank page.[4][6]
With SSR + Deferred Hydration:
Browser receives: <html>...<h1>Welcome</h1>...<section>...</section>...</html>
Browser paints: Complete page visible ✓
JS loads later: Hydration adds interactivity
Without SSR + Deferred Hydration:
Browser receives: <html><body><div id="app"></div></body></html>
Browser paints: Blank page ✗
JS loads later: Page eventually renders, but user saw nothing
flowchart LR
subgraph SSR + Deferred
S1["Server sends<br/>fully rendered HTML"] --> S2["Browser paints<br/>complete page"]
S2 --> S3[Deferred JS loads] --> S4["Hydration adds<br/>interactivity"]
end
subgraph No SSR + Deferred
C1["Server sends<br/><div id=#quot;app#quot;> only"] --> C2["Browser paints<br/>blank shell"]
C2 --> C3[Deferred JS loads] --> C4["Client-side render<br/>content finally appears"]
end
It is an SSR-only strategy. You trade a brief delay in interactivity for instant visual completeness—but only when the HTML already contains the content.
Selective Hydration: The hydrate-never Directive
Deferred hydration determines when JavaScript runs. Selective hydration determines how much JavaScript runs.[4][6]
Static text, decorative images, and structural containers do not change on the client. Hydrating them is wasted work.
A custom directive marks subtrees to skip (patterned after Nuxt’s lazy-hydration work like v-hydrate-never).[4][6]
Usage:
<div v-hydrate-never>
<h2>About Our Company</h2>
<p>Founded in 1999, we have been serving customers...</p>
<p>Our mission is to provide...</p>
<!-- 500 words of static content -->
</div>
Effect:
Vue's hydration skips this entire subtree:
• No patch() calls
• No reactive tracking setup
• No vnode creation for children
• Zero TBT contribution from this section
On pages with large static sections (landing pages, editorial content, legal text), selective hydration can cut TBT by 30–50% in practice. The server already rendered the markup—Vue does not need to re-verify or instrument it.[4][6]
flowchart TB R[Root Vue app] --> D1["Dynamic section<br/>v-hydrate"] R --> D2["Static section<br/>v-hydrate-never"] D1 --> H1["Hydration work:<br/>patch(), reactivity,<br/>vnode creation"] D2 --> H2[Skipped by hydration] classDef heavy fill:#ffebee,stroke:#c62828; classDef light fill:#e8f5e9,stroke:#2e7d32; class H1 heavy class H2 light
The Interaction with Manual Chunking
With modulepreload removed, the browser only discovers route-level chunks when entry.js executes import() calls. That creates a deliberate, staged sequence:
Loading Sequence (deferred + manual chunks):
1. requestIdleCallback fires
2. entry.js loads (core framework)
3. entry.js calls import('./route-homepage.js')
4. route-homepage.js loads (page-specific code)
5. route-homepage.js calls import('./cms-components.js')
6. cms-components.js loads (CMS section renderers)
Each import() creates a new network request.
But by this point, the page is already visible.
These requests happen AFTER first paint,
when the browser has bandwidth available.
Manual chunking ensures chunks are large enough to avoid a waterfall of tiny requests yet small enough to avoid shipping large amounts of unused code.
sequenceDiagram
participant B as Browser
participant E as entry.js
participant RH as route-homepage.js
participant CMS as cms-components.js
B->>E: Download & execute (after idle)
E->>RH: import('./route-homepage.js')
B->>RH: Fetch route-homepage chunk
RH-->>E: Module loaded
RH->>CMS: import('./cms-components.js')
B->>CMS: Fetch CMS components chunk
CMS-->>RH: Module loaded
RH->>B: Start route hydration
Edge Cases and Fallbacks
No requestIdleCallback Support
Safari only added requestIdleCallback in version 16.4 (2023). Older browsers fall back to setTimeout(load, 50), which gives the browser a moment to paint but cannot wait for true idle time. Users may see a brief period where the page is visible but not yet interactive.[5]
Timeout for Busy Pages
The 3-second timeout ensures the callback still fires if the browser never declares itself idle. Without it, a constantly busy page might never hydrate.[5]
flowchart LR
A[Page starts busy] --> B["requestIdleCallback scheduled<br/>with timeout=3000"]
B --> C{Browser becomes idle<br/>before 3000ms?}
C -->|Yes| D["Callback runs at idle<br/>load entry.js"]
C -->|No| E["Timeout reached at 3000ms<br/>callback forced to run"]
D --> F[Hydration proceeds]
E --> F
Server-Side Bot Detection
For crawlers and synthetic monitoring (e.g., Lighthouse), server-side logic can detect bots. Bot traffic bypasses deferred hydration entirely—crawlers need content, not interactivity.[3][5]
flowchart TB
R[Incoming request] --> D{Is bot / crawler?}
D -->|Yes| Bypass["Serve standard SSR<br/>no deferred hydration"]
D -->|No| Defer["Serve SSR + deferred<br/>requestIdleCallback loader"]
Lessons Learned
modulepreload is not controllable — only removable
No attributes like media, loading, or fetchpriority currently affect modulepreload. To control when chunks download, you must remove these hints and rely on import() timing.[7] This is a limitation of the spec and browser implementations, not of any specific framework.
requestIdleCallback is the right abstraction for “not now”
Unlike setTimeout(fn, 0) (next macrotask) or requestAnimationFrame (just before the next paint), requestIdleCallback fires when the browser has spare capacity. It is designed exactly for “do this later, but not at the user’s expense.”[5]
flowchart LR
A[Work to schedule] --> B{Urgency?}
B -->|Run ASAP| C["setTimeout(fn, 0)<br/>next macrotask"]
B -->|Before next paint| D[requestAnimationFrame]
B -->|When idle| E["requestIdleCallback<br/>with optional timeout"]
Deferred hydration and selective hydration are complementary
Deferred hydration controls when hydration runs (after idle). Selective hydration controls what actually hydrates (skip static subtrees).[4][6] Used together, they minimize TBT—even on low-end mobile hardware.
flowchart TB SSR[SSR-rendered page] --> DH["Deferred hydration<br/>via requestIdleCallback"] SSR --> SH["Selective hydration<br/>v-hydrate-never"] DH --> TBT1[Reduced blocking from timing] SH --> TBT2[Reduced blocking from volume] TBT1 --> TBT[Low Total Blocking Time] TBT2 --> TBT
This technique is SSR-specific and opinionated
For a short period, users interact with a “dead” page. Click handlers and other events do not work until hydration completes. On content-heavy pages, this is effectively invisible—users are reading, not clicking. On highly interactive pages (dashboards, editors, complex tools), deferred hydration may be the wrong trade-off.
Series Conclusion
This wraps up the 27-article series on building a modern web application with Nuxt 4, GraphQL, and cloud-native infrastructure:
- Architecture: Schema stitching, modular design, CMS integration
- Automation: Code generation, type-safe forms, scaffolding
- Performance: SSR, caching, deferred hydration, image proxying
- Operations: Containerized runtime, process managers, observability, security
- Developer Experience: AI-assisted debugging, structured logging, framework modules
- Advanced Patterns: A/B testing, conditional rendering, drill-down filters
The common thread: generate what can be generated, measure what you assume, remove what does not add value.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Leave a Reply