Top Organic Leads

Improve Your PageSpeed Score: From 65 to 85

Fix the most common PageSpeed issues in 30 minutes. Paste diagnostic scripts into DevTools, find exactly what's slow, and fix it.

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

Last updated: May 30, 2026 at 6:51 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."

Most performance optimization efforts fail for one reason: developers guess what's slow instead of measuring. They compress images that aren't the bottleneck, minify JavaScript that isn't render-blocking, or add preloads for the wrong resources.

This guide starts with diagnosis — four scripts you paste into Chrome DevTools that tell you exactly what's wrong — and then walks through the quick wins that consistently deliver a 20-point PageSpeed improvement.

Step 1: Diagnose Before You Optimize (30 Seconds)

Open your site in Chrome. Press F12 to open DevTools. Click the Console tab. Paste each script below.

The Hoodbuilder Rule: We once spent hours optimizing TTFB, CDN routing, and image preloads — all theoretical. One 30-second DevTools console paste instantly revealed the real bottleneck. Always run these scripts first.

Script 1 — Identify the Actual LCP Element

This tells you which element Chrome picked as the Largest Contentful Paint:

new Promise(r => {
  new PerformanceObserver(l => {
    const e = l.getEntries().at(-1);
    r({
      element: e?.element?.tagName + ' ' + e?.element?.className?.substring(0, 60),
      url: e?.url,
      startTime: Math.round(e?.startTime),
      renderTime: Math.round(e?.renderTime),
      size: e?.size,
    });
  }).observe({ type: 'largest-contentful-paint', buffered: true });
  setTimeout(() => r('timeout'), 6000);
}).then(console.log)

What to look for:

FieldWhat it tells you
elementThe actual DOM element Chrome picked — is it what you expected?
urlThe resource URL — is it going through /_next/image? A cold CDN? A redirect?
startTimeReal LCP in milliseconds. Multiply ×4 for a rough PageSpeed simulated mobile estimate.
sizePixel area — is this truly the largest above-fold element?

