Sitemap
Aastik Mandil

Aastik Mandil

Software Engineer | React · Redux · Node.js Writing about frontend performance, full-stack systems & cloud ☁️

Web Vitals

10 min readJun 23, 2026

--

What Are Web Vitals?

Web Vitals is a set of standardised, user-centric performance metrics defined by Google that quantify the real-world experience of a webpage's loading speed, interactivity, and visual stability, directly influencing both user satisfaction and search engine rankings.

ℹ️ ️Core Web Vitals (CWV) — the three metrics that affect Google Search ranking: LCP (Largest Contentful Paint), INP (Interaction to Next Paint), and CLS (Cumulative Layout Shift). These are measured on real users via Chrome User Experience Report (CrUX).

Every Metric Explained

Each vital measures a different dimension of the user experience. Here’s a complete breakdown of all 8 metrics — their definition, how the browser calculates them, and what “good” looks like.

  1. LCP (Largest Contentful Paint): Time until the largest image or text block in the viewport is fully rendered. Measures perceived load speed.
    Good ≤ 2.5s
    NI ≤ 4s
    Poor > 4s
  2. INP (Interaction to Next Paint): Latency of the slowest interaction (click, tap, key) from first input to next frame painted and replaced FID in 2024.
    Good ≤ 200ms
    NI ≤ 500ms
    Poor > 500ms
  3. CLS (Cumulative Layout Shift): Sum of all unexpected layout shift scores. Each shift = impact fraction × distance fraction. Measures visual stability.
    Good ≤ 0.1
    NI ≤ 0.25s
    Poor > 0.25
  4. TTFB (Time to First Byte): Time from navigation start until the first byte of the HTTP response is received by the browser. Diagnoses server and network speed.
    Good ≤ 800ms
    NI ≤ 1.8s
    Poor > 1.8s
  5. FCP (First Contentful Paint): Time until the browser renders any content — text, image, SVG, or canvas. First signal that the page is actually loading.
    Good ≤ 1.8s
    NI ≤ 3s
    Poor > 3s
  6. TBT (Total Blocking Time): Sum of all blocking portions of long tasks (>50ms) between FCP and TTI. Each task’s blocking time = duration − 50ms.
    Good ≤ 200ms
    NI ≤ 600ms
    Poor > 600ms
  7. FID (First Input Delay (deprecated)): Delay before the browser could process the user’s first interaction. Replaced by INP in March 2024. Still measured in some tools.
    Good ≤ 100ms
    NI ≤ 300ms
    Poor > 300ms
  8. SI (Speed Index): How quickly content is visually populated during page load. Calculated from video frame analysis. Lower = more content visible sooner.
    Good ≤ 3.4s
    NI ≤ 5.8s
    Poor > 5.8s

How the Browser Calculates Each Metric

LCP uses PerformanceObserver with largest-contentful-paint entries. The browser continuously tracks the largest image, video poster, or block of text in the viewport and records a new entry each time a larger element is painted. The final LCP value is reported when the user interacts or the tab becomes hidden.
INP was introduced to capture the full distribution of interaction latency, not just the first one (FID’s flaw). It measures every click, tap, and key event from input to next paint — and reports the worst one (excluding outliers above the 98th percentile). This means one bad interaction tanks your INP score.
CLS calculates a layout shift score for each unexpected shift: impact_fraction × distance_fraction. Impact fraction is the percentage of the viewport affected by the shift. Distance fraction is how far the shifted element moved as a percentage of the viewport height. It uses session windows (max 5s, max 1s gap) and reports the worst window.
TTFB comes directly from the Navigation Timing API: responseStart − navigationStart. It includes DNS, TCP, TLS handshake, and server processing time — making it a key indicator of backend performance.
TBT is a lab metric (Lighthouse only). Any main-thread task longer than 50ms is a “long task.” TBT accumulates taskDuration − 50ms for each long task between FCP and Time to Interactive. It’s the closest lab proxy for INP.

The Page Load Timeline

Every Web Vital maps to a specific moment in the page load journey. Here’s how they relate on a typical page load timeline.

Press enter or click to view image in full size

