Optimizing JavaScript for Core Web Vitals Guide
Optimize JavaScript for Core Web Vitals. Covers reducing LCP with critical path optimization, minimizing CLS from dynamic content, lowering INP with input debouncing, long task splitting, third-party script management, and real user monitoring.
Core Web Vitals measure real user experience. JavaScript directly impacts all three metrics: Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), and Interaction to Next Paint (INP). This guide shows how to optimize each.
For profiling tools, see JavaScript Profiling: Advanced Performance Guide.
Core Web Vitals Overview
// Measure all Core Web Vitals with web-vitals library
import { onLCP, onCLS, onINP } from "web-vitals";
function sendToAnalytics(metric) {
const body = {
name: metric.name,
value: metric.value,
rating: metric.rating, // "good", "needs-improvement", "poor"
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
url: location.href,
};
// Use sendBeacon for reliable delivery
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", JSON.stringify(body));
} else {
fetch("/api/vitals", {
method: "POST",
body: JSON.stringify(body),
keepalive: true,
});
}
}
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);| Metric | Good | Needs Improvement | Poor | What It Measures |
|---|---|---|---|---|
| LCP | < 2.5s | 2.5s - 4.0s | > 4.0s | Loading performance |
| CLS | < 0.1 | 0.1 - 0.25 | > 0.25 | Visual stability |
| INP | < 200ms | 200ms - 500ms | > 500ms | Interaction responsiveness |
Optimizing LCP (Largest Contentful Paint)
// PROBLEM: JavaScript blocking LCP element render
// Large JS bundles delay parsing and execution
// SOLUTION 1: Defer non-critical JavaScript
// <script defer src="analytics.js"></script>
// <script defer src="chat-widget.js"></script>
// SOLUTION 2: Preload critical resources
function injectPreloads() {
const criticalResources = [
{ href: "/fonts/inter.woff2", as: "font", type: "font/woff2" },
{ href: "/hero-image.webp", as: "image" },
{ href: "/api/above-fold-data", as: "fetch" },
];
criticalResources.forEach(({ href, as, type }) => {
const link = document.createElement("link");
link.rel = "preload";
link.href = href;
link.as = as;
if (type) link.type = type;
if (as === "font") link.crossOrigin = "anonymous";
document.head.appendChild(link);
});
}
// SOLUTION 3: Inline critical JavaScript
// Extract only the JS needed for above-fold content
function renderHeroFast(data) {
const hero = document.getElementById("hero");
hero.innerHTML = `
<h1>${data.title}</h1>
<p>${data.subtitle}</p>
<img
src="${data.image}"
fetchpriority="high"
width="1200"
height="600"
alt="${data.title}"
>
`;
}
// SOLUTION 4: Optimize server response time
// Use streaming for faster first byte
async function streamResponse(req, res) {
res.setHeader("Content-Type", "text/html");
// Send head immediately
res.write("<!DOCTYPE html><html><head>");
res.write('<link rel="stylesheet" href="/critical.css">');
res.write("</head><body>");
// Stream above-fold content
const heroData = await getHeroData();
res.write(`<div id="hero"><h1>${heroData.title}</h1></div>`);
// Flush to browser (starts rendering)
res.flush();
// Continue with rest of page
const mainContent = await getMainContent();
res.write(`<main>${mainContent}</main>`);
res.end("</body></html>");
}Optimizing CLS (Cumulative Layout Shift)
// PROBLEM: Dynamic content insertion causes layout shifts
// SOLUTION 1: Reserve space for dynamic content
class LayoutStabilizer {
reserveSpace(container, expectedHeight) {
container.style.minHeight = `${expectedHeight}px`;
container.style.contain = "layout";
return {
release() {
container.style.minHeight = "";
container.style.contain = "";
},
};
}
// Reserve space for ads
stabilizeAds() {
document.querySelectorAll("[data-ad-slot]").forEach((slot) => {
const width = slot.dataset.adWidth || "300";
const height = slot.dataset.adHeight || "250";
slot.style.width = `${width}px`;
slot.style.height = `${height}px`;
slot.style.contain = "strict";
slot.style.contentVisibility = "auto";
});
}
// Handle dynamic font loading
stabilizeFonts() {
if ("fonts" in document) {
document.fonts.ready.then(() => {
document.documentElement.classList.add("fonts-loaded");
});
}
// CSS: use font-display: swap with size-adjust
// @font-face {
// font-family: 'CustomFont';
// src: url('font.woff2') format('woff2');
// font-display: swap;
// size-adjust: 105%; // Match fallback font metrics
// }
}
}
// SOLUTION 2: Batch DOM updates to single frame
function batchDOMUpdates(updates) {
// Collect all changes
const changes = updates.map((fn) => fn);
// Apply in a single animation frame
requestAnimationFrame(() => {
changes.forEach((change) => change());
});
}
// SOLUTION 3: Use CSS containment for dynamic sections
function stabilizeDynamicSection(selector) {
const section = document.querySelector(selector);
const rect = section.getBoundingClientRect();
section.style.contain = "layout size";
section.style.width = `${rect.width}px`;
section.style.height = `${rect.height}px`;
return {
update(newContent) {
requestAnimationFrame(() => {
section.innerHTML = newContent;
// Re-measure and update containment
const newRect = section.getBoundingClientRect();
section.style.height = `${newRect.height}px`;
});
},
};
}Optimizing INP (Interaction to Next Paint)
// PROBLEM: Long tasks block the main thread, making interactions slow
// SOLUTION 1: Break long tasks with yield points
async function processLargeDataset(items) {
const CHUNK_SIZE = 50;
const results = [];
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
// Process chunk
for (const item of chunk) {
results.push(transform(item));
}
// Yield to main thread between chunks
if (i + CHUNK_SIZE < items.length) {
await yieldToMain();
}
}
return results;
}
function yieldToMain() {
return new Promise((resolve) => {
if ("scheduler" in globalThis && "yield" in scheduler) {
scheduler.yield().then(resolve);
} else {
setTimeout(resolve, 0);
}
});
}
// SOLUTION 2: Use requestIdleCallback for non-urgent work
function scheduleNonUrgentWork(tasks) {
let index = 0;
function processNext(deadline) {
while (index < tasks.length && deadline.timeRemaining() > 5) {
tasks[index]();
index++;
}
if (index < tasks.length) {
requestIdleCallback(processNext);
}
}
requestIdleCallback(processNext);
}
// SOLUTION 3: Debounce expensive event handlers
function optimizeInteractions() {
const searchInput = document.getElementById("search");
// BAD: Filter on every keystroke
// searchInput.addEventListener("input", () => filterResults(searchInput.value));
// GOOD: Debounce with visual feedback
let debounceTimer;
searchInput.addEventListener("input", (e) => {
// Immediate visual feedback (fast, no layout)
searchInput.classList.add("searching");
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
filterResults(e.target.value);
searchInput.classList.remove("searching");
}, 150);
});
}
// SOLUTION 4: Move computation to Web Workers
class WorkerOptimizer {
constructor(workerUrl) {
this.worker = new Worker(workerUrl);
this.pending = new Map();
this.id = 0;
this.worker.onmessage = (e) => {
const { id, result } = e.data;
const resolve = this.pending.get(id);
if (resolve) {
resolve(result);
this.pending.delete(id);
}
};
}
compute(task, data) {
return new Promise((resolve) => {
const id = this.id++;
this.pending.set(id, resolve);
this.worker.postMessage({ id, task, data });
});
}
}
// Worker file (compute-worker.js)
// self.onmessage = (e) => {
// const { id, task, data } = e.data;
// let result;
// switch(task) {
// case "sort": result = data.sort((a,b) => a-b); break;
// case "filter": result = heavyFilter(data); break;
// }
// self.postMessage({ id, result });
// };Third-Party Script Management
class ThirdPartyManager {
constructor() {
this.scripts = new Map();
this.loaded = new Set();
}
register(name, config) {
this.scripts.set(name, {
src: config.src,
priority: config.priority || "low",
loadOn: config.loadOn || "idle",
async: config.async !== false,
});
}
loadAll() {
const high = [];
const medium = [];
const low = [];
for (const [name, config] of this.scripts) {
switch (config.priority) {
case "high": high.push(name); break;
case "medium": medium.push(name); break;
default: low.push(name);
}
}
// High priority: load immediately (analytics)
high.forEach((name) => this.loadScript(name));
// Medium priority: after page load
window.addEventListener("load", () => {
medium.forEach((name) => this.loadScript(name));
}, { once: true });
// Low priority: during idle time
if ("requestIdleCallback" in window) {
requestIdleCallback(() => {
low.forEach((name) => this.loadScript(name));
});
} else {
setTimeout(() => {
low.forEach((name) => this.loadScript(name));
}, 3000);
}
}
loadScript(name) {
if (this.loaded.has(name)) return;
const config = this.scripts.get(name);
if (!config) return;
const script = document.createElement("script");
script.src = config.src;
script.async = config.async;
document.body.appendChild(script);
this.loaded.add(name);
}
}
// Usage
const thirdParty = new ThirdPartyManager();
thirdParty.register("analytics", {
src: "https://analytics.example.com/v2.js",
priority: "high",
});
thirdParty.register("chat", {
src: "https://chat-widget.example.com/chat.js",
priority: "low",
loadOn: "idle",
});
thirdParty.register("tracking", {
src: "https://tracking.example.com/pixel.js",
priority: "medium",
});
thirdParty.loadAll();Real User Monitoring Dashboard
class WebVitalsMonitor {
constructor() {
this.metrics = { LCP: [], CLS: [], INP: [] };
this.sessionStart = Date.now();
}
record(name, value, rating) {
this.metrics[name].push({
value,
rating,
timestamp: Date.now() - this.sessionStart,
url: location.pathname,
});
}
getReport() {
const report = {};
for (const [name, entries] of Object.entries(this.metrics)) {
if (entries.length === 0) continue;
const values = entries.map((e) => e.value);
values.sort((a, b) => a - b);
report[name] = {
p75: values[Math.floor(values.length * 0.75)],
median: values[Math.floor(values.length * 0.5)],
good: entries.filter((e) => e.rating === "good").length,
poor: entries.filter((e) => e.rating === "poor").length,
total: entries.length,
};
}
return report;
}
}| Optimization | Affects | Impact | Difficulty |
|---|---|---|---|
| Defer non-critical JS | LCP | High | Easy |
| Preload LCP resources | LCP | High | Easy |
| Reserve space for dynamic content | CLS | High | Medium |
| Break long tasks with yield | INP | High | Medium |
| Move work to Web Workers | INP | Very High | Hard |
| Lazy load third-party scripts | LCP, INP | High | Easy |
| Use CSS containment | CLS | Medium | Easy |
| Debounce expensive handlers | INP | Medium | Easy |
Rune AI
Key Insights
- Defer non-critical JavaScript to improve LCP: Use script defer/async and preload critical resources to unblock the browser from rendering the largest content element
- Reserve space for dynamic content to prevent CLS: Set explicit dimensions on ad slots, images, and lazy-loaded containers using CSS containment before content loads
- Break long tasks under 50ms to improve INP: Use scheduler.yield() or setTimeout(0) to yield the main thread between chunks of work, keeping interactions responsive
- Move heavy computation to Web Workers: Sort, filter, and transform operations should run off the main thread to prevent blocking user interactions
- Manage third-party scripts by priority: Load analytics immediately, load social widgets after page load, and load non-essential scripts during idle time to limit their Web Vitals impact
Frequently Asked Questions
What is the difference between FID and INP?
How does JavaScript affect LCP?
Why do third-party scripts disproportionately affect Web Vitals?
How do I optimize INP for complex form interactions?
What is a good target for each Core Web Vital?
Conclusion
Every Core Web Vital is directly impacted by JavaScript. Defer and split bundles for LCP. Reserve space and batch DOM updates for CLS. Break long tasks and use Web Workers for INP. Monitor with real user data to verify improvements. For profiling workflows, see JavaScript Profiling: Advanced Performance Guide. For code splitting techniques, see JS Code Splitting: Advanced Performance Guide.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.