Deferred Hydration Done Right — The `requestIdleCallback` Trick and the `modulepreload` Pitfall

Elderly man drinking water post-exercise with towel around neck, promoting healthy lifestyle. Hero image credit: Photo by Kampus Production on Pexels

A young boy drinks water in a boxing gym, under a motivational neon sign. Ideal for sports themes. 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