Measuring Web Vitals in JavaScript

  1. Using the web-vitals library: Google’s official web-vitals npm package is the easiest and most accurate way to collect all metrics in production. It matches exactly what Chrome reports to the CrUX dataset.
// measure-vitals.js


// npm install web-vitals
import { onLCP, onINP, onCLS, onFCP, onTTFB, onFID } from 'web-vitals';

// ── Send to your analytics endpoint ──────────────────
function sendToAnalytics(metric) {
const { name, value, rating, delta, id, navigationType } = metric;

// rating: 'good' | 'needs-improvement' | 'poor'
console.log(`[${name}] ${value.toFixed(2)} - ${rating}`);

// Send to Google Analytics 4
gtag('event', name, {
event_category: 'Web Vitals',
event_label: id, // unique per page load
value: Math.round(name === 'CLS' ? value * 1000 : value),
non_interaction: true,
metric_rating: rating,
metric_delta: delta, // change since last report
navigation_type: navigationType,
});
}

// ── Register all metric observers ────────────────────
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
onFID(sendToAnalytics); // deprecated but still in some tools

2. Raw PerformanceObserver APIs:

// raw-observers.js


// ── 1. Largest Contentful Paint ───────────────────────
let lcpValue = 0;
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
lcpValue = last.startTime;
console.log('LCP candidate:', lcpValue.toFixed(0), 'ms');
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

// Finalise LCP on user interaction or page hide
const finaliseLCP = () => {
lcpObserver.disconnect();
console.log('Final LCP:', lcpValue.toFixed(0), 'ms');
};
document.addEventListener('keydown', finaliseLCP, { once: true });
document.addEventListener('click', finaliseLCP, { once: true });

// ── 2. Cumulative Layout Shift ────────────────────────
let clsScore = 0, sessionValue = 0, sessionEntries = [], lastEntry;

new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (!entry.hadRecentInput) {
// Session window: max 5s duration, max 1s gap
if (lastEntry && (entry.startTime - lastEntry.startTime < 1000)
&& (entry.startTime - sessionEntries[0].startTime < 5000)) {
sessionValue += entry.value;
sessionEntries.push(entry);
} else {
sessionValue = entry.value;
sessionEntries = [entry];
}
if (sessionValue > clsScore) clsScore = sessionValue;
lastEntry = entry;
}
});
console.log('CLS:', clsScore.toFixed(4));
}).observe({ type: 'layout-shift', buffered: true });

// ── 3. Interaction to Next Paint ──────────────────────
let maxINP = 0;
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
// INP = processingStart − startTime + (nextPaintTime − processingEnd)
const duration = entry.duration;
if (duration > maxINP) {
maxINP = duration;
console.log('New INP candidate:', maxINP.toFixed(0), 'ms',
'- target:', entry.target);
}
});
}).observe({ type: 'event', durationThreshold: 16, buffered: true });

// ── 4. Time to First Byte ─────────────────────────────
const nav = performance.getEntriesByType('navigation')[0];
const ttfb = nav.responseStart - nav.requestStart;
console.log('TTFB:', ttfb.toFixed(0), 'ms');

// ── 5. First Contentful Paint ─────────────────────────
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
if (entry.name === 'first-contentful-paint') {
console.log('FCP:', entry.startTime.toFixed(0), 'ms');
}
});
}).observe({ type: 'paint', buffered: true });
// ── 6. Long Tasks (TBT building blocks) ───────────────
let tbt = 0;
new PerformanceObserver((list) => {
list.getEntries().forEach(entry => {
tbt += entry.duration - 50; // blocking time = duration beyond 50ms
console.log('Long task:', entry.duration.toFixed(0), 'ms | TBT so far:', tbt.toFixed(0));
});
}).observe({ type: 'longtask', buffered: true });

3. Real-World Dashboard Reporter

// vitals-reporter.js


// Batched beacon reporter — sends all metrics on page hide
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

const metrics = {};

function collect(metric) {
metrics[metric.name] = {
value: metric.value,
rating: metric.rating,
delta: metric.delta,
};
}

