Back to Blog
Web Performance SEO Core Web Vitals Astro Accessibility

PageSpeed Insights: A Practical Guide to Achieving 100/100

Step-by-step guide to optimizing your website's performance, accessibility, and SEO using PageSpeed Insights. Learn how we reduced image sizes by 95%, eliminated render-blocking resources, and improved Core Web Vitals with real code examples.

AG
Alfonso Garcia
· · 11 min read
PageSpeed Insights: A Practical Guide to Achieving 100/100

If you’ve ever run your site through PageSpeed Insights and stared at an underwhelming score, you’re not alone. The good news: most performance issues follow predictable patterns, and the fixes are straightforward once you know what to look for.

This guide walks through the exact optimizations we applied to labitcode.com — reducing our largest image by 95%, eliminating render-blocking fonts, and improving accessibility — with code you can copy into your own projects.


Understanding Core Web Vitals

Before diving into fixes, let’s understand what PageSpeed actually measures. Google evaluates your site using three Core Web Vitals:

MetricWhat It MeasuresGoodNeeds WorkPoor
LCP (Largest Contentful Paint)How fast the main content loads≤ 2.5s≤ 4.0s> 4.0s
INP (Interaction to Next Paint)How responsive the page feels≤ 200ms≤ 500ms> 500ms
CLS (Cumulative Layout Shift)How much content shifts during loading≤ 0.10≤ 0.25> 0.25

These metrics directly affect your search rankings. Google uses Core Web Vitals as a ranking signal, so performance improvements translate directly into SEO gains.


1. Image Optimization — The Biggest Win

Images are almost always the single largest opportunity for performance improvement. Here’s why: they’re typically the heaviest assets on a page, and they directly impact LCP.

Convert PNG/JPEG to WebP

WebP offers 25-35% smaller file sizes than PNG at equivalent quality, and it’s supported by every modern browser. For our site, the results were dramatic:

ImagePNG SizeWebP SizeReduction
terreno-rustico2,057 KB64 KB-97%
markdown-style-guide62 KB46 KB-26%
ai-driven-development48 KB35 KB-27%
building-labitcode31 KB17 KB-44%
labitcode-platform22 KB8 KB-64%

That’s 2.2 MB reduced to 170 KB — a 92% total reduction.

How to Convert Images with Sharp

If you’re using Node.js (any framework — Astro, Next.js, Remix, etc.), Sharp is the industry-standard tool:

npm install sharp --save-dev
// convert-images.mjs
import sharp from "sharp";
import { readdirSync } from "fs";
import { join } from "path";

const dir = "./public/images";
const files = readdirSync(dir).filter((f) => f.endsWith(".png"));

for (const file of files) {
  const input = join(dir, file);
  const output = join(dir, file.replace(".png", ".webp"));

  const info = await sharp(input).webp({ quality: 80 }).toFile(output);

  console.log(`${file} → ${info.size} bytes`);
}

Run it with node convert-images.mjs and update your image references from .png to .webp.

Pro tip: For maximum compression, consider AVIF format. It offers ~50% smaller files than WebP, but encoding is slower and browser support is slightly narrower. Use <picture> with AVIF as the preferred source and WebP as fallback.

Add fetchpriority="high" to Your LCP Image

The Largest Contentful Paint element is usually a hero image. By default, browsers discover images late in the parsing process. The fetchpriority attribute tells the browser to prioritize downloading this image immediately:

<!-- ❌ Before: browser discovers this late -->
<img src="/images/hero.webp" alt="Hero image" />

<!-- ✅ After: browser prioritizes this download -->
<img
  src="/images/hero.webp"
  alt="Hero image"
  width="1280"
  height="720"
  fetchpriority="high"
  loading="eager"
  decoding="async"
/>

Key attributes explained:

  • fetchpriority="high" — Tells the browser this is a high-priority download. Only use on the LCP element, usually 1 per page.
  • loading="eager" — Prevents lazy loading (which would delay the LCP image). All images below the fold should use loading="lazy" instead.
  • decoding="async" — Allows the browser to decode the image off the main thread.
  • width and height — Lets the browser reserve space before the image loads, preventing layout shifts (CLS).

Prevent Layout Shifts with Explicit Dimensions

One of the most common causes of CLS (Cumulative Layout Shift) is images loading without reserved space. The browser doesn’t know how tall the image will be, so content jumps when it arrives.

The fix is simple — always include width and height:

<!-- ❌ Causes CLS: browser doesn't know the size -->
<img src="/photo.webp" alt="Photo" loading="lazy" />

<!-- ✅ No CLS: browser reserves 16:9 space immediately -->
<img src="/photo.webp" alt="Photo" loading="lazy" decoding="async" width="640" height="360" />

If you’re using CSS to control the actual display size (e.g., width: 100%), the width and height attributes still matter — the browser uses them to calculate the aspect ratio and reserve the correct space.

Resize Images to Match Rendered Size

Converting to WebP is great, but if your source image is 2560x1440 and you only display it at 640x360 (like inside card feeds or mobile screens), you are still wasting bandwidth on pixels the user cannot see. Lighthouse will flag this with the “Properly size images” audit.

For example, resizing our terreno-rustico.webp from its original 2560x1440 resolution to a retina-optimized 1280x720 resolution (which provides a perfect 2x pixel density for its 640x360 container) dropped its file size from 109 KB to just 64 KB with zero visible loss in quality and perfect sharpness on high-DPI screens. Always size your assets appropriately for the container they live in!


2. Font Loading Strategy — Eliminating Render-Blocking CSS

Google Fonts loads via a <link rel="stylesheet"> tag, which is render-blocking by default. This means the browser won’t paint anything until the font CSS has fully downloaded and parsed.

The Problem

<!-- ❌ Render-blocking: browser waits for this before painting -->
<link
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
  rel="stylesheet"
/>

The Solution: Preload + Swap

<!-- ✅ Non-blocking: preload downloads early, swap makes it a stylesheet once loaded -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
  rel="preload"
  as="style"
  href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
  onload="this.onload=null;this.rel='stylesheet'"
/>
<noscript>
  <link
    href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
    rel="stylesheet"
  />
</noscript>

How this works:

  1. rel="preload" tells the browser to start downloading immediately, but without blocking rendering.
  2. as="style" ensures correct prioritization and caching.
  3. onload="this.onload=null;this.rel='stylesheet'" swaps it to a proper stylesheet once loaded. The this.onload=null prevents an infinite loop.
  4. <noscript> provides a fallback for users without JavaScript (rare, but proper progressive enhancement).

Advanced: Self-Hosting Fonts

For maximum performance, consider self-hosting your fonts instead of loading them from Google:

# Download the font files
npx @nicolo-ribaudo/google-webfonts-helper \
  --font Inter --weights 400,500,600,700,800 --formats woff2 \
  --output public/fonts

Then reference them locally in your CSS:

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-v18-latin-regular.woff2") format("woff2");
}

This eliminates two DNS lookups (fonts.googleapis.com and fonts.gstatic.com) and removes a third-party dependency entirely.

Inline Critical Stylesheets to Avoid Render-Blocking CSS

Lighthouse will flag any stylesheet linked in the <head> of your page as render-blocking, since the browser must download and parse it before drawing anything. While preloading is great for third-party scripts, your primary stylesheets should block rendering to prevent FOUC (Flash of Unstyled Content).

However, if your stylesheet is small (e.g., less than 15-20 KB), you can completely eliminate the HTTP request and render-blocking penalty by inlining the CSS directly into the HTML within a <style> block.

In Astro, this is incredibly easy. Simply configure inlineStylesheets in astro.config.mjs:

// astro.config.mjs
export default defineConfig({
  build: {
    inlineStylesheets: "always", // Inlines all stylesheets directly into the HTML
  },
});

By inlining our 8.7 KB stylesheet, we eliminated a round-trip request, cut our render-blocking resources audit failure completely, and saved ~160ms of paint delay.


3. Accessibility Improvements

PageSpeed Insights also evaluates accessibility. Here are three quick wins that improve both your score and the real experience for users with disabilities.

Connect Controls with aria-controls

When a button controls the visibility of another element (like a mobile menu), link them with aria-controls:

<!-- ❌ Screen readers don't know what this button controls -->
<button aria-label="Toggle menu" aria-expanded="false">☰</button>
<div id="mobile-menu">...</div>

<!-- ✅ Screen readers can jump directly to the controlled element -->
<button aria-label="Toggle menu" aria-expanded="false" aria-controls="mobile-menu">☰</button>
<div id="mobile-menu">...</div>

Hide Decorative SVGs from Screen Readers

Decorative icons (hearts, dividers, background shapes) should be invisible to assistive technology:

<!-- ❌ Screen reader announces "image" with no useful content -->
<svg viewBox="0 0 24 24" fill="currentColor">
  <path d="M12 21.35l-1.45-1.32..." />
</svg>

<!-- ✅ Screen reader skips this entirely -->
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
  <path d="M12 21.35l-1.45-1.32..." />
</svg>

Respect Motion Preferences

Some users experience motion sickness or have vestibular disorders. The prefers-reduced-motion media query lets you disable animations for those users:

/* Define the animation */
@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* Only animate for users who haven't requested reduced motion */
@media (prefers-reduced-motion: no-preference) {
  .animate-fade-in {
    animation: fade-in 0.4s ease-out both;
  }
}

/* Explicitly disable for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
  .animate-fade-in {
    animation: none;
  }
}

This is both an accessibility requirement and a best practice — users who prefer reduced motion get instant rendering, which also improves perceived performance.


Ensure Proper Color Contrast Ratios

Text and interactive elements must have a contrast ratio of at least 4.5:1 against their background (or 3:1 for large text) to be readable by users with low vision.

In our first run, our “Live” project link had #DC2626 (red) on a #FDF4F4 (very light red) background. This resulted in a contrast ratio of 4.46:1—only 0.04 below the requirement! Lighthouse flagged this as a critical failure.

By switching to a slightly darker shade in light mode, #B91C1C (our accent-dark variable), we bumped the contrast ratio to 5.64:1, resolving the audit:

<!-- ❌ Before: 4.46:1 contrast (failing) -->
<a class="text-accent bg-accent/5 ...">Live</a>

<!-- ✅ After: 5.64:1 contrast (passing!) -->
<a class="text-accent-dark dark:text-accent bg-accent/5 ...">Live</a>

Provide Sufficient Touch Target Sizes

Tap targets (links, buttons, inputs) must be large enough and have enough space around them to prevent accidental taps on mobile devices. Lighthouse flags any interactive element smaller than 48×48px or too close to other targets.

To solve this for inline card links:

  1. Wrap them in a helper layout with sufficient gap (e.g., gap-4).
  2. Add padding to increase their clickable dimensions (e.g., px-3 py-1.5 min-h-[38px]).
  3. Turn text links into small, visually distinct buttons. This not only passes accessibility but improves overall visual cues!

4. SEO Meta Tags You Might Be Missing

Beyond the basics (<title>, <meta name="description">), there are several meta tags that improve your site’s presentation across platforms.

Theme Color

Controls the browser chrome color on mobile devices:

<!-- Different colors for light and dark mode -->
<meta name="theme-color" content="#0A1628" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />

Apple Touch Icon

Provides a proper icon when users add your site to their iOS home screen:

<link rel="apple-touch-icon" href="/apple-touch-icon.png" />

The recommended size is 180×180px. If you already have an SVG favicon, you can reference it directly — iOS will handle the conversion.

Open Graph Default Image

Always provide a fallback OG image for pages that don’t have their own. Social media platforms use this when sharing links:

<!-- In your SEO component, set a default -->
const image = Astro.props.image || "/og-default.png";

Create a 1200×630px image with your brand name and tagline. This ensures every page on your site has a proper preview image when shared on Twitter, LinkedIn, or Slack.


5. Checklist: Apply This to Your Project

Here’s a prioritized checklist you can work through on any project:

High Impact (Do First)

  • Convert all images to WebP (or AVIF)
  • Add fetchpriority="high" to your LCP image (usually the hero)
  • Add width and height to every <img> tag
  • Make Google Fonts non-blocking with preload + onload

Medium Impact

  • Create a proper OG default image (1200×630px)
  • Add <meta name="theme-color"> for mobile browsers
  • Add <link rel="apple-touch-icon">
  • Lazy-load all images below the fold with loading="lazy"

Accessibility

  • Add aria-hidden="true" to decorative SVGs/images
  • Add aria-controls to buttons that toggle panels
  • Wrap animations in prefers-reduced-motion
  • Verify color contrast ratios (minimum 4.5:1 for body text)

Verification

  • Run PageSpeed Insights on mobile AND desktop
  • Test with Chrome DevTools Lighthouse tab
  • Verify no CLS in Chrome DevTools Performance panel
  • Test with a screen reader (VoiceOver on Mac, NVDA on Windows)

Results

After applying these optimizations to labitcode.com, we achieved:

  • 100/100 in Local Lighthouse audits for both Performance and Accessibility.
  • 93/100 Mobile Performance and 100/100 Mobile Accessibility / SEO / Best Practices in production PageSpeed Insights.
  • 92% total reduction in image payload (2.2 MB → 170 KB).
  • Non-blocking font loading and inlined stylesheets — eliminating render-blocking resources.
  • Zero CLS — all images have explicit dimensions.
  • Perfect accessibility — high-contrast text, proper touch target sizes (≥38px with spacing), motion preferences respected, and ARIA attributes correct.
  • Complete SEO meta tags — OG images, theme-color, apple-touch-icon.

Why the difference between Local (100) and Production (93)?

It’s common to see a drop in performance scores when moving from a local environment to production. Here is why:

  1. Network Latency & Server Roundtrips: When running Lighthouse locally, files are served instantly from your machine (localhost has 0ms latency). In production, files are requested over real networks, which adds latency even with a fast CDN.
  2. Third-Party Scripts & Trackers: Real production sites load analytical scripts (like @vercel/speed-insights or Google Analytics) to track performance and traffic. These scripts execute JavaScript on the main thread during load, slightly impacting mobile performance metrics like Total Blocking Time (TBT).
  3. Throttled Hardware Emulation: PageSpeed Insights emulates a mid-range mobile device with a slow 4G network connection. This makes any network delay or JavaScript execution (like trackers) show up as a larger performance hit than on your local computer.

The best part? These optimizations made the site blazingly fast in the real world — where performance directly impacts user retention and search rankings.


Further Reading


Performance is a feature. Every millisecond you save is a user who stays.

Join the conversation

Have thoughts on this post? Share them on social media or reach out directly.