
Web Performance: Core Web Vitals
If it takes >3s, 53% users leave. Google's Core Web Vitals (LCP, INP, CLS) and how to optimize them.

If it takes >3s, 53% users leave. Google's Core Web Vitals (LCP, INP, CLS) and how to optimize them.
Why does my server crash? OS's desperate struggle to manage limited memory. War against Fragmentation.

Two ways to escape a maze. Spread out wide (BFS) or dig deep (DFS)? Who finds the shortest path?

A comprehensive deep dive into client-side storage. From Cookies to IndexedDB and the Cache API. We explore security best practices for JWT storage (XSS vs CSRF), performance implications of synchronous APIs, and how to build offline-first applications using Service Workers.

Fast by name. Partitioning around a Pivot. Why is it the standard library choice despite O(N²) worst case?

The Lighthouse score for my first landing page was 32. Red warnings covered the screen, and while the page loaded fine on my laptop, it took over 10 seconds on my phone. Thinking like a user gave me the answer. If a page doesn't load in 3 seconds, I hit the back button.
Google Analytics confirmed it. The bounce rate was 67%. People were leaving as soon as they arrived. That's when it hit me. Performance isn't optional. No matter how pretty the design or how many features you have, if it's slow, nobody will stick around.
That day, I dove into performance optimization. I'd never heard of Core Web Vitals, and I had no idea what LCP, FID, or CLS meant. But as I fixed these issues one by one and watched the score climb from 32 to 91, it felt like magic. For myself, and for anyone wrestling with the same problems, here's how that journey unfolded.
At first, I had no idea what to tackle first. The Lighthouse report showed over 20 warnings: "Reduce JavaScript execution time", "Properly size images", "Eliminate render-blocking resources". Everything seemed important, but where should I begin?
I started with images. The hero image was a 3MB PNG. Not compressed, just the raw file the designer gave me. Converting it to WebP and resizing it brought it down to 200KB. The score jumped by 10 points. Now at 42.
Next was JavaScript. The bundle was 700KB. Libraries that weren't needed on the first screen were loading entirely. Chart libraries, animation libraries, even all of lodash that I wasn't using. Code splitting brought the initial bundle down to 200KB. The score shot up to the 60s.
But there was still a problem. The layout shifted while the page loaded. Images pushed text down as they appeared, and ad banners changed button positions. The exact frustrating experience I hated on other sites—I was creating it on mine.
At some point, I realized all these metrics were expressing the same philosophy. They turned user experience into measurable numbers.
It all boiled down to three questions. Users hate waiting (LCP), they tap unresponsive buttons repeatedly (INP), and shifting layouts while reading are infuriating (CLS). That's why Google made these three metrics Core Web Vitals. They factor into search rankings and are built into Chrome DevTools.
From that point, performance optimization wasn't just about raising scores. It became about removing friction from the user experience. Not chasing numbers, but improving people's lives.
LCP measures when the largest content element renders on screen. Usually, this is the hero image, banner, or the first paragraph of body text. Google's target is under 2.5 seconds.
I understood this through a restaurant analogy. Walk into a restaurant and the menu doesn't arrive for 5 minutes? You leave. Websites are the same. The first screen needs to load fast so users think, "Oh, this looks decent."
The biggest culprit is usually images. Don't use PNG or JPEG raw. Switch to WebP or AVIF to cut the size in half. I used Next.js's <Image> component, which automatically converts to WebP and resizes.
import Image from 'next/image';
function Hero() {
return (
<Image
src="/hero.png"
alt="Hero image"
width={1200}
height={600}
priority // Priority flag for LCP elements!
quality={85}
/>
);
}
The priority flag loads this image first. It skips lazy loading and automatically adds a preload tag. Essential for LCP elements.
No matter how optimized the images are, if the server takes 3 seconds to respond, it's useless. You need to reduce TTFB (Time to First Byte). Using Edge Functions on Vercel brought my TTFB from 200ms to 50ms. Properly using a CDN made a massive difference.
3. Inline Critical CSSOnly inline the CSS needed for the first screen in the <head>, and load the rest later. This eliminates waiting for CSS files.
<style>
/* Critical CSS - only what's needed for first screen */
.hero { font-size: 48px; margin-top: 100px; }
</style>
Next.js does this automatically, but if you're implementing it manually, libraries like critical can help.
INP (Interaction to Next Paint) replaced FID (First Input Delay) in 2024. It measures the time from when a user clicks, taps, or types until the browser actually responds. The target is under 200ms.
I understood this through an elevator button analogy. Press the button and nothing lights up, no response? People press it multiple times. Same with web pages. Click a button and nothing happens for 0.5 seconds? Users think "Is this broken?" and click again.
Long tasks blocking the main thread are the biggest issue. I used requestIdleCallback to defer non-urgent work until the browser is idle.
function heavyTask() {
// Heavy computation
for (let i = 0; i < 1000000; i++) {
// ...
}
}
// Defer non-urgent work
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
heavyTask();
});
} else {
setTimeout(heavyTask, 1);
}
2. Code Splitting and Dynamic Import
Don't load everything at once. Load only when needed. I converted heavy components like modals, charts, and editors to dynamic imports.
import dynamic from 'next/dynamic';
// Chart library is heavy - load only when needed
const Chart = dynamic(() => import('./Chart'), {
loading: () => <p>Loading chart...</p>,
ssr: false // Don't render on server
});
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Show Chart
</button>
{showChart && <Chart />}
</div>
);
}
The chart library doesn't load until the button is clicked. Initial bundle size drops significantly.
3. Debouncing and ThrottlingCalling an API on every keystroke in a search box clogs the main thread. Debouncing calls it only once after typing stops.
import { useState, useEffect } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
// Search only after typing stops for 500ms
const timer = setTimeout(() => {
if (query) {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}
}, 500);
return () => clearTimeout(timer);
}, [query]);
return (
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}
Search happens once after stopping, not on every keystroke. INP improves dramatically.
CLS (Cumulative Layout Shift) measures unexpected layout movements. The target is below 0.1. This is the most frustrating experience. Reading an article when suddenly an ad banner appears and pushes the text down. About to click a link when an image loads and the button moves.
I understood this through a subway handle analogy. If the handle suddenly shifts when the train starts? You fall. Same with web pages. When the layout changes at the moment a user tries to interact, the UX is completely ruined.
Before loading images, tell the browser how much space is needed. Always include width and height attributes.
// Bad
<img src="/photo.jpg" alt="Photo" />
// Good
<img src="/photo.jpg" alt="Photo" width="800" height="600" />
// Better (Next.js)
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
/>
Next.js's <Image> component automatically reserves space while maintaining aspect ratio.
Prevent text from flickering or jumping when custom fonts load (FOIT/FOUT). Using font-display: swap shows system fonts first, then swaps to custom fonts when loaded.
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* Key! */
}
In Next.js, using next/font optimizes this automatically.
import { Inter } from 'next/font/inter';
const inter = Inter({ subsets: ['latin'] });
export default function RootLayout({ children }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
3. Reserve Space for Ads and Embeds
Ads, social media embeds, and iframes are major CLS culprits because their size is unknown. At minimum, specify a container height to reserve space.
<div style={{ minHeight: '250px' }}>
{/* Ad script */}
</div>
To understand performance optimization, you need to know how browsers render screens. The Critical Rendering Path is the process from receiving HTML to painting pixels on screen.
The bottleneck here is render-blocking resources. The browser won't paint the screen until CSS and JavaScript finish loading. This needs to be reduced.
I understood this through a cooking analogy. Making pasta involves boiling water, making sauce, cooking pasta, and plating—in that order. Do things in parallel when possible, and sequentially when order matters. Same with web pages.
<head>, JavaScript at End of <body>
CSS is essential for CSSOM creation, so put it in <head>. But for JavaScript, use defer or async to avoid blocking HTML parsing.
<head>
<link rel="stylesheet" href="styles.css"> <!-- Load first -->
</head>
<body>
<div id="app"></div>
<script src="app.js" defer></script> <!-- Execute later -->
</body>
2. Preload and Prefetch
Preload critical resources. preload means "need this now", prefetch means "might need this later".
<link rel="preload" href="/hero.woff2" as="font" type="font/woff2" crossorigin>
<link rel="prefetch" href="/next-page.js">
Performance optimization isn't guesswork. It's a cycle of measuring, finding bottlenecks, fixing, and measuring again.
Built into Chrome DevTools, this measures performance. It scores Performance, Accessibility, Best Practices, and SEO. I ran Lighthouse with every deployment.
To measure real user data, use the Web Vitals library. You can send data to Google Analytics or Sentry.
import { onCLS, onINP, onLCP } from 'web-vitals';
function sendToAnalytics(metric) {
console.log(metric);
// Send to Google Analytics or Sentry
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
Browser's built-in API for direct timing measurements.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP candidate:', entry.startTime, entry);
}
});
observer.observe({ entryTypes: ['largest-contentful-paint'] });
Load images only when they enter the visible area after scrolling.
import { useEffect, useRef, useState } from 'react';
function LazyImage({ src, alt }) {
const [isVisible, setIsVisible] = useState(false);
const imgRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
});
});
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div ref={imgRef}>
{isVisible ? (
<img src={src} alt={alt} />
) : (
<div style={{ height: '300px', background: '#f0f0f0' }} />
)}
</div>
);
}
Using the Intersection Observer API, images load when they enter the viewport. Next.js's <Image> does this automatically.
Heavy components load only when needed.
// pages/dashboard.js
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
// Chart loads only on click
const Chart = dynamic(() => import('../components/Chart'), {
loading: () => <div>Loading chart...</div>,
});
// PDF viewer loads only on client
const PDFViewer = dynamic(() => import('../components/PDFViewer'), {
ssr: false,
});
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading...</div>}>
<Chart data={data} />
<PDFViewer file="/report.pdf" />
</Suspense>
</div>
);
}
This brought the initial bundle from 700KB down to 200KB.
Works offline, and second visits are lightning fast.
// public/sw.js
const CACHE_NAME = 'v1';
const urlsToCache = ['/', '/styles.css', '/app.js'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
// Return cache if available, otherwise network
return response || fetch(event.request);
})
);
});
In Next.js, the next-pwa plugin automatically generates a Service Worker.
Three weeks after starting performance optimization, my Lighthouse score went from 32 to 91. The bounce rate dropped from 67% to 34%. More than anything, I could feel my site was faster. It loads in under 3 seconds even on mobile.
Performance optimization never ends. Every new feature drops the score, and I have to fix it again. But now I know what to look for and how to fix it.
In the end, all of this is for the users. Fast sites get used more, shared more, and rank higher. Speed is a feature. Never forget that.