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:
- LCP Preload Engineering — Why your preload URL doesn't match what the browser actually fetches, and how to fix it
- The Double-Preload Trap — How
priority+ a manual preload causes the browser to download your hero image twice - The
loading="eager"Preload Trap — Howloading="eager"auto-generates a preload WITHOUTfetchPriority, causing a second competing preload - Font Budget Calculus — Max 2 font families, ≤50KB total, and why variable fonts save 44KB
- CSS Scroll-Driven Animations — Replace JS scroll listeners with zero-main-thread CSS animations
- Compositor Layer Traps — Why
will-change: transformon your LCP image delays it behind FCP - The LCP
translateYTrap — How a parallax start offset clips your hero image and reduces LCP area - The
decoding="auto"Override — Next.js defaults todecoding="async"which queues your hero image decode as a background task - Next.js 16 + React 19 Traps — The Dual-Preload Quality Trap, the
fetchPrioritygap,loading="lazy"on logos, and theunoptimized: truenuclear option - The
DeferredClientShellPattern — Defer non-first-paint client components to reduce FCP content-visibility: auto— Confirmed TBT reduction for below-fold sections- Complete LCP
<Image>Props Reference — The exact prop set that achieves 100/100 - Third-Party Script Deferral — Gate accessibility widgets and chat tools on first user gesture
- The
inlineCssRule — Why disabling inline CSS to "reduce HTML size" always makes LCP worse - Audit
"use client"Before Optimizing — Remove accidental client components first - 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:
| Device | DPR | Viewport | Picks from srcset |
|---|---|---|---|
| iPhone SE, 12 mini | 2x | 375px | 750w |
| iPhone 14, 13 | 2x | 390px | 828w |
| iPhone 15 Pro | 3x | 393px | 1200w |
| Galaxy S21 | 3x | 360px | 1080w |
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:
- Auto-generated by
loading="eager":<link rel="preload" as="image" imageSrcSet="...q=22...">— nofetchPriority - Manual in
layout.tsx:<link rel="preload" as="image" fetchPriority="high" imageSrcSet="...q=22...">— withfetchPriority
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 withoutfetchPriority— the browser treats it as lower priority thanfetchPriority="high"preloadspriority={true}generates a preload withfetchPriority="high"and covers all Next.jsdeviceSizesautomatically- With
qualities: [22, 55, 75]innext.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.tsxis 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:
- Main thread renders text → FCP at 2.3s
- 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: transformto 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 value | Behavior | Effect on LCP |
|---|---|---|
"async" (Next.js default) | Decode queued as background task | LCP may lag FCP by 0.1–0.5s |
"auto" | Browser decides based on image size | LCP ≈ 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). Usedecoding="sync"only ifautodoesn't help. Never usedecoding="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 location | Correct loading value |
|---|---|
| Navbar / header logo | loading="eager" (explicit) |
| Hero / LCP image | priority={true} — no loading prop needed |
| First content image (above fold) | loading="eager" |
| All below-fold images | loading="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: autoto the hero section or LCP element — it would delay their rendering. Also do NOT apply to sticky elements or elements withposition: fixedchildren — 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 innext.config.tsqualitiesarray -
sizes— accurate to actual rendered dimensions - No
loading="lazy"on the hero image - No
will-change: transformon the image (§6) - No CSS animation on the image element
- Navbar/logo images above fold:
loading="eager"(§9c) -
unoptimized: trueabsent fromnext.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: true | inlineCss: false | |
|---|---|---|
| CSS delivery | Inline in HTML (immediate) | External file (blocking round-trip) |
| LCP (demolitionoc.com) | 1.1s ✅ | 2.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:
| Context | quality | Size (640w) |
|---|---|---|
| Full-bleed, no overlay | 75 (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— returnsnull. 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:
- Does it use hooks (
useState,useEffect,useRef,usePathname, etc.)? → needs client - Does it use browser APIs (
window,document,localStorage, etc.)? → needs client - Does it have interactive event handlers that require state? → needs client
- 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:
- Run 5 tests in a row, wait 30s between each
- Discard the highest and lowest
- Take the median of the remaining 3
- Only compare medians — never single runs
| Median Score | Status | Action |
|---|---|---|
| 95-100 | ✅ Excellent | Ship it |
| 90-94 | ⚠️ Good | Check which single metric is dragging |
| 80-89 | ❌ Needs work | Something structural — run the diagnosis scripts |
| <80 | 🔴 Critical | Likely a blocking script or unoptimized hero |
The Golden Rules
- Trace first, optimize second — always run the console diagnosis scripts
- Exactly ONE
priorityimage per page — the LCP element - Always specify
qualityon the LCP<Image>— prevents the Dual-Preload Quality Trap - Max 2 font families, ≤50KB total
- Never disable
inlineCss: true decoding="auto"on LCP imagesfetchPriority="high"on the LCP<Image>ANDpriority— they serve different purposes- No
will-change: transformon LCP images - No
translateYoffset at page load on LCP images - CSS scroll-driven animations over JS scroll listeners
loading="eager"on above-fold logos — neverloading="lazy"- Never set
unoptimized: trueinnext.config.ts - 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.