onLCP(collect); onINP(collect);
onCLS(collect); onFCP(collect); onTTFB(collect);

// Send all metrics at once when user leaves the page
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/analytics/vitals', JSON.stringify({
url: location.href,
timestamp: Date.now(),
metrics,
connection: navigator?.connection?.effectiveType,
deviceMemory: navigator?.deviceMemory,
}));
}
});

Applications Across Industries

  • E-commerce: LCP directly affects add-to-cart rates. Every 100ms of LCP delay can cost conversions at scale.
  • News & Media: CLS improvements prevent accidental ad clicks and layout jumps that frustrate readers.
  • SaaS / Web Apps: INP is critical — dashboards, editors, and data tools must respond to interactions instantly.
  • Travel & Booking: TTFB improvements from CDN and server-side caching directly cut time-to-quote-visible.
  • Mobile-First Markets: TBT reduction is essential on low-powered Android devices, where JS execution is 3–5× slower than on desktop.
  • SEO-driven traffic: Passing the Core Web Vitals “Good” threshold gives a ranking signal boost across all queries.

Tools, Libraries & Workflows

Web Vitals are measured in two fundamentally different contexts — Lab (controlled, simulated, reproducible) and Field (real users, real devices, real networks). Both are essential.

ℹ️ Lab vs Field: Lab tools (Lighthouse, WebPageTest) run on a controlled machine with a throttled connection, fast to iterate but not representative. Field tools (CrUX, web-vitals.js) measure actual user sessions, accurate but slower to collect. Google’s ranking uses field data (75th percentile from CrUX).

Get Aastik Mandil’s stories in your inbox

Join Medium for free to get updates from this writer.

Lab Tools

  1. 🔦 Lighthouse: Built into Chrome DevTools and available as a CLI/Node module. Measures LCP, CLS, TBT, FCP, SI, TTFB. Generates a 0–100 performance score. The gold standard for lab audits.
  2. 🚀 PageSpeed Insights: Google’s free web interface. Shows both Lab (Lighthouse) AND Field (CrUX) data side-by-side for any URL. The easiest way to check your real-world score.
  3. 🌐 WebPageTest: Test from real browsers worldwide, with filmstrip view, waterfall charts, and video comparison. Best for diagnosing TTFB and render-blocking resources at real geographic locations.
  4. 🛠 Chrome DevTools: Performance panel shows long tasks, layout shifts (red bars), and LCP candidates with overlays. The Elements panel highlights CLS-causing elements. Essential for debugging INP.

Field Tools (Real User Monitoring)

  1. 📊 CrUX Dashboard (Looker Studio): Google’s Chrome User Experience Report dataset. Shows 28-day rolling field data for any URL or origin. Free. The same data is used in Google Search rankings.
  2. 🔎 Core Web Vitals Report: Groups your pages into Good / Needs Improvement / Poor clusters based on CrUX data. Best for prioritising which page templates to fix first.
  3. 📦 web-vitals.js: Google’s official library. Tiny (<2.5kB gzip), accurate, matches CrUX methodology exactly. Integrates with any analytics backend. The right choice for custom RUM.
  4. ⚡ Datadog / Sentry / Vercel Speed: Full Real User Monitoring platforms with per-route breakdowns, session replay, and alerting. Best for engineering teams who need production observability at scale.

Setting Up Automated CI Monitoring

# .github/workflows/lighthouse-ci.yml
# Runs Lighthouse CI on every PR - fails if vitals regress

name: Lighthouse CI
on: [pull_request]

jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v11
with:
urls: |
http://localhost:3000/
http://localhost:3000/products
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true

// lighthouse-budget.json
[{
"path": "/*",
"timings": [
{ "metric": "first-contentful-paint", "budget": 1800 },
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "total-blocking-time", "budget": 200 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 },
{ "metric": "interactive", "budget": 3500 }
]
}]

Actionable Optimisations, Metric by Metric

