Table of Contents
I spent a weekend optimizing this portfolio site — the one you're reading right now — from a mediocre Lighthouse score to consistent 95+ across all categories. Here's a frank breakdown of what made a real difference, ordered by impact.
What Core Web Vitals actually measure
Core Web Vitals are three metrics Google uses as part of its ranking signal since 2021. They measure real user experience, not synthetic benchmarks:
- LCP (Largest Contentful Paint) — how long until the main content is visible. Target: under 2.5 seconds.
- CLS (Cumulative Layout Shift) — how much the page layout jumps around while loading. Target: under 0.1.
- INP (Interaction to Next Paint) — how responsive the page is to user input. Target: under 200ms. Replaced FID in March 2024.
Google's ranking boost from good Core Web Vitals is real but modest — it's a tiebreaker, not a primary signal. The more important reason to care: these metrics directly correlate with whether users stay on your page.
LCP: the biggest win
LCP is almost always caused by one of two things: a large above-the-fold image loading slowly, or render-blocking resources delaying first paint. On my portfolio, it was the hero image.
1. Preload the LCP image
The browser doesn't know your hero image is critical until it parses the HTML and discovers the <img> tag. By then, it's already loaded stylesheets and fonts. A preload hint tells the browser immediately:
<!-- In <head>, before any stylesheets -->
<link
rel="preload"
as="image"
href="images/hero-640.webp"
imagesrcset="images/hero-640.webp 1280w, images/hero-768.webp 768w"
imagesizes="50vw"
fetchpriority="high"
/>
Add fetchpriority="high" to the <img> element itself as well. This dropped my LCP from 3.8s to 1.4s on its own.
2. Serve modern image formats
WebP is 25–34% smaller than JPEG at equivalent quality. AVIF is even smaller but has less browser support. Use WebP with a JPEG fallback:
<picture>
<source
type="image/webp"
srcset="hero-640.webp 640w, hero-1280.webp 1280w"
sizes="50vw"
/>
<img
src="hero-1280.jpg"
alt="Hero image"
width="1280"
height="853"
fetchpriority="high"
/>
</picture>
3. Always specify width and height on images
Without explicit dimensions, the browser reserves no space for the image. When it loads, the layout shifts. This hurts both CLS and LCP. With dimensions, the browser allocates space immediately.
CLS: easier than it looks
CLS is usually caused by content shifting as assets load. The fixes are mechanical:
- Images and videos: Always set
widthandheightattributes. CSSaspect-ratioworks too. - Fonts: Use
font-display: swaporfont-display: optional. Avoidfont-display: auto(the default) which can cause invisible text that then suddenly appears. - Dynamic content: Reserve space for ads, banners, or late-loading sections before they arrive. A placeholder div with fixed height is enough.
- Avoid inserting content above existing content. Notification banners that push the page down after load are classic CLS offenders.
My CLS was 0.18 — over the 0.1 threshold — entirely because of a Google Fonts load causing a font swap. Switching to font-display: swap and preloading the font brought it to 0.02.
<!-- Preconnect before any font CSS loads -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Load fonts asynchronously to avoid render blocking -->
<link
href="https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@400;700&display=swap"
rel="stylesheet"
media="print"
onload="this.onload=null;this.removeAttribute('media');"
/>
INP: the new kid
INP measures the delay between a user interaction (click, key press, tap) and the next visual update. Most static HTML sites have excellent INP naturally. JavaScript-heavy apps are where it suffers.
Common causes on React/SPA sites:
- Long tasks on the main thread — large synchronous computations blocking the event loop. Break them up with
setTimeout(fn, 0)orscheduler.postTask(). - Excessive re-renders on interaction — a click triggering a cascade of React re-renders. Memoize aggressively around click handlers.
- Third-party scripts — analytics, chat widgets, and ad scripts run on the same main thread. Load them with
deferor move them after user interaction.
On this portfolio, the contact form had a Turnstile (CAPTCHA) widget that blocked interaction for ~200ms. Loading it deferred fixed it.
Fonts are performance killers
Google Fonts is convenient but adds two DNS lookups, two connections, and a network round-trip for the CSS file before any font bytes download. The asynchronous loading pattern eliminates the render-blocking behavior:
<!-- This pattern loads fonts without blocking render -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Raleway:wght@600;800&display=swap"
media="print"
onload="this.onload=null;this.removeAttribute('media');"
/>
<noscript>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?..." />
</noscript>
The media="print" trick makes the browser load the CSS without blocking render. The onload handler switches it to media="all" once loaded. Include a <noscript> fallback for browsers without JavaScript.
For maximum performance, self-host fonts using @font-face with local WOFF2 files. This eliminates the third-party DNS lookup entirely. Tools like google-webfonts-helper make this easy.
What didn't work
I also tried things that looked good in theory but didn't move the metrics:
- Minifying HTML. Saves maybe 5KB on a 30KB page. Lighthouse doesn't care.
- Inlining critical CSS. The build complexity wasn't worth the marginal gain for this site's size. Worth it for very large CSS files.
- Aggressive image compression. Going below 75% WebP quality made images look bad with minimal file size gain.
- Service Worker caching for first visits. Service Workers help on repeat visits, not the first one. LCP is a first-visit metric.
Final scores
After all optimizations, running Lighthouse on a throttled mobile connection:
- Performance: 97
- Accessibility: 100
- Best Practices: 100
- SEO: 100
- LCP: 1.4s (was 3.8s)
- CLS: 0.02 (was 0.18)
- INP: 56ms (was 190ms)
The 80% of the gains came from: preloading the hero image, serving WebP, setting image dimensions, and async-loading fonts. Everything else was marginal.
Want a performance audit on your site?
Get in touch