Core Web Vitals in Practice: How I Cut 3 MB of JavaScript from My Portfolio
A real performance audit: lazy-loading a 3 MB three.js feature, removing a blocking splash screen, fixing an empty-HTML bug and shrinking assets, with Core Web Vitals explained.

Mohit Malaya Nandy
Software Engineer & Product Builder

On this page
- Core Web Vitals in 60 seconds
- Step 1: Measure before you change anything
- Fix 1: Load heavy features only when needed
- Fix 2: Stop hiding content behind a splash screen
- Fix 3: The empty-page bug
- Fix 4: Shrink images and 3D assets
- Smaller wins
- The honest results
- Your performance audit checklist
- FAQ
- Official references
My portfolio looked fast on my laptop. It was not fast. Every page downloaded around 3 MB of 3D code most visitors never used, sat behind a 3-second splash screen, and, worst of all, sent search engines an empty page.
This is the practical story of finding and fixing those problems, with the exact code. Along the way it explains the Core Web Vitals in plain English, so you can do the same audit on your own site.
Core Web Vitals in 60 seconds#
| Metric | Measures | Good | Poor |
|---|---|---|---|
| LCP | How fast the main content appears | ≤ 2.5 s | > 4 s |
| INP | How fast the page reacts to taps, clicks and keys | ≤ 200 ms | > 500 ms |
| CLS | How much the layout jumps around | ≤ 0.1 | > 0.25 |
Google judges each metric at the 75th percentile of real visits, separately for mobile and desktop. In other words, three out of four visitors should have a good experience. INP replaced the older First Input Delay metric in 2024.
Step 1: Measure before you change anything#
- PageSpeed Insights: shows real-user data (from the Chrome UX Report) when your site has enough traffic, plus a lab test with specific suggestions.
- Your build output:
next buildlists every route. Then look at the largest files in.next/static/chunks. - Real-user monitoring: the web-vitals library reports the metrics from your actual visitors.
import { onLCP, onINP, onCLS } from 'web-vitals';
function send(metric) {
navigator.sendBeacon('/api/vitals', JSON.stringify({ name: metric.name, value: metric.value }));
}
onLCP(send);
onINP(send);
onCLS(send);On my site, the build folder told the story immediately:
2.1M .next/static/chunks/e58a2bf8b26df379.js
1.2M .next/static/chunks/182b88113a57d02f.js
220K .next/static/chunks/4c86ddcf3a767f1a.jsSearching inside those two big files showed WebGLRenderer and rapier: three.js and a physics engine. That is the code for a 3D lanyard card that drops down when you click the logo.
Fix 1: Load heavy features only when needed#
The problem
The navbar imported the 3D component at the top of the file:
import Lanyard from '@/components/ui/Lanyard';Because the navbar is on every page, three.js, the physics engine and all their dependencies were bundled into every page, even though the lanyard only appears after a click. That extra JavaScript has to be downloaded, parsed and compiled, which slows loading and can delay interactions on slower phones.
The fix
Use next/dynamic to load the component only when it is first rendered:
import dynamic from 'next/dynamic';
// three.js + rapier are ~3 MB; only fetch them when the lanyard is opened.
const Lanyard = dynamic(() => import('@/components/ui/Lanyard'), { ssr: false });The component is rendered only after a click, so the 3D code downloads only for visitors who actually open it. ssr: false skips rendering it on the server, where WebGL does not exist anyway.
Fix 2: Stop hiding content behind a splash screen#
The site showed a 3-second preloader on every visit, and kept the real content invisible until it finished. For LCP that is about the worst possible design: the largest content element cannot paint while it is hidden.
Three changes:
- Render the content immediately underneath the overlay, instead of hiding it with
opacity: 0. - Show the preloader once per session, not on every page load.
- Shorten it from 3 seconds to 1.5 seconds.
The "once per session" check runs as a tiny inline script in the document head, before the page paints, so returning visitors never see a flash of the overlay:
<script
dangerouslySetInnerHTML={{
__html: `try{if(sessionStorage.getItem('preloader-seen'))document.documentElement.setAttribute('data-preloaded','')}catch(e){}`,
}}
/>html[data-preloaded] .site-preloader {
display: none;
}Fix 3: The empty-page bug#
This one was invisible in the browser and devastating for SEO. The theme provider waited until it was mounted before rendering anything:
if (!mounted) return null; // the server sends an empty <body>Visitors never noticed, because the browser filled the page in quickly. But the HTML from the server, which search engines and social apps read, was empty, and the browser could not paint anything until JavaScript ran. The fix was to always render children, read the saved theme with an inline script before paint, and sync React to it with useSyncExternalStore. I explain the SEO side in Next.js SEO in 2026.
Fix 4: Shrink images and 3D assets#
| File | Before | After | How |
|---|---|---|---|
| card.glb (3D model) | 2.46 MB | 393 KB | Embedded textures converted to WebP with glTF Transform |
| About background | 1.6 MB PNG | 106 KB WebP | Converted with sharp at quality 80 |
| Lanyard texture | 499 KB | 260 KB | Palette PNG with maximum compression |
| Hero image | 2.9 MB | Deleted | Nothing referenced it |
Both tools are scriptable, so you can run them in a build step:
# 3D model: convert embedded textures to WebP
npx @gltf-transform/cli webp card.glb card.opt.glb --quality 85
# Images: PNG to WebP with sharp
node -e "require('sharp')('bg.png').webp({ quality: 80 }).toFile('bg.webp')"A lesson from the 3D model: I first tried Draco geometry compression, which only saved about 6%. The file was big because of its textures, not its geometry. Inspect a file before choosing how to compress it (glTF Transform has an inspect command for this).
Smaller wins#
- Passive scroll listeners:
addEventListener('scroll', fn, { passive: true })tells the browser the handler will not block scrolling. - Static generation: blog and project pages now use
generateStaticParams, so they are built once instead of rendered on every request. - Removed unused packages and a chatbot that loaded an animation library on the home page.
- Opt-in third-party calls: a weather widget used to call two external APIs on every page load. It now waits until the visitor asks for it.
The honest results#
The improvements I can measure precisely are the ones above: about 3 MB less JavaScript on every page that does not open the 3D card, around 7 MB less in images and models, real content in the server HTML, and a splash screen that no longer blocks most visits.
Your performance audit checklist#
- Check that the server HTML contains your real content.
- List your largest JavaScript chunks and find out what is inside them.
- Lazy-load heavy libraries that are not needed for the first view.
- Never hide the main content behind a splash screen.
- Convert images to WebP or AVIF and resize them to the size you display.
- Inspect 3D models and compress what is actually large.
- Delete unused assets and packages.
- Delay third-party scripts and API calls until they are needed.
- Measure with real users, not just Lighthouse.
Frequently asked questions#
What are the Core Web Vitals?
Three metrics Google uses to measure page experience: Largest Contentful Paint (loading, good at 2.5 seconds or less), Interaction to Next Paint (responsiveness, good at 200 milliseconds or less) and Cumulative Layout Shift (visual stability, good at 0.1 or less).
What replaced First Input Delay (FID)?
Interaction to Next Paint (INP) replaced FID as a Core Web Vital in 2024. INP measures how quickly the page responds to all interactions during a visit, not just the first one.
Why is my Lighthouse score different from my real Core Web Vitals?
Lighthouse runs a single lab test on a simulated device. Core Web Vitals in Search Console and PageSpeed Insights come from real Chrome users (field data) at the 75th percentile. Both are useful, but field data is what Google uses.
How do I find which JavaScript is making my site slow?
Look at the build output and your largest chunks, use a bundle analyzer to see which packages they contain, then check which component imports them. A single top-level import of a heavy library can put it on every page.
Does a preloader or splash screen hurt performance?
It usually hurts Largest Contentful Paint, because the real content is hidden until the preloader finishes. If you keep one, show it only once per session and keep the content rendered underneath.
Official references#
- Web Vitals, LCP, INP and CLS, web.dev
- web-vitals library, PageSpeed Insights and Chrome UX Report
- Lazy loading and package bundling, Next.js
- sharp and glTF Transform
Found this useful? You can support my writing on GitHub Sponsors or with a coffee.