Red flags in url:

  • /_next/image?...&w=1920 → cold Lambda, potentially 7-20s on first request
  • An unexpected image (e.g., a carousel slide you didn't intend) → structural bug
  • Direct S3/Blob URL → the image isn't going through optimization

Script 2 — All Above-Fold Images and Their Loading State

;[...document.querySelectorAll('img')].map(img => {
  const r = img.getBoundingClientRect();
  return {
    src: img.src?.split('?')[0].split('/').slice(-1)[0],
    loading: img.loading,
    fetchpriority: img.getAttribute('fetchpriority'),
    w: Math.round(r.width), h: Math.round(r.height),
    inViewport: r.top < window.innerHeight && r.height > 0,
  };
}).filter(i => i.inViewport)

This shows every image visible on initial load: its filename, whether it's lazy-loaded (bad if in viewport), and whether it has fetchpriority="high".

Script 3 — Active Preloads

;[...document.querySelectorAll('link[rel=preload]')].map(l => ({
  href: l.href?.split('/').slice(-1)[0] + '?' + l.href?.split('?')[1]?.substring(0, 40),
  as: l.as,
  fetchpriority: l.getAttribute('fetchpriority'),
  imageSrcSet: !!l.getAttribute('imagesrcset'),
}))

Rule: Exactly 1 image preload should exist — the LCP image. More than 1 means competing preloads are splitting bandwidth. If the preload URL doesn't match Script 1's url, the preload is wasted.

Script 4 — Resource Timing (Font vs Image Race)

The Despora Rule: Run this on a hard refresh (Ctrl+Shift+R) and paste BEFORE the page fully loads. It captures the font vs image bandwidth race that caused one site's LCP to hit 3.1s despite the H1 being SSR'd in plain HTML — caused by 4 simultaneous _next/image entries at ~973ms starving the font download.

// Hard refresh first (Ctrl+Shift+R), then paste immediately:
new PerformanceObserver((list) => {
  list.getEntries().forEach(e => {
    if (e.entryType === 'largest-contentful-paint') {
      console.log('LCP:', Math.round(e.startTime)+'ms | el:', e.element?.tagName, '| url:', e.url||'(text)');
      console.log('  renderTime:', Math.round(e.renderTime)+'ms | loadTime:', Math.round(e.loadTime)+'ms');
    }
  });
}).observe({ type: 'largest-contentful-paint', buffered: true });
new PerformanceObserver(list => {
  list.getEntries()
    .filter(e => e.name.includes('woff2') || e.name.includes('_next/image'))
    .forEach(e => console.log(
      e.name.includes('woff2') ? 'FONT:' : 'IMG: ',
      Math.round(e.startTime)+'ms->'+Math.round(e.responseEnd)+'ms',
      '| size:', e.transferSize, 'bytes | url:', e.name.substring(0,100)
    ));
}).observe({ type: 'resource', buffered: true });

What the output tells you:

PatternDiagnosisFix
Font finishes AFTER LCP renderTimefont-display:swap is updating LCPCheck font weight order + display strategy
4–5 IMG lines starting at same time (~973ms)Lazy images competing with fontReduce competing preloads / check lazy threshold
LCP renderTime >> loadTimeDelay is font swap or CSS animation endRemove animations from LCP element
LCP renderTimeloadTimeImage download is the bottleneckFix sizes prop / remove competing preloads

Step 2: The One-Priority Rule

This is the single biggest quick win on most sites.

The rule: Exactly ONE <Image> per page gets priority. That image must be the LCP element.

Every <Image priority> in Next.js generates a <link rel="preload" as="image"> in the HTML <head>. When 4+ images have priority, the browser preloads all of them at high priority, competing for bandwidth.

The waste is real. We've seen sites where three 55×55px avatar images each generated preload tags with srcSet entries up to 3840w — each entry is a separate potential fetch, competing with the actual hero image for bandwidth. The hero image loaded 2 seconds later than it should have.

How to Fix

# Find all images with priority
grep -rn "priority" features/ --include="*.tsx"

Remove priority from every image that is NOT the LCP element:

// Before (4 competing preloads):
<Image src={logo} priority />        // ❌ 180px logo — not LCP
<Image src={avatar1} priority />     // ❌ 55px avatar — not LCP
<Image src={heroImage} priority />   // ✅ This is the LCP element
<Image src={avatar2} priority />     // ❌ 55px avatar — not LCP

// After (only LCP preloaded):
<Image src={logo} />                 // ✅ Still loads eagerly, no preload
<Image src={avatar1} />              // ✅ Still loads eagerly, no preload
<Image src={heroImage} priority />   // ✅ Single preload
<Image src={avatar2} />              // ✅ Still loads eagerly, no preload

Non-priority images still load eagerly by default (loading="eager") — they just don't generate a competing <link rel="preload"> tag.

Verify after fix:

# In the deployed HTML, count preload links for images
curl -s https://yoursite.com | grep -c 'rel="preload" as="image"'
# Should be exactly 1 (the LCP image)

Step 3: Add the sizes Prop to Every Image

The problem: Without a sizes prop, next/image defaults to sizes="100vw" and the browser downloads the largest srcset entry (up to 3840px) even when the image displays at 665px. This wastes 100-300KB per image.

The fix: Add sizes matching the component's actual container width:

// Article body images (max-width ~665px column):
<Image
  src={articleImage}
  sizes="(max-width: 768px) 100vw, 640px"
  width={1200}
  height={675}
/>

// Hero images (max-width ~1150px container):
<Image
  src={heroImage}
  sizes="(max-width: 768px) 100vw, (max-width: 1280px) calc(100vw - 64px), 1150px"
  priority
  fill
/>

// Full-bleed images:
<Image
  src={bgImage}
  sizes="(max-width: 768px) 100vw, (max-width: 1280px) calc(100vw - 64px), 1400px"
  fill
/>

The sizes prop tells the browser: "This image will display at X pixels wide, so download the closest srcset entry." Without it, the browser has to guess — and it always guesses too large.

CAUTION: Never reduce image quality to compensate. Keep the existing quality value (typically quality={100}). Only optimize images by adjusting sizes to match actual display dimensions — this achieves the same file-size savings without visible degradation.

Step 4: Fix Broken Aspect Ratios

PageSpeed diagnostic: "Displays images with incorrect aspect ratio"

This happens when the width and height you set on an <Image> don't match the actual dimensions of the source image.

Common cause: hardcoded fallback dimensions

Markdown/MDX image renderers often have a hardcoded width={1200} height={800} (3:2 ratio) for images without explicit dimensions. When the actual image is 750×443 (1.69:1), PageSpeed flags the mismatch.

Fix — Use width={0} height={0} with sizes:

// Before (hardcoded 3:2 fallback — causes mismatch):
<Image src={src} width={1200} height={800} className="w-full h-auto" />

// After (auto-detect natural ratio):
<Image src={src} width={0} height={0} sizes="(max-width: 768px) 100vw, 640px" className="w-full h-auto" />

Common cause: SVGs through next/image

SVG files with a viewBox that doesn't match the width/height set in the component. A play button SVG with viewBox="0 0 300 150" (2:1) displayed at width={65} height={65} (1:1).

Fix: Replace with inline SVGs for simple icons:

// Before (aspect ratio mismatch):
<Image src={`${S3}play-circle.svg`} width={65} height={65} />

// After (zero network request, correct ratio):
<svg width="65" height="65" viewBox="0 0 65 65" fill="none" aria-hidden="true">
  <circle cx="32.5" cy="32.5" r="31" stroke="white" strokeWidth="3" opacity="0.9" />
  <path d="M26 20.5V44.5L46 32.5L26 20.5Z" fill="white" opacity="0.9" />
</svg>

Inline SVGs also eliminate network requests — one fewer fetch per icon.

Step 5: Audit Inline SVG Path Complexity

PageSpeed impact: Inflated HTML size → slower TTFB → higher LCP

Design tools like Figma and Illustrator export SVG icons with absurdly precise coordinate data. A simple phone icon can have a d="" path of 4,871 bytes when it should be ~500 bytes. In a Next.js app with RSC, each inline SVG is duplicated in the SSR'd HTML, the RSC payload, and the JS chunk — one bloated icon used 5 times across navbar/hero/CTA can add ~39KB of raw HTML.

Detect:

# Find inline SVGs with bloated path data (>1KB per path)
curl -s "https://YOUR_SITE/" | grep -o 'M[0-9.]*[^"]*' | awk '{ if (length > 1000) print NR": "length" bytes" }'

# Count total inline SVG bytes in HTML
curl -s "https://YOUR_SITE/" | grep -o '<svg[^>]*>.*</svg>' | wc -c

Fix — Replace filled paths with stroke-based Lucide icons:

// Before (4,871 bytes — filled path with absurd precision):
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
  <path d="M11.3255 10.7693L10.7967 10.2374L11.3255 10.7693ZM..."
        fill="black" fillOpacity="0.7"/>
</svg>

// After (497 bytes — clean stroke-based Lucide phone):
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
  <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07
    19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3
    a2 2 0 0 1 2 1.72c.127.96.362 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09
    9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.338 1.85.573
    2.81.7A2 2 0 0 1 22 16.92Z"
    stroke="rgba(0,0,0,0.7)" strokeWidth="1.5"
    strokeLinecap="round" strokeLinejoin="round"/>
</svg>

Rule of thumb:

Icon typeExpected path size
Arrow / chevron~100 bytes
Star~300 bytes
Phone~500 bytes
If <path d="..."> > 1KBBloated — replace it

The Lucide icon set provides clean, optimized SVGs for all common icons.

Step 6: Fix Duplicate Font Loading

Two common issues combine to cause the browser to download fonts twice — wasting bandwidth and starving the LCP image.

Issue 1: Tailwind arbitrary font values

Using font-['Inter_Tight'] in Tailwind generates CSS like font-family: Inter Tight — the raw Google Font name, not the optimized next/font CSS variable. Tailwind v4 may also generate duplicate @font-face rules for these raw names.

Fix: Replace arbitrary font values with utility classes that use CSS variables:

// Before (generates raw font-family reference):
<h2 className="font-['Inter_Tight'] font-bold">Title</h2>

// After (uses next/font's CSS variable):
<h2 className="font-inter-tight font-bold">Title</h2>

Define the utilities in globals.css:

.font-inter-tight { font-family: var(--font-inter-tight); }
.font-inter { font-family: var(--font-inter); }

Issue 2: :root CSS variable override

If globals.css defines --font-inter-tight: "Inter Tight", sans-serif in :root, this overrides next/font's optimized variable (set on a body class) that includes size-adjusted fallback fonts.

Fix: Remove hardcoded font variables from :root — let next/font be the single source of truth:

/* REMOVE these from :root */
--font-inter-tight: "Inter Tight", sans-serif;
--font-inter: "Inter", sans-serif;

Detect:

# Count .woff2 files referenced in HTML (should be ≤2 for 2 fonts)
curl -s "https://YOUR_SITE/" | grep -o '\.woff2' | wc -l

# Check for Tailwind arbitrary font-family values
grep -rn "font-\\['" --include="*.tsx" features/ atoms/ molecules/

Infinite loop carousels duplicate slides (e.g., 5 original items become 15 in a horizontal scroll row). Even with loading="lazy", Chrome's horizontal lookahead threshold is aggressive — the browser downloads all 15 images on initial load, starving the LCP image.

Fix: Introduce an isInitialized React state. Only render images for the slides visible in the initial viewport. Defer the rest until user interaction:

const [isInitialized, setIsInitialized] = useState(false);

// Trigger onMouseEnter, onTouchStart, onFocus, onPointerDown:
const initializeScroll = () => {
  if (isInitialized) return;
  setIsInitialized(true);
  // Set initial scroll offset...
};

// Inside loopedItems.map((item, i) => ...):
const shouldRenderImage = isInitialized || i < 4 || item.priority;

return (
  <div className="relative h-96 overflow-hidden">
    {shouldRenderImage ? (
      <Image src={item.image} fill priority={item.priority} ... />
    ) : (
      <div className="absolute inset-0 bg-gray-900" />
    )}
  </div>
);

This reduces the number of rendered images on initial load by 70%+, freeing bandwidth for the LCP image.

Step 8: Fix Accessibility Quick Wins

PageSpeed flags accessibility issues that are easy to fix and often bundled with performance audits.

Green text contrast

If your brand green is #379b75, it only has 3.5:1 contrast on white backgrounds (WCAG AA requires 4.5:1).

Fix: Replace #379b75 with #217a55 (5.3:1 on white). For Tailwind users: replace text-emerald-600 with text-emerald-700.

Touch target overlap

Negative margins (-mx-4) on 44px+ touch targets cause them to overlap, violating WCAG spacing requirements.

Fix: Remove negative margins. Use gap-0 on the parent flex container instead.

Step 9: Verify Your Fixes

After applying the fixes above, run this checklist:

# 1. Build — must pass with zero errors
pnpm run build

# 2. Compare bundle sizes
find .next/static -name "*.js" -type f | xargs cat | wc -c | awk '{printf "%.0f KB total client JS\n", $1/1024}'

# 3. Spot-check largest chunks
find .next/static/chunks -name "*.js" -type f | xargs ls -lhS | head -10

# 4. Verify no raw <img> tags remain
grep -rn "<img" features/ atoms/ molecules/ components/ --include="*.tsx" | grep -v "next/image" | grep -v "{/*"

# 5. Verify no forced reflow patterns
grep -rn "scrollBehavior\|offsetHeight\|offsetWidth" features/ --include="*.tsx"

# 6. Verify green contrast fixed everywhere
grep -rn "379b75" features/ atoms/ molecules/ components/ lib/ --include="*.tsx"
# Should only return icon color props — NOT text on white

# 7. Verify priority only on LCP images
grep -rn "priority" features/ --include="*.tsx"
# Should only match hero images and index === 0 patterns

# 8. Verify font loading (should be ≤2 .woff2 preloads)
curl -s https://yoursite.com | grep -o 'rel="preload"[^>]*as="font"' | wc -l

# 9. Deploy and run PageSpeed Insights ON ALL PAGES
# Target: 80-85 on mobile

What to Expect

These basic fixes consistently deliver a 15-20 point improvement:

FixTypical Impact
Remove competing priority images+5-10 points (LCP improvement)
Add sizes to large images+3-5 points (reduced download size)
Fix aspect ratios+1-2 points (eliminates CLS shifts)
Fix contrast/touch targets+1-3 points (accessibility score)

If you started at 65, you should now be in the 80-85 range.

Next Steps

Got to 85 but stuck? In the next guide, we show you how to cut 300KB of JavaScript without changing a single feature — taking you from 86 to 93.

Next: Reduce Unused JavaScript (86 to 93) →

Partner Up with Us and We'll Help Your Business Grow!

Call now to consult with our digital marketing experts.

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.