Development··3 min read

Getting Core Web Vitals to a 90+ Score

How I took a blog from a Lighthouse score of 62 to 94, with the numbers to prove it

The Reality of 62 Points

The first time I ran Lighthouse after deploying my blog, the performance score was 62. On mobile. Desktop was 89, which was fine, but mobile was the problem. LCP 3.8s, CLS 0.15, INP 320ms. All three metrics squarely in the "Poor" zone.

You might think "it's a personal blog, who cares," but once I learned it affects search engine rankings, I couldn't ignore it.

LCP: From 3.8s to 1.2s

LCP was the biggest offender. The culprits were the hero image and web fonts.

I replaced the hero image with Next.js's Image component and added the priority attribute. That alone shaved 1.5 seconds off image loading. Converting to WebP format reduced the image from 320KB to 85KB.

For web fonts, I switched to self-hosting via next/font. The external request latency from fetching from Google Fonts CDN disappeared. Another 0.4 seconds saved.

These two changes brought LCP from 3.8 seconds down to 1.2 seconds.

CLS: Taming Layout Shift

The 0.15 CLS came from images. Setting explicit width and height on images and reserving placeholder space before loading brought CLS below 0.05.

Since my blog has no ads, this was relatively straightforward. For services with ads, CLS is a much more painful battle. Dynamically-sized ad banners are the worst enemy of CLS.

INP: Interaction Response Speed

INP (Interaction to Next Paint) was sluggish at 320ms. I tracked the cause to the dark mode toggle and search modal.

Pressing the dark mode toggle triggered a full theme CSS swap, causing a repaint that blocked other interactions. Refactoring to CSS variable-based theme switching brought the toggle response from 280ms down to 40ms.

The search modal was running post-list filtering logic on the main thread when it opened. Using useDeferredValue to debounce the search and lazy-loading the initial data brought INP below 80ms.

Trimming the JavaScript Bundle

The Performance tab showed JavaScript parsing taking 1.2 seconds on initial load. Running a bundle analyzer revealed that an unused icon library was included in its entirety.

Switching Lucide React to individual imports and removing 3 unused dependencies cut the JavaScript bundle from 180KB to 112KB. A 38% reduction.

The highest-impact optimization for bundle size isn't optimizing code -- it's deleting code you're not using.

Managing Third-Party Scripts

Google Analytics and the Giscus comment widget were each making additional requests during initial load. I switched both scripts to loading="lazy" or strategy="lazyOnload". They're scripts that work fine loading after the user has seen the page for a moment.

This alone improved FCP (First Contentful Paint) by 0.3 seconds.

Final Results

After two weeks of work, the final numbers: LCP from 3.8s to 1.2s, CLS from 0.15 to 0.03, INP from 320ms to 75ms. The Lighthouse performance score went from 62 to 94 on mobile.

Most importantly, it felt different. Opening my blog on mobile, the sense of "this is fast" was unmistakable.

Lessons Learned

Performance optimization isn't "do everything" -- it's "fix the biggest bottleneck first." Fixing LCP alone would have boosted the score by 20 points. Prioritization matters more than perfectionism. Focus on whether the real user experience improved rather than obsessing over the Lighthouse number.

Related Posts