Tutorial 12 min read

Speed Up Your Website to 100% PageSpeed Score — Practical Guide

Mahmoud Hamdy
February 1, 2026

Website speed is not a vanity metric — it directly affects your search ranking, user retention, and conversion rate. Google's own research shows that a one-second delay in mobile load time can reduce conversions by up to 20%. When I first ran this portfolio through PageSpeed Insights, it scored 75. After applying the techniques in this guide, it now holds a consistent 99–100. This article is a complete walkthrough of everything I did and why it worked.

Why Speed Matters

Speed matters on three fronts. First, SEO: Google uses Core Web Vitals as a ranking signal. A slow site loses ground to faster competitors even when the content is better. Second, user experience: studies show 53% of mobile users abandon a page that takes more than three seconds to load. Third, conversion: e-commerce sites see a direct correlation between load time and checkout completion. Every 100ms you shave off can meaningfully move revenue numbers.

The good news is that most speed problems have well-understood fixes. You do not need a rewrite. You need to apply a checklist methodically.

Understanding PageSpeed Insights

PageSpeed Insights (PSI) runs two analyses: a lab test using Lighthouse in a controlled environment, and field data sourced from the Chrome User Experience Report (CrUX) — real-world measurements from Chrome users. The lab score is what you can control directly. The field data is what actually matters for ranking. Both should be green.

Run PSI on both mobile and desktop. Mobile is always harder to optimize because the test simulates a mid-tier Android device on a 4G connection. Passing mobile automatically means desktop is fine.

Core Web Vitals Explained

There are four metrics you need to understand deeply:

LCP (Largest Contentful Paint) — measures when the largest visible element (usually a hero image or heading) finishes rendering. Target: under 2.5 seconds. The most common culprit is a large unoptimized hero image or a render-blocking resource before it.

FID / INP (Interaction to Next Paint) — measures the delay between user input and the browser's response. FID was replaced by INP in March 2024. Target: under 200ms. The usual cause is heavy JavaScript executing on the main thread at page load.

CLS (Cumulative Layout Shift) — measures visual stability. Every time an element shifts unexpectedly (ads loading in, fonts swapping, images without dimensions), the score rises. Target: under 0.1. The fix is almost always declaring explicit width and height on images and reserving space for dynamic content.

FCP (First Contentful Paint) — the moment the first text or image appears. Target: under 1.8 seconds. Improving FCP is about reducing server response time and eliminating render-blocking resources.

Image Optimization

Images are almost always the single biggest opportunity. The rules are simple but few developers follow all of them consistently.

Always serve images in WebP or AVIF format. AVIF is 50% smaller than JPEG at equivalent quality; WebP is 25–35% smaller. Use the HTML picture element to provide fallbacks:

<picture>
  <source srcset="hero.avif" type="image/avif">
  <source srcset="hero.webp" type="image/webp">
  <img src="hero.jpg" alt="Hero image" width="1200" height="630"
       loading="lazy" decoding="async">
</picture>

Always declare explicit width and height attributes — this prevents CLS. Use loading="lazy" for below-the-fold images. For the LCP image (your hero), use loading="eager" and add a <link rel="preload"> in the head. Use decoding="async" on non-critical images to unblock the main thread.

Compress aggressively: for photos, quality 75–80 in WebP is indistinguishable from 100. Use Squoosh, ImageOptim, or Sharp in your build pipeline.

CSS Optimization

Render-blocking CSS is one of the most overlooked causes of poor FCP. The browser cannot paint anything until it has downloaded and parsed all stylesheets in the <head>. The fix has two parts.

First, inline your critical CSS — the styles needed to render above-the-fold content — directly in a <style> tag in the <head>. Tools like Critical, Penthouse, or Astro's built-in critical CSS extraction automate this. The critical CSS block is usually 5–15 KB.

Second, load the rest of your CSS asynchronously using the media trick:

<link rel="preload" href="styles.css" as="style"
      onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>

Also audit your CSS for unused rules. PurgeCSS can remove unused Tailwind classes and custom styles automatically during the build. On this portfolio, PurgeCSS reduced the CSS bundle from 48 KB to 11 KB.

JavaScript Optimization

JavaScript is the heaviest resource type relative to its byte size because it requires parsing, compilation, and execution — all on the main thread. There are several powerful techniques to minimize its impact.

Defer and async: every <script> tag that is not critical to the initial render should carry defer. Use async only for completely independent scripts like analytics. Never put synchronous scripts in the <head> without defer or async.

Code splitting: in frameworks like Next.js or Vite, route-based code splitting is automatic. Ensure you are not importing large libraries into your main bundle that are only needed on one page.