LCP — Largest Contentful Paint

  1. Preload the LCP resource: Add <link rel="preload" as="image" href="hero.webp" fetchpriority="high"> to the <head>. This is the single biggest LCP win for image-heavy pages.
  2. Use modern image formats (WebP / AVIF): AVIF is 50% smaller than JPEG at the same quality. Use <picture> with <source type="image/avif"> for progressive enhancement.
  3. Serve from a CDN close to the user: Reduce TTFB first — LCP can’t improve if the HTML itself arrives late. Use Cloudflare, AWS CloudFront, or Fastly with proper cache headers.
  4. Remove render-blocking resources: Add defer or async to non-critical JS. Move large CSS to inline critical styles + async-loaded non-critical CSS.
  5. Never lazy-load the LCP element:loading="lazy" on your hero image defers its load — the opposite of what you want. Only lazy-load below-the-fold images.

INP — Interaction to Next Paint

  1. Break up long tasks with scheduler.yield(): Yield control back to the browser mid-computation so input events can be processed. Use await scheduler.yield() or setTimeout(0) between chunks.
  2. Avoid heavy event handlers: Keep click/keydown handlers under 50ms. Move expensive work to requestIdleCallback or a Web Worker. Use debounce/throttle on scroll and resize.
  3. Reduce JavaScript execution time: Code-split aggressively. Use dynamic imports for route-level chunks. Remove unused polyfills. Every KB of JS that doesn’t execute = less blocking time.
  4. Minimise DOM size: Large DOMs (>1500 nodes) slow style recalculation triggered by interactions. Virtualise long lists with react-window or @tanstack/virtual.

CLS — Cumulative Layout Shift

  1. Always specify width and height on images and videos: Set explicit width and height attributes — the browser reserves space before the image loads. Use aspect-ratio in CSS as an alternative.
  2. Reserve space for ads and embeds: Ad slots that arrive empty then expand are the #1 cause of CLS. Set a min-height on ad containers equal to the expected ad size.
  3. Avoid inserting content above existing content: Cookie banners, newsletter pop-ins, and dynamic notifications that push content down are classic CLS culprits. Use overlay/fixed positioning instead.
  4. Preload and subset web fonts: Font swap causes FOUT (Flash of Unstyled Text) which shifts text. Use font-display: optional or preload fonts. Subset fonts to reduce load time.

TTFB — Time to First Byte

  1. Use a CDN + edge caching: Serve HTML from the edge closest to the user. Cloudflare Pages, Vercel Edge, or AWS Lambda@Edge can reduce TTFB from 800ms to under 100ms globally.
  2. Enable HTTP/3 and early hints: HTTP/3 (QUIC) eliminates head-of-line blocking. Early Hints (103 status) lets the browser preload critical resources while the server prepares the HTML.
  3. Cache server responses aggressively: Use Redis or Memcached for DB query results. Implement ISR (Incremental Static Regeneration) with Next.js. Serve stale-while-revalidate for non-personalised pages.

Summary

<!-- html-quick-wins.html -->


<!-- ✓ LCP: Preload hero image with high priority -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">

<!-- ✓ LCP: Never lazy-load the hero -->
<img src="/hero.avif" width="1200" height="630" fetchpriority="high" alt="Hero">

<!-- ✓ CLS: Reserve space for below-fold images -->
<img src="/product.jpg" width="400" height="400" loading="lazy" alt="Product">

<!-- ✓ TTFB: Early Hints for critical CSS + font -->
<!-- Server sends 103 before 200 response: -->
<!-- Link: </style.css>; rel=preload; as=style -->

<!-- ✓ TBT/INP: Defer non-critical JS -->
<script src="/analytics.js" defer></script>
<script src="/chat-widget.js" async></script>

<!-- ✓ CLS: Font display to avoid FOUT shifts -->
<style>
@font-face {
font-family: 'MyFont';
src: url(https://medium.com/'/font.woff2') format('woff2');
font-display: optional; /* no swap, no shift */
}
</style>

✅ Prioritisation order: Fix TTFB first (server/CDN) → then LCP (resource loading) → then CLS (layout stability) → then INP (JS execution). TTFB improvements cascade into better LCP and FCP automatically.

--

--

Written by Aastik Mandil

0 followers

Software Engineer | React · Redux · Node.js Writing about frontend performance, full-stack systems & cloud ☁️