Top Organic Leads

Fix LCP Issues: Advanced PageSpeed Optimization

The last 5 PageSpeed points require understanding how browsers prioritize, decode, and paint. These are the non-obvious LCP fixes we discovered across many production sites.

Live Document— This guide is continuously updated as we optimize real client sites.

Last updated: May 30, 2026 at 7:46 PM UTC

AI-agent ready. Share this article with your coding agent and say: "Read this guide, run the detect commands on my project, and create an implementation plan for the fixes that apply."

You've done the basics (65→85) and the intermediate work (86→93). Your site loads fast. Your JavaScript is lean. But PageSpeed still shows 93 — and the last 5 points feel impossible.

They're not. But the techniques that get you from 93 to 98 are fundamentally different from everything you've done so far. They require understanding how browsers actually work — how preloads compete, how compositor layers delay painting, and why decoding="async" on your hero image creates a gap between FCP and LCP.

This guide covers the hard-won, non-obvious techniques we discovered while optimizing many real-world sites to near-perfect scores.

What's Inside

This premium playbook covers:

  1. LCP Preload Engineering — Why your preload URL doesn't match what the browser actually fetches, and how to fix it
  2. The Double-Preload Trap — How priority + a manual preload causes the browser to download your hero image twice
  3. The loading="eager" Preload Trap — How loading="eager" auto-generates a preload WITHOUT fetchPriority, causing a second competing preload
  4. Font Budget Calculus — Max 2 font families, ≤50KB total, and why variable fonts save 44KB
  5. CSS Scroll-Driven Animations — Replace JS scroll listeners with zero-main-thread CSS animations
  6. Compositor Layer Traps — Why will-change: transform on your LCP image delays it behind FCP
  7. The LCP translateY Trap — How a parallax start offset clips your hero image and reduces LCP area
  8. The decoding="auto" Override — Next.js defaults to decoding="async" which queues your hero image decode as a background task
  9. Next.js 16 + React 19 Traps — The Dual-Preload Quality Trap, the fetchPriority gap, loading="lazy" on logos, and the unoptimized: true nuclear option
  10. The DeferredClientShell Pattern — Defer non-first-paint client components to reduce FCP
  11. content-visibility: auto — Confirmed TBT reduction for below-fold sections
  12. Complete LCP <Image> Props Reference — The exact prop set that achieves 100/100
  13. Third-Party Script Deferral — Gate accessibility widgets and chat tools on first user gesture
  14. The inlineCss Rule — Why disabling inline CSS to "reduce HTML size" always makes LCP worse
  15. Audit "use client" Before Optimizing — Remove accidental client components first
  16. PSI Score Variance — Why 100 drops to 90 on the next run (and why that's normal)

1. LCP Preload URL Matching

When you add a <link rel="preload"> for your hero image, the URL must exactly match what next/image generates. A preload pointing to the raw S3/Blob URL is wasted — the browser preloads that URL, but <Image> fetches via /_next/image?url=...&w=X&q=X. The browser downloads the image twice.

The fix:

<link
  rel="preload"
  as="image"
  fetchPriority="high"
  imageSrcSet="
    /_next/image?url=https%3A%2F%2F...hero.webp&w=640&q=22 640w,
    /_next/image?url=https%3A%2F%2F...hero.webp&w=750&q=22 750w,
    /_next/image?url=https%3A%2F%2F...hero.webp&w=828&q=22 828w,
    /_next/image?url=https%3A%2F%2F...hero.webp&w=1080&q=22 1080w,
    /_next/image?url=https%3A%2F%2F...hero.webp&w=1200&q=22 1200w,
    /_next/image?url=https%3A%2F%2F...hero.webp&w=1920&q=22 1920w
  "
  imageSizes="100vw"
/>

The q= in the preload must match the quality prop on the <Image>. A mismatch = double download.

Critical widths to include:

DeviceDPRViewportPicks from srcset
iPhone SE, 12 mini2x375px750w
iPhone 14, 132x390px828w
iPhone 15 Pro3x393px1200w
Galaxy S213x360px1080w

Missing 750w or 828w causes cache misses on the most common iPhones.

2. The Double-Preload Trap

The most expensive mistake you can make at this level.

When <Image priority> is set with the default quality={75}, Next.js auto-generates a <link rel="preload"> covering all deviceSizes. If you also add a manual <link rel="preload"> in layout.tsx for the same image, the browser sees two competing preloads:

  • Manual preload: browser picks 828w
  • Auto preload from priority: browser picks 750w
  • <img> srcset: browser picks 750w → matches auto-preload ✓
  • 828w from manual preload was downloaded for nothing — wasted bandwidth

The rule: If quality={75} (default), use priority + fetchPriority="high" on the <Image> and remove any manual preload. The auto-generated preload is correct and complete.

// ✅ CORRECT — let Next.js generate the preload:
<Image src={heroUrl} fill sizes="100vw" priority fetchPriority="high" quality={75} />
// No manual <link rel="preload"> needed

// ❌ WRONG — manual preload + priority = double download

But if you use custom quality (e.g., quality={22} for dark-overlay images), the auto-generated preload uses the wrong quality. In that case, use a manual preload and remove both priority and fetchPriority from the <Image>:

// ✅ CORRECT for custom quality:
<Image src={heroUrl} quality={22} sizes="100vw" fill />
// Manual preload in layout.tsx with q=22

3. The loading="eager" Preload Trap

Discovered on demolitionoc.com — LCP regressed from ~1.1s to 2.6s.

After switching a hero <Image> from priority to loading="eager" (to avoid a perceived q=75 conflict), the page ended up with two competing preloads:

  1. Auto-generated by loading="eager": <link rel="preload" as="image" imageSrcSet="...q=22...">no fetchPriority
  2. Manual in layout.tsx: <link rel="preload" as="image" fetchPriority="high" imageSrcSet="...q=22...">with fetchPriority

The browser saw both, potentially fetched the hero image twice (once at normal priority, once at high priority), and LCP regressed from ~1.1s → 2.6s.

Fix: Use priority + fetchPriority="high" directly on the <Image>, and remove any manual <link rel="preload"> from layout.tsx:

// ✅ CORRECT — single preload with fetchPriority="high", covers ALL deviceSizes:
<Image
  src={heroSrc}
  alt={heroAlt}
  fill
  priority
  fetchPriority="high"
  sizes="100vw"
  quality={22}   // valid because next.config.ts qualities:[22,55,75]
  className="object-cover"
/>
// ❌ WRONG — creates SECOND preload without fetchPriority:
<Image
  loading="eager"   // ← auto-generates preload WITHOUT fetchPriority
  quality={22}
  ...
/>
// PLUS in layout.tsx:
<link rel="preload" as="image" fetchPriority="high" imageSrcSet="...q=22..." />
// ← now you have TWO preloads fighting → LCP 2.6s

Key facts:

  • loading="eager" alone generates a preload without fetchPriority — the browser treats it as lower priority than fetchPriority="high" preloads
  • priority={true} generates a preload with fetchPriority="high" and covers all Next.js deviceSizes automatically
  • With qualities: [22, 55, 75] in next.config.ts, priority + quality={22} generates preloads at q=22 (not q=75) — the quality prop IS respected in the preload URL
  • A manual preload in layout.tsx is only needed when the auto-generated preload is provably wrong (wrong quality, missing breakpoints) — and even then it must exactly match every srcset entry the <img> would use

4. Font Budget Calculus

The EcoGreenVision Rule: One project had 8 font preloads (5 font families × multiple weights) totaling 388KB. On PageSpeed's 1.6 Mbps throttle, fonts alone consumed 2+ seconds of bandwidth — starving the hero image.

The budget:

  • Maximum 2 font families (one body, one heading)
  • Maximum 3 font files total
  • Maximum 50KB combined

Detection:

# Count font preloads in deployed HTML
curl -sL "https://yoursite.com/" | grep -o 'rel="preload"[^>]*as="font"' | wc -l

Fix — Variable fonts over static:

Switching Barlow (static, 4 files at 62KB) to Albert Sans (variable, 1 file at 18KB) saved 44KB. A single variable font file covers all weights.

Fix — Suppress non-essential font preloads:

const headingFont = Anton_SC({
  subsets: ["latin"],
  display: "swap",
  preload: false,  // ← not render-critical, loads on demand
});

Only the primary body font needs preloading. All others load on-demand via @font-face with display: swap.

5. CSS Scroll-Driven Animations

Replace JavaScript scroll listeners with CSS that runs on the compositor thread — zero main-thread cost.

A parallax hero image using useEffect + requestAnimationFrame requires "use client", adds hydration JavaScript, and runs on the main thread during the critical rendering period.

The CSS replacement runs entirely on the compositor thread:

@keyframes hero-parallax-move {
  from { transform: scale(1.08) translateY(0%); }
  to   { transform: scale(1.08) translateY(20%); }
}

.hero-parallax-img {
  animation: hero-parallax-move linear both;
  animation-timeline: scroll(root block);
  animation-range: 0px 100svh;
}
// Before (requires "use client" — JS on main thread):
"use client";
import { useEffect, useRef } from "react";

export function HeroParallax({ speed = 0.25 }) {
  const imgRef = useRef<HTMLImageElement>(null);
  useEffect(() => {
    const onScroll = () => {
      imgRef.current!.style.transform = `translateY(${window.scrollY * speed}px)`;
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);
  return <Image ref={imgRef} className="object-cover" ... />;
}

// After (pure Server Component — no JS at all):
export function HeroParallax() {
  return <Image className="object-cover hero-parallax-img" ... />;
}

Browser support: Chrome 115+, Safari 18+, Firefox 128+ (~92% globally). Unsupported browsers get a static image — acceptable fallback.

6. Compositor Layer Traps — will-change: transform

will-change: transform on your LCP image creates a gap between FCP and LCP.

When you add will-change: transform to the hero image CSS, the browser promotes it to a compositor layer immediately. The rendering pipeline splits:

  1. Main thread renders text → FCP at 2.3s
  2. Compositor thread commits image layer → image appears 0.3s after FCP → LCP at 2.6s

The compositor layer promotion is what creates the 0.3s gap between FCP and LCP.

Fix: Remove will-change: transform from LCP images. The CSS scroll-driven animation still works without it — the browser promotes to compositor on the first scroll event (one frame, unnoticeable). The image renders alongside text on the main thread → LCP ≈ FCP.

/* ❌ BEFORE — creates compositor layer, delays image past FCP: */
.hero-parallax-img {
  will-change: transform;   /* ← forces compositor layer → LCP delay */
  animation: hero-parallax-move linear both;
  animation-timeline: scroll(root block);
  animation-range: 0px 100svh;
}

/* ✅ AFTER — image renders with text on main thread → LCP ≈ FCP: */
.hero-parallax-img {
  /* No will-change — browser promotes to compositor on first scroll. */
  animation: hero-parallax-move linear both;
  animation-timeline: scroll(root block);
  animation-range: 0px 100svh;
}

Never apply will-change: transform to the LCP image. It will ALWAYS create an FCP→LCP gap. Use it only on scroll-animated elements that are NOT the LCP candidate (e.g., below-fold cards, overlays).

7. The LCP translateY Trap

CSS scroll-driven animation from { transform: scale(1.08) translateY(-10%); } shifts the hero image 10% above the container at page load (scroll = 0). With overflow: hidden on the parent, 10–14% of the image is clipped above the viewport. This reduces the image's visible LCP area and may allow Chrome to pick a smaller element as the LCP candidate.

Fix: Start the animation at translateY(0%) so the image is centered at page load. The parallax movement goes from centered → shifted down (not from shifted-up → shifted-down):

/* ❌ BEFORE — image partially above viewport at page load: */
@keyframes hero-parallax-move {
  from { transform: scale(1.08) translateY(-10%); }  /* ← shifts UP at load */
  to   { transform: scale(1.08) translateY(15%);  }
}

/* ✅ AFTER — image centered at page load: */
@keyframes hero-parallax-move {
  from { transform: scale(1.08) translateY(0%);  }   /* ← centered at load */
  to   { transform: scale(1.08) translateY(20%); }   /* ← moves down on scroll */
}

8. The decoding="auto" Override

Next.js sets decoding="async" on ALL <Image> components by default — including those with priority={true}. Async decode means the image decode is queued as a background task. Even if the image bytes arrive before FCP, the browser may not decode and paint the image until after the first frame is committed → LCP > FCP.

Fix:

<Image
  priority
  fetchPriority="high"
  decoding="auto"   {/* override Next.js default "async" */}
  quality={22}
  sizes="100vw"
  fill
/>

decoding="auto" lets the browser choose: for small images (<30KB), browsers typically choose synchronous decode → image appears in the same frame as FCP → LCP ≈ FCP.

decoding valueBehaviorEffect on LCP
"async" (Next.js default)Decode queued as background taskLCP may lag FCP by 0.1–0.5s
"auto"Browser decides based on image sizeLCP ≈ FCP for small images
"sync"Always decode synchronously (blocks main thread)LCP = FCP but may delay FCP for large images

Use decoding="auto" for LCP images under 30 KB (e.g., hero at q=22). Use decoding="sync" only if auto doesn't help. Never use decoding="sync" for large unoptimized images.

9. Next.js 16 + React 19 LCP Rules

These traps are specific to Next.js 16 with React 19. They caused one production site to drop from 100/100 to 80/100 — and took four separate fixes to recover.

9a. The Dual-Preload Quality Trap

Always specify quality on your LCP <Image>.

In Next.js 16 with React 19, priority={true} generates two separate preload links — one from Next.js's SSR rendering (defaults to q=75) and one from React 19's ReactDOM.preload() API (uses a different quality). Without an explicit quality prop, the two preloads use different quality values — the browser downloads the same image twice at different qualities, splitting bandwidth and doubling the LCP image download cost.

The mechanism:

Next.js 16 + React 19, priority={true}, NO quality prop:
  ├── SSR preload:      /_next/image/?url=...&w=640&q=75  (Next.js default)
  └── React 19 preload: /_next/image?url=...&w=828&q=55  (different default!)
  → Browser downloads hero image TWICE at different qualities — LCP image gets half bandwidth

Detect:

# Check how many preloads exist for the LCP image and what qualities they use
curl -s "https://YOUR_SITE/" | python3 -c "
import sys, re
html = sys.stdin.read()
preloads = re.findall(r'<link[^>]*preload[^>]*/?>',html,re.DOTALL)
for i,p in enumerate(preloads):
    if 'image' in p:
        qs = set(re.findall(r'q=(\d+)', p))
        fp = 'fetchpriority' in p.lower()
        print(f'Preload {i+1}: qualities={qs}, fetchpriority={fp}')
"
# RED FLAG: Two preloads with DIFFERENT q= values for the same image URL

Fix:

// ❌ BEFORE — no quality prop, two preloads at different qualities:
<Image src={lcpUrl} fill={true} priority={true} sizes="100vw" />

// ✅ AFTER — explicit quality, both preloads use same quality:
<Image
    src={lcpUrl}
    fill={true}
    priority={true}
    fetchPriority="high"
    decoding="auto"
    quality={55}          // ALWAYS specify — eliminates quality mismatch between preloads
    sizes="100vw"
/>

With explicit quality={55}, both preloads use the same CDN cache entry. Even if both fire, no extra download occurs. Always ensure the quality value is present in next.config.ts qualities: [...] array.

9b. The fetchPriority Gap — fill + priority Skips fetchpriority on <img>

In Next.js 16 with fill={true} and priority={true}, Next.js generates <link rel="preload" fetchpriority="high"> in <head> BUT the rendered <img> element gets decoding="async" with no fetchpriority attribute. These are two separate rendering paths.

Fix: Pass fetchPriority="high" directly as a prop alongside priority={true}:

// These are TWO SEPARATE mechanisms in Next.js 16 — both are needed:
<Image
    priority={true}       // ← generates <link rel="preload"> in <head>
    fetchPriority="high"  // ← adds fetchpriority="high" to <img> element itself
/>

Adding fetchPriority="high" also upgrades the SSR-generated preload link to include fetchpriority="high". Without it, the SSR preload has no fetchpriority.

9c. Above-Fold loading="lazy" Trap — Logos and Navbar Images

Both navbar logos (desktop + mobile) had loading="lazy" despite being always in the viewport. Lazy loading gates the download behind an IntersectionObserver callback, adding unnecessary delay to visible above-fold images.

Image locationCorrect loading value
Navbar / header logoloading="eager" (explicit)
Hero / LCP imagepriority={true} — no loading prop needed
First content image (above fold)loading="eager"
All below-fold imagesloading="lazy" (browser default) ✅
// Fix: add loading="eager" to ALL above-fold logo instances:
<Image
    src="...logo.webp"
    loading="eager"   // ← ADD THIS
    alt="Logo"
/>

Logos are typically 5–20KB. Eager-loading them doesn't compete meaningfully with the LCP image for bandwidth. The IntersectionObserver delay from lazy-loading is always more costly.

9d. The unoptimized: true Nuclear Option

The most destructive config mistake in a Next.js image-heavy app.

unoptimized: true in next.config.ts disables the entire image optimization pipeline. ALL /_next/image requests return 400 Bad Request. Every image on every page fails to load. PageSpeed drops instantly.

Detect:

# Check config
grep -n "unoptimized" next.config.ts
# Should return nothing. "unoptimized: true" = REMOVE IMMEDIATELY.

# Verify live health of image optimizer
curl -s -o /dev/null -w "Image optimizer: HTTP %{http_code}\n" \
  "https://YOUR_SITE/_next/image/?url=ENCODED_IMAGE_URL&w=828&q=75"
# Must be 200. If 400 → unoptimized:true or missing remotePatterns or missing quality in array.

The critical next.config.ts settings that must NEVER regress:

const nextConfig: NextConfig = {
    images: {
        // ALL quality values used anywhere in <Image quality={X}> must be listed here.
        // A missing quality value → 400 error for any image using that quality.
        qualities: [55, 65, 75, 80, 90],
        remotePatterns: [...],
        // ❌ NEVER ADD: unoptimized: true
    },
    experimental: {
        inlineCss: true,   // inlines critical CSS → eliminates CSS round-trip → faster FCP
    },
};

Pre-deploy config health check:

echo "=== next.config.ts health ==="
grep "unoptimized" next.config.ts && echo "🔴 FAIL: unoptimized found — REMOVE IT" || echo "✅ No unoptimized"
grep "inlineCss" next.config.ts && echo "✅ inlineCss:true present" || echo "🟡 WARN: inlineCss missing"
grep "qualities" next.config.ts && echo "✅ qualities array present" || echo "🔴 FAIL: qualities missing → 400 errors"

10. The DeferredClientShell Pattern

Components like CookieConsent and NextTopLoader are loaded eagerly in layout.tsx, adding to the critical-path JS bundle and increasing FCP/SI even though they're never visible on initial load.

Pattern: Create a "use client" wrapper that holds all ssr: false dynamic imports. Import it once in layout.tsx:

// components/deferred-client-shell.tsx
"use client";
import dynamic from "next/dynamic";

const NextTopLoader = dynamic(() => import("nextjs-toploader"), { ssr: false });
const CookieConsent = dynamic(
  () => import("@/components/cookie-consent").then((m) => m.CookieConsent),
  { ssr: false }
);

export function DeferredClientShell() {
  return (
    <>
      <NextTopLoader color="#dc5a31" height={3} showSpinner={false} />
      <CookieConsent />
    </>
  );
}

What belongs in DeferredClientShell: navigation loaders (nextjs-toploader), cookie banners, chat widgets, analytics overlays — anything with zero effect on the initial visual render.

Why the shell is needed: dynamic(..., { ssr: false }) calls are not allowed in Server Components. You'll get: `ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component. The DeferredClientShell wrapper solves this by providing a "use client" boundary that layout.tsx can import as a single component.

Impact: Removes those components from the initial JS bundle → less main-thread work at FCP → faster Speed Index.

11. content-visibility: auto for TBT Reduction

content-visibility: auto tells the browser to skip layout computation for off-screen sections. This reduces Total Blocking Time (TBT) significantly.

Pattern that works:

/* globals.css */
.content-lazy {
    content-visibility: auto;
    contain-intrinsic-size: auto 600px;
}
// page.tsx — wrap ALL below-fold sections (NEVER the hero/LCP)
<HeroSlider slides={heroSlides} />                              {/* LCP — NO content-lazy */}
<div className="content-lazy"><ContactForm /></div>             {/* below fold ✅ */}
<div className="content-lazy"><BlogPosts blogPosts={data} /></div>  {/* below fold ✅ */}
<div className="content-lazy"><Testimonials /></div>            {/* below fold ✅ */}
<div className="content-lazy"><ServicesGrid /></div>            {/* below fold ✅ */}

dynamic(() => import(...)) and content-visibility: auto are complementary. Dynamic imports defer JS execution; content-visibility defers CSS layout computation. Apply both to below-fold sections.

Never apply content-visibility: auto to the hero section or LCP element — it would delay their rendering. Also do NOT apply to sticky elements or elements with position: fixed children — it breaks their stacking context.

12. Complete LCP <Image> Props Reference — Next.js 16

Confirmed 100/100 with this exact prop set:

<Image
    src={lcpImageUrl}
    alt="Descriptive alt text"
    fill={true}           // for full-bleed hero; use width/height for fixed-size images
    priority={true}       // generates <link rel="preload" fetchpriority="high"> in <head>
    fetchPriority="high"  // also adds fetchpriority="high" to <img> element (§9b)
    decoding="auto"       // browser chooses sync-decode for small images → LCP ≈ FCP (§8)
    quality={55}          // explicit; must be in next.config.ts qualities[] (§9a)
    sizes="100vw"         // required; prevents downloading 1920px image for 375px screen
    className="object-cover"
/>

LCP image checklist:

  • priority={true} — preload link with fetchpriority in <head>
  • fetchPriority="high" — fetchpriority attribute on the <img> element
  • decoding="auto" — sync decode for small hero images (<30KB)
  • quality={N} — explicit value present in next.config.ts qualities array
  • sizes — accurate to actual rendered dimensions
  • No loading="lazy" on the hero image
  • No will-change: transform on the image (§6)
  • No CSS animation on the image element
  • Navbar/logo images above fold: loading="eager" (§9c)
  • unoptimized: true absent from next.config.ts (§9d)

13. Third-Party Script Deferral

Accessibility overlays like AccessiBe (~218KB) are flagged even with lazyOnload. Lighthouse never simulates user interactions, so window.onload still falls inside its measurement window.

Fix: Use requestIdleCallback to load the script as soon as the browser is idle after LCP. For call-tracking scripts (like CallRail), this is the preferred approach — numbers swap ~50-200ms after LCP without waiting for user interaction:

"use client";
import { useEffect, useRef } from "react";

export default function CallRailLoader() {
  const loaded = useRef(false);

  useEffect(() => {
    const load = () => {
      if (loaded.current) return;
      loaded.current = true;

      const script = document.createElement("script");
      script.src = "/api/callrail-swap/";
      script.async = true;
      document.head.appendChild(script);
    };

    // requestIdleCallback fires ~50-200ms after LCP.
    // Lighthouse never reaches idle → invisible to PageSpeed.
    const id =
      typeof requestIdleCallback !== "undefined"
        ? requestIdleCallback(load, { timeout: 3000 })
        : null;
    const timer = id === null ? setTimeout(load, 1) : undefined;

    return () => {
      if (id !== null) cancelIdleCallback(id);
      if (timer !== undefined) clearTimeout(timer);
    };
  }, []);

  return null;
}

For heavy accessibility overlays like AccessiBe (~218KB) where loading speed is less critical, you can still use interaction-gated loading to defer even longer:

"use client";
import { useState, useEffect } from "react";
import Script from "next/script";

export default function AcsbScript() {
  const [load, setLoad] = useState(false);

  useEffect(() => {
    const trigger = () => setLoad(true);
    const events = ["scroll", "click", "keydown", "touchstart", "mousemove"];
    events.forEach(e => window.addEventListener(e, trigger, { once: true, passive: true }));
    return () => events.forEach(e => window.removeEventListener(e, trigger));
  }, []);

  if (!load) return null;

  return (
    <Script id="acsb-script" src="https://acsbapp.com/apps/app/dist/js/app.js" strategy="lazyOnload" />
  );
}

The key distinction: Use requestIdleCallback for scripts that must execute quickly after page load (call tracking, form tracking). Use interaction-gating only for scripts that can wait until the user actively engages (accessibility overlays, chat widgets, social embeds).

14. The inlineCss Rule

Never disable inlineCss: true. We learned this the hard way.

Disabling inlineCss to "reduce HTML size" caused LCP to regress from 1.1s → 2.6s. Without inline CSS, Next.js emits a <link rel="stylesheet"> — a render-blocking request. The browser cannot paint anything until it downloads the external CSS file.

With inlineCss: true, the CSS is already in the HTML stream. No second round-trip. The hero image preload fires immediately.

inlineCss: trueinlineCss: false
CSS deliveryInline in HTML (immediate)External file (blocking round-trip)
LCP (demolitionoc.com)1.1s2.6s ❌

inlineCss: true is NOT about CSS size — it's about avoiding an extra blocking network round-trip. The "bigger HTML" is a false economy — the external CSS file is always worse for LCP.

15. Image Quality for Dark-Overlay Images

When your hero image is behind a dark overlay (50%+ opacity), you can dramatically reduce image quality without any visible difference:

ContextqualitySize (640w)
Full-bleed, no overlay75 (default)~54 KB
Light overlay (<40%)55~37 KB
Heavy overlay (≥50%)22~18 KB

At quality={22} under a dark overlay, the image is 66% smaller with no perceptible quality loss.

Important: After setting custom quality values, add them to the allowlist:

// next.config.ts
images: {
  qualities: [22, 55, 75],
  formats: ['image/avif', 'image/webp'],
  minimumCacheTTL: 31536000,
}

Without this, Next.js 15+ returns 400 errors for non-default quality values.

16. Audit "use client" Before Optimizing

Before spending time on bundle analysis, audit every "use client" component. We found 3 unnecessary client components on one homepage:

  • ServicesSpotlight — pure static HTML + images, zero hooks. "use client" was left in accidentally.
  • SmoothScroll — returns null. The smooth scroll lib was removed; the component was a no-op.
  • HeroParallaxBackground — replaced with CSS scroll-driven animation (see §5).

The check:

# Find all client components
grep -rn '"use client"' components/ --include="*.tsx"

# For each, check if it actually uses client APIs
grep -n "use\|window\|document\|onClick\|onChange" components/my-component.tsx

If a component has "use client" but no hooks, no browser APIs, and no interactive event handlers — remove the directive and convert it to a Server Component.

The rule: Before spending time on bundle analysis or dynamic imports, audit every "use client" component on the page and ask:

  1. Does it use hooks (useState, useEffect, useRef, usePathname, etc.)? → needs client
  2. Does it use browser APIs (window, document, localStorage, etc.)? → needs client
  3. Does it have interactive event handlers that require state? → needs client
  4. Otherwise → remove "use client" and convert to a Server Component

17. PSI Score Variance

Don't panic when 100 drops to 90 on the next run.

PageSpeed uses simulated throttling. Score variance of ±5-8 points is normal. A site that scores 100 on one run and 90 on the next is a 95-scoring site with normal variance.

How to get consistent scores:

  1. Run 5 tests in a row, wait 30s between each
  2. Discard the highest and lowest
  3. Take the median of the remaining 3
  4. Only compare medians — never single runs
Median ScoreStatusAction
95-100✅ ExcellentShip it
90-94⚠️ GoodCheck which single metric is dragging
80-89❌ Needs workSomething structural — run the diagnosis scripts
<80🔴 CriticalLikely a blocking script or unoptimized hero

The Golden Rules

  1. Trace first, optimize second — always run the console diagnosis scripts
  2. Exactly ONE priority image per page — the LCP element
  3. Always specify quality on the LCP <Image> — prevents the Dual-Preload Quality Trap
  4. Max 2 font families, ≤50KB total
  5. Never disable inlineCss: true
  6. decoding="auto" on LCP images
  7. fetchPriority="high" on the LCP <Image> AND priority — they serve different purposes
  8. No will-change: transform on LCP images
  9. No translateY offset at page load on LCP images
  10. CSS scroll-driven animations over JS scroll listeners
  11. loading="eager" on above-fold logos — never loading="lazy"
  12. Never set unoptimized: true in next.config.ts
  13. Median PageSpeed ≥95 = ship it

Want a Perfect 100/100?
Let Us Handle It.

Our team has achieved 100/100 PageSpeed scores on many real-world sites with Google Analytics and CallRail fully active. We'll do the same for yours.

Get a Perfect 100/100 PageSpeed Score

Services I'm interested in:

Top Organic Leads

Risk-free lead generation. We only get paid when your business gets new customers.

Address

333 City Blvd West Suite #1722

Orange, CA 92868

Hours

Mon – Fri

8:00 AM – 5:00 PM

©2026 Top Organic Leads. All rights reserved.