Ultimate Performance Optimization — Chasing That 100% Lighthouse Score

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:

  1. Build features fast.
  2. Ship.
  3. 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:

MetricTargetWhy
LCP (Largest Contentful Paint)< 2.5 sCore Web Vitals “good” threshold
CLS (Cumulative Layout Shift)< 0.1Prevents content jumps
TBT (Total Blocking Time)< 200 msPage 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:

  1. 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.
  1. Replace the entry