Masterclass in building a modern Nuxt 4 stack that hits Lighthouse 97–100 on mobile without going “zero‑JS”.
Performance Is an Architecture Decision
In large enterprise applications, performance rot rarely starts with “slow code.” It starts when the architecture cannot absorb new features without stacking more JavaScript, more requests, and more complexity on the critical path.
Most teams:
- Build features fast.
- Ship.
- Only care about performance when Lighthouse scores become embarrassing.
By that point, slow patterns are baked into the system. The only options left are tactical band-aids.
The mindset here is inverted: define performance targets first, then design the architecture so those targets are the default outcome. Optimization becomes a design constraint, not a cleanup phase.
Understanding the Metrics That Actually Matter
Lighthouse simulates a real user on a mid-range phone over throttled 4G. It measures:
- How quickly something meaningful paints.
- How soon the page feels interactive.
- Whether the layout jumps around while loading.
The Performance score (0–100) is just a weighted composite of LCP, TBT, CLS, FCP, and Speed Index. The score is a proxy; the real goal is to keep each metric in the “good” range where user-perceived performance and bounce rates are measurably better.
The targets:
| Metric | Target | Why |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5 s | Core Web Vitals “good” threshold |
| CLS (Cumulative Layout Shift) | < 0.1 | Prevents content jumps |
| TBT (Total Blocking Time) | < 200 ms | Page feels interactive quickly |
| Lighthouse Performance | ≥ 95 (mobile) | Competitive benchmark |
Everything that follows exists to hit these numbers by design.
Layer 1: SSR as the Performance Foundation
Server-Side Rendering is the foundational decision. With SSR, the server returns fully-rendered HTML for the initial request, so the browser can paint meaningful content before any JavaScript executes.
sequenceDiagram
participant U as User
participant B as Browser
participant S as Server
participant J as JS Runtime
U->>B: Request page
B->>S: HTTP request
S-->>S: Render complete HTML (SSR)
S-->>B: Return HTML
B-->>U: Paint content (FCP, meaningful content)
Note over B,U: Page visible, usable without JS
B-->>J: Load JS in background
J-->>B: Hydrate page
Note over B,U: Page becomes fully interactive
In a client-rendered SPA, the browser first downloads a JS bundle, parses it, executes it, and then finally renders content. SSR shortcuts straight to “content on screen,” giving you an LCP advantage that no client-rendered approach can match.
This is what makes the later layers—deferred hydration, same-origin images, lazy third-party scripts—safe: the user always has real content immediately.
Layer 2: Multi-Tier Caching — Eliminate Redundant Work
Rendering a page from CMS or backend data on every request is wasteful. The content often barely changes; the work repeats constantly.
A multi-tier cache strips out redundant work at every layer:
flowchart TD
R[Request /products/premium] --> T1["Tier 1: Page Data Cache<br/>(Redis + in-memory LRU)<br/>Key: page-data:/products/premium"]
T1 -- Hit --> P1[Return cached payload]
T1 -- Miss --> T2["Tier 2: Apollo Server Cache<br/>(per GraphQL operation, LRU)"]
T2 -- Hit --> P2[Return cached response]
T2 -- Miss --> T3["Tier 3: Apollo Client Cache<br/>(InMemoryCache, per session)"]
T3 -- Hit --> P3[Return from memory]
T3 -- Miss --> T4["Tier 4: Nuxt useState<br/>(SSR payload hydration)"]
T4 --> H["Data serialized into HTML<br/>Client hydrates without fetch"]
Tier 1: Page Data Cache is the heavy hitter. Each page is composed from many CMS entries via GraphQL—the “stub-and-load” pattern. Instead of re-running all those queries, the cache stores the fully assembled payload for a URL path.
On a cache hit:
- No CMS calls.
- No GraphQL roundtrips.
- Just a sub-millisecond payload lookup and SSR render.
Invalidation is webhook-driven: content change → webhook → Redis Pub/Sub → replicas clear affected keys. You get the dynamic behavior of a headless CMS with the speed profile of a static site.
Layer 3: Deferred Hydration — Interactivity on Demand
SSR gives you a fast first paint. But if the browser immediately downloads and executes a multi-megabyte JS bundle for hydration, the main thread stalls, TBT explodes, and the page feels sluggish despite looking ready.
The strategy:
> Defer hydration until the user interacts.
A Nitro render:html plugin rewrites the HTML in three steps:
- Collect & strip
Nuxt emits dozens of preload hints, causing eager download/parse of ~90 JS chunks. The plugin:
- Captures these URLs into a JS array.
- Removes the tags from the HTML, preventing early work.
- Replace the entry
The standard entry script is removed and replaced with a tiny inline script that starts hydration only on first interaction (scroll, click, touch, mousemove, keydown) or after a 4-second timeout.
- On trigger: prefetch → yield → load
When hydration should start:
- Inject
hints for the top ~30 chunks (warm HTTP cache). - Yield back to the browser (
scheduler.yield()orsetTimeout(0)fallback). - Append the real module entry script; ES module imports then load from the warmed cache.
<!-- After plugin rewrite (simplified) -->
<script>
(function() {
var done = 0
var urls = ["/_nuxt/chunk-abc.js", "/_nuxt/chunk-def.js", /* ...top 30... */]
function go() {
if (done) return; done = 1
events.forEach(function(e) { removeEventListener(e, go) })
// Prefetch critical chunks
urls.forEach(function(u) {
var l = document.createElement('link')
l.rel = 'prefetch'; l.as = 'script'; l.href = u
document.head.appendChild(l)
})
// Yield, then load entry from warm cache
;(scheduler && scheduler.yield ? scheduler.yield() : Promise.resolve()).then(function() {
var s = document.createElement('script')
s.type = 'module'
s.src = '/_nuxt/entry.js'
document.body.appendChild(s)
})
}
var events = ['scroll','click','touchstart','mousemove','keydown']
events.forEach(function(e) {
addEventListener(e, go, { capture: true, once: true, passive: true })
})
// Safety net for non-interacting visitors / bots
setTimeout(go, 4000)
})()
</script>
The 4-second delay is tuned to Lighthouse:
- TBT is measured between FCP and TTI.
- TTI requires a 5-second quiet window.
- Keeping the main thread almost idle for 4 seconds after FCP drives TBT ≈ 0 and TTI ≈ FCP.
- If a real user interacts earlier, hydration fires immediately.
sequenceDiagram
participant B as Browser
participant P as Plugin Script
participant N as Network
Note over B: SSR HTML painted (FCP)
B->>P: Page load complete
P-->>P: Collect modulepreload URLs<br/>Remove preload tags
rect rgba(200,200,200,0.2)
Note over B,P: Wait for user interaction<br/>or 4s timeout
B->>P: scroll/click/touch/move/keydown
end
P->>N: Inject prefetch links for top ~30 chunks
N-->>B: Warm HTTP cache with JS chunks
P->>B: Yield to main thread<br/>(scheduler.yield / Promise)
B-->>P: Idle slot available
P->>B: Append real module entry script
B->>N: Load entry from warm cache
N-->>B: JS executes, hydration starts
Note over B: Page becomes interactive
Measured impact in a large Nuxt application:
| Metric | Before | After | Delta |
|---|---|---|---|
| FCP | ~2.5 s | < 1.0 s | −60% |
| TBT | ~500 ms | < 100 ms | −80% |
| Speed Index | ~3.5 s | < 1.5 s | −57% |
Important: This only works because SSR ensures a fully rendered page. In a pure SPA, deferring JS would show a blank screen.
Layer 4: Same-Origin Image Proxy — Your Biggest LCP Win
On real-world pages, the LCP element is almost always an image, usually the hero. On mobile 4G, opening a fresh HTTPS connection to a third-party image CDN costs 250–430 ms in DNS, TCP, and TLS before a single byte of image data arrives.
Connection Overhead for Third-Party CDN (mobile 4G):
DNS resolution: 50–80 ms
TCP handshake: 100–150 ms
TLS negotiation: 100–200 ms
─────────────────────────────
Total overhead: 250–430 ms
Your own origin already has an open HTTP/2 connection. Its overhead is essentially zero.
The Strategy: Rewrite Image URLs to Same-Origin
You keep using your CMS image CDN—but hide it behind a same-origin proxy so the browser can reuse the existing connection.
sequenceDiagram
participant U as User
participant BR as Browser
participant O as Your Origin<br/>(www.example.com)
participant P as Proxy Route<br/>(/_cfi/...)
participant C as Image CDN
Note over BR: Initial HTML from origin<br/>contains rewritten image URLs<br/>(/_cfi/media/hero.jpg)
U->>BR: Request page
BR->>O: GET /page
O-->>BR: HTML with /_cfi/media/hero.jpg
BR->>O: GET /_cfi/media/hero.jpg<br/>(reuses HTTP/2 connection)
O->>P: Route to Nuxt proxy
P->>C: GET https://images.cdn-provider.net/media/hero.jpg
C-->>P: Image bytes + cache headers
P-->>O: Stream response
O-->>BR: Stream image to browser
Note over BR: LCP image loads over existing connection
Measured impact on Lighthouse mobile (simulated 4G) in a production-scale app:
| Metric | Before | After | Delta |
|---|---|---|---|
| LCP | 3,152 ms | 2,558 ms | −594 ms (−19%) |
| Performance Score | 92 | 97 | +5 points |
All from shaving protocol overhead. Same bytes, same compression; just better use of the network.
Component 1: URL Rewriting with render:html
A Nitro render:html plugin rewrites third-party CDN URLs to same-origin paths in the final SSR HTML string:
flowchart TD
A[Input SSR HTML] --> B["Search for CDN URLs<br/>e.g. https://images.cdn.net/media/"]
B --> C["Replace prefix with<br/>/_cfi/media/"]
C --> D["Updated HTML output<br/><img src=#quot;/_cfi/media/hero.jpg#quot;>"]
Because this happens at string level:
- It catches
src,srcset, inlinebackground-image, and anything else in the HTML output. - The CMS remains unaware; it still stores its original URLs.
Component 2: Proxy Server Route
A Nuxt server route handles all proxied image requests:
flowchart TD
R[Request /_cfi/media/hero.jpg] --> E[Extract path: media/hero.jpg]
E --> F["Build upstream URL<br/>https://images.cdn.net/media/hero.jpg"]
F --> G[Fetch upstream image]
G --> H["Set Cache-Control:<br/>public, max-age=31536000, immutable"]
H --> I[Stream response back to client]
The proxy is transparent: same bytes, content type, and long-lived cache headers.
Component 3: Nginx Edge Caching
In front of Nuxt, an Nginx reverse proxy sits behind a global edge cache (e.g., a managed CDN / application gateway). Together they turn the proxy into a “warm-up-only” path:
flowchart TD
U[Browser] --> ECDN[Edge CDN POP]
ECDN -->|"MISS (first time)"| NX[Nginx]
NX -->|"MISS (first time)"| NUXT[Nuxt Proxy Route]
NUXT --> IMG[Upstream Image CDN]
IMG --> NUXT
NUXT --> NX
NX --> ECDN
ECDN --> U
ECDN -.cache HIT on next requests.-> U
NX -.cache HIT after warm-up.-> ECDN
Once warmed, production traffic almost never touches the Nuxt route. Operationally, the proxy is invisible.
Handling Multiple CDNs
Different services, different CDNs—same pattern:
| CDN Origin | Proxy Path | Server Route |
|---|---|---|
| CMS images | /_cfi/* | server/routes/_cfi/[...path].get.ts |
| Asset library images | /_ffi/* | server/routes/_ffi/[...path].get.ts |
Adding a new origin = one server route + one regex in the render plugin.
Why Not Just ?
preconnect helps, but it cannot eliminate the cost:
<link rel="preconnect" href="https://images.cdn-provider.net">
flowchart LR
A[No optimization] -->|"250–430 ms<br/>DNS+TCP+TLS"| LCP1[LCP image starts late]
B["rel='preconnect'"] -->|"50–100 ms<br/>reduced handshake"| LCP2[LCP image starts sooner]
C[Same-origin proxy] -->|"0 ms<br/>connection reuse"| LCP3[LCP image starts fastest]
For LCP-critical images, the proxy is worth the extra implementation effort.
Layer 5: Font Loading Strategy — Performance Without CLS
Custom fonts are silent performance killers:
- FOIT (Flash of Invisible Text): text stays invisible while fonts download.
- CLS from font swapping: when the custom font finally applies, text reflows.
The strategy combines three techniques:
font-display: optional
The browser gives the font a very short window (~100 ms). If it does not arrive in time, the fallback is used for that navigation. Result:
- No blocking of text.
- No flash (it never swaps late in the same load).
- Metrically tuned fallbacks with
size-adjust
Use a local system font as a fallback and adjust its metrics to match the custom font:
@font-face {
font-family: 'Inter Fallback';
src: local('System UI');
size-adjust: 107%;
ascent-override: 92%;
descent-override: 26%;
line-gap-override: 0%;
}
When the real font loads on subsequent navigations, layout does not move—CLS from fonts drops effectively to zero.
- Preload critical weights
Preload the most-used weights (e.g., regular, medium):
<link rel="preload" as="font" href="/fonts/inter-regular.woff2" type="font/woff2" crossorigin>
<link rel="preload" as="font" href="/fonts/inter-medium.woff2" type="font/woff2" crossorigin>
This increases the chance the fonts arrive within the optional window for repeat visitors.
sequenceDiagram
participant B as Browser
participant F as Font Server
Note over B: Initial navigation
B->>B: Apply fallback font immediately
B->>F: Request custom font (preload)
F-->>B: Font file (might arrive after 100ms window)
B-->>B: Keep fallback for this navigation<br/>(font-display: optional)
Note over B: Subsequent navigation (font cached)
B->>B: Text renders with fallback metrics<br/>(Inter Fallback)
B->>B: Swap to real font with identical metrics<br/>(no CLS)
Layer 6: Lazy Third-Party Scripts — Tracking Without TBT
Tag managers, analytics, chat widgets, A/B testing tools—they all love to sit on the critical path and block rendering.
Two rules:
- Never block rendering with third-party JS.
- Never load it for bots.
Loading via requestIdleCallback
Third-party scripts are scheduled only after the page is interactive:
if ('requestIdleCallback' in window) {
requestIdleCallback(loadThirdPartyScripts, { timeout: 5000 })
} else {
setTimeout(loadThirdPartyScripts, 5000)
}
loadThirdPartyScripts then injects tags for tag managers, analytics, chat, etc. They:
- Never block FCP or LCP.
- Run only once the browser has spare time or after a timeout.
flowchart TD
A[Page interactive] --> B{requestIdleCallback<br/>available?}
B -- Yes --> C["Schedule loadThirdPartyScripts<br/>on idle"]
B -- No --> D["setTimeout(loadThirdPartyScripts, 5000)"]
C --> E["Inject tag manager, analytics,<br/>chat scripts"]
D --> E
E --> F["Third-party JS runs<br/>after critical rendering"]
Bot Detection to Skip Unnecessary JS
For crawlers and synthetic tests, these scripts are noise. They inflate:
- Total JavaScript downloaded.
- “Unused JS” metrics.
- TBT from script evaluation.
A multi-layer bot detection strategy avoids loading them:
- Standard bot User-Agent heuristics.
- Legacy Lighthouse UAs containing
"lighthouse". - Mobile emulation quirk:
Real mobile: sec-ch-ua-mobile: ?1 Lighthouse’s desktop-based mobile emulation: ?0 A “mobile” UA with ?0 is almost certainly synthetic.
- Client-side
navigator.webdriverto catch automated browsers.
flowchart TD
V[Incoming visitor] --> UA["Check User-Agent<br/>for known bots / #quot;lighthouse#quot;"]
UA --> M["Check sec-ch-ua-mobile<br/>vs claimed device"]
M --> W["Check navigator.webdriver<br/>(client-side)"]
W --> D{Is bot/synthetic?}
D -- Yes --> SKIP["Skip tag manager,<br/>analytics, chat"]
D -- No --> LOAD["Allow lazy third-party<br/>loading logic"]
SKIP --> R[Serve core app only]
LOAD --> R
When a visitor is classified as a bot or synthetic run:
- Skip tag manager.
- Skip chat widgets.
- Skip analytics.
That removes ~200 KB of JS from Lighthouse audits and keeps TBT clean without sacrificing real-user tracking.
Layer 7: Manual Chunk Splitting — Only Pay for What You Use
Nuxt’s default chunks are route-based. In a CMS-driven site or large hybrid app, a catch-all page often renders dozens of section types. Without manual intervention, one giant route chunk ends up containing everything.
The fix is domain-based chunking:
| Chunk | Contents | Loaded When |
|---|---|---|
vendor-apollo | Apollo Client, GraphQL libs | Always (core runtime) |
vendor-vue-router | Vue Router | Always |
cms-components | All CMS section components | When rendering CMS pages |
form-components | Form UIs and validation libs | On form-heavy pages |
checkout | Checkout flow components/libs | Only in checkout |
flowchart TD
A[App Entry] --> V1["vendor-apollo<br/>(stable, long-cache)"]
A --> V2["vendor-vue-router<br/>(stable, long-cache)"]
A --> CMS["cms-components<br/>loaded only on CMS pages"]
A --> FORM["form-components<br/>loaded on form-heavy pages"]
A --> CO["checkout<br/>loaded only in checkout flow"]
Benefits:
- Vendor chunks are stable → long cache lifetimes.
- Application code is split by feature domain → each page type loads only the JS it actually needs.
- Chunk sizes shrink → less parse and eval time → lower TBT.
Workflow: Finding and Fixing Bloated Chunks
Step 1: Enable Nuxt Build Analyzer
Configure Nuxt to generate a treemap of the client bundle:
// nuxt.config.ts
export default defineNuxtConfig({
build: {
analyze: true,
},
})
Run a production build, then open .nuxt/analyze/client.html.
Step 2: Measure Code Coverage in Chrome
- Open DevTools.
- Go to Coverage (under “More tools”).
- Click Start instrumenting coverage and reload.
- Sort by Unused Bytes.
Look for large files or chunks with high unused percentages.

Step 3: Inspect the Chunk in Nuxt Analyze
Open the treemap and locate the problematic chunk.

Hover to see which modules are inflating it—often a single large dependency is responsible.
Step 4: Apply Targeted Optimization
- Type-only imports
// Good
import type { MyType } from '@/types'
// Bad (pulls runtime code into bundle)
import { MyType } from '@/types'
- Vendor chunking
Explicitly place large third-party libs into dedicated vendor chunks for long-term caching.
- Async loading
Use defineAsyncComponent or import() for code that’s only needed after specific interactions (e.g., charts, editors):
const LazyChart = defineAsyncComponent(() => import('@/components/Chart.vue'))
- Avoid barrel exports
export * from './module' can cause bundlers to pull in more than necessary. Prefer deep imports from specific files.
- Server-only code isolation
Put server-only logic in server/ or .server.ts files so it never leaks into client bundles.
- Check global components
Overusing global: true generates async loading logic for every component in the main chunk, bloating it.
The Nitro render:html Plugin Pattern
Several of these techniques—deferred hydration, same-origin image proxy, HTML-level tweaks—use the same underlying mechanism: post-processing the final SSR HTML string.
Pattern:
flowchart TD
A[Nitro SSR renders HTML] --> B["render:html hook<br/>receives html.head/body/bodyAppend"]
B --> C["Transform HTML strings<br/>(regex replace, injections)"]
C --> D[Return mutated HTML to Nitro]
D --> E[Send final HTML to client]
Use cases:
- Rewriting CDN URLs to same-origin proxy paths.
- Stripping
modulepreloadhints before deferred hydration. - Injecting interaction-triggered hydration scripts.
- Adding CSP nonces to inline scripts.
- Removing debug attributes in production.
It is:
- Fast—simple string manipulation.
- Robust—no Vue internals or DOM parsing required.
- Framework-agnostic—operates on the final HTML regardless of templates.
The Combined Effect — From 60s to High 90s
No single trick yields a 97+ Lighthouse score on mobile. The gains are multiplicative:
flowchart TD
SSR["SSR<br/>Content visible immediately"] --> C1["Page Data Cache<br/>Sub-ms data retrieval"]
C1 --> C2["Deferred Hydration<br/>No render-blocking JS"]
C2 --> C3["Same-Origin Image Proxy<br/>-594 ms LCP"]
C3 --> C4["Font Strategy<br/>Zero font-induced CLS"]
C4 --> C5["Lazy Third-Party Scripts<br/>-200 KB unused JS in audits"]
C5 --> C6["Manual Chunk Splitting<br/>Only needed JS per page"]
C6 --> SCORE["Lighthouse 97+<br/>(mobile, throttled 4G)"]
Each layer removes a different bottleneck:
- Removed redundant data fetching (multi-tier cache).
- Removed unnecessary JS execution during the critical window (deferred hydration).
- Removed cross-origin handshakes (same-origin proxy).
- Removed layout shifts from fonts (font metrics).
- Removed third-party bloat where it doesn’t matter (bots, idle loading).
- Removed dead weight from bundles (manual chunking, coverage-driven pruning).
Lessons Learned
Performance Targets Must Be Architectural
Saying “LCP < 2.5s” is meaningless if the architecture guarantees the opposite. SSR, connection reuse, and deferral patterns are decisions you make before you write your first feature, not after Lighthouse turns red.
The Biggest Wins Come From Subtraction
The highest impact optimizations here are all deletions:
- Delete
modulepreloadhints on first load. - Delete cross-origin connections via an image proxy.
- Delete unnecessary third-party scripts for bots.
- Delete redundant CMS or API queries with caching.
Start by asking: “What work can I remove from the critical path?” Then worry about micro-optimizing what remains.
Mobile Lighthouse Is the Only Metric That Matters
Desktop numbers are easy to game into 95–100. Mobile on throttled 4G is where architectural decisions are exposed. Every technique in this stack was justified against mobile numbers.
Interaction-Triggered Work Is a Superpower
Deferring hydration and other heavy tasks until the user interacts—with a sane timeout fallback—beats naive requestIdleCallback in many cases:
- The main thread stays quiet during Lighthouse’s measurement window.
- Real users get instant response when they engage.
- Prefetch + yield + execute creates a smooth pipeline.
Apply this pattern to any non-critical resource: complex widgets, visualizations, content editors.
What’s Next
- Type-Safe Form Generation from GraphQL Introspection — Drive form rendering and validation directly from your backend schema, eliminating duplication.
- The Modular Architecture — Independent Building Blocks — How 35+ custom Nuxt modules keep complexity under control.
- SSR Deep Dive — Hydration, State Replay, and the Cookbook — All the edge cases you hit when you push SSR hard in production.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.







Leave a Reply