Tree shaking: import only what you use. Instead of import _ from 'lodash', write import { debounce } from 'lodash-es'. Lodash-es is the ES module version that supports tree shaking; lodash is not.

Third-party scripts: analytics, chat widgets, and ad scripts are often the worst offenders. Load them after the page is interactive using requestIdleCallback or a facade pattern.

Font Optimization

Custom fonts are a subtle but significant source of layout shift and render blocking. The three key techniques are:

Preload your primary font files in the head so the browser fetches them early:

<link rel="preload" href="/fonts/outfit-var.woff2"
      as="font" type="font/woff2" crossorigin>

Use font-display: swap (or optional for less critical fonts) in your @font-face declarations. swap shows a fallback font immediately and swaps once the custom font loads, avoiding invisible text. optional only uses the custom font if it loads within a very short window — great for non-essential decorative fonts.

Subset your fonts. If your site is English-only, there is no reason to load the full Unicode range. The Google Fonts API supports subsetting with the text= parameter for static strings, or use glyphhanger for full control.

Server-Side Optimizations

Client-side work can only get you so far. The server must also be fast. The most impactful server-side changes are:

Caching headers: serve static assets with Cache-Control: public, max-age=31536000, immutable. For HTML, use Cache-Control: no-cache combined with ETags so browsers revalidate cheaply. This is the single most powerful optimization for repeat visitors.

Compression: enable Brotli compression (not just gzip). Brotli achieves 15–25% better compression on text assets. Nginx supports it with the ngx_brotli module; Cloudflare enables it automatically.

HTTP/2 or HTTP/3: enables multiplexing, eliminating the connection overhead of HTTP/1.1. All major hosting platforms support HTTP/2 by default today.

CDN: serve static assets from a CDN close to your users. Cloudflare's free tier is sufficient for most personal and small business sites.

TTFB (Time to First Byte): if your TTFB is above 600ms, look at your server response time. For static sites, a CDN edge cache should bring TTFB under 100ms globally.

Lazy Loading and Intersection Observer

Lazy loading is not just for images. Any resource that is only needed when the user scrolls to a particular section can be deferred. Use the native loading="lazy" attribute for images and iframes. For JavaScript modules, use dynamic import():

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      import('./heavy-chart-module.js').then(m => m.init(entry.target));
      observer.unobserve(entry.target);
    }
  });
}, { rootMargin: '200px' });

document.querySelectorAll('.chart-container').forEach(el => observer.observe(el));

The rootMargin: '200px' triggers the import 200px before the element enters the viewport, giving the browser time to fetch and parse the module before it is needed.

Compositor-Only Animations

CSS animations that trigger layout recalculation (anything that changes width, height, margin, padding, or top/left) are expensive. They force the browser to recalculate the layout of the entire document on every frame. Stick to properties that the browser can animate on the compositor thread without touching the main thread: transform and opacity. Add will-change: transform to elements with heavy animations, but use it sparingly — it allocates a separate GPU layer for each element.

Service Workers for Repeat Visits

A service worker can serve your entire shell from cache on repeat visits, achieving near-instant load times. The Workbox library from Google simplifies this considerably:

import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';

cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);

// Images: cache first
registerRoute(({ request }) => request.destination === 'image',
  new CacheFirst({ cacheName: 'images', plugins: [/* ExpirationPlugin */] }));

// JS/CSS: stale-while-revalidate
registerRoute(({ request }) =>
  request.destination === 'script' || request.destination === 'style',
  new StaleWhileRevalidate({ cacheName: 'static-resources' }));

Measuring Tools

The essential toolset: PageSpeed Insights for the combined lab + field view. WebPageTest for waterfall analysis and multi-location testing. Chrome DevTools Performance tab for deep main-thread profiling. Lighthouse CLI for CI integration — fail the build if the score drops below 90. web-vitals npm package to report real-user CWV to your analytics.

My Personal Journey: 75 to 99

When I first measured this portfolio, the score was 75. The main culprits were an unoptimized hero image (loaded as JPEG, 340 KB), two render-blocking Google Fonts requests, and several large Tailwind classes that were never used in production. The JavaScript was also loaded synchronously without defer on three third-party scripts.

After converting the hero to WebP, implementing critical CSS inline, adding PurgeCSS to the build, and deferring all non-critical scripts, the score jumped to 94. The final push to 99 came from adding a preload hint for the hero image, setting immutable cache headers on all static assets, and switching to Brotli compression on the server. The result: LCP dropped from 3.1s to 0.9s, CLS went from 0.14 to 0.02, and FCP improved from 2.4s to 0.7s.