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.

JavaScriptadvanced
17 min read

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

javascriptjavascript
// 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);
MetricGoodNeeds ImprovementPoorWhat It Measures
LCP< 2.5s2.5s - 4.0s> 4.0sLoading performance
CLS< 0.10.1 - 0.25> 0.25Visual stability
INP< 200ms200ms - 500ms> 500msInteraction responsiveness

Optimizing LCP (Largest Contentful Paint)

javascriptjavascript
// 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)

javascriptjavascript
// 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)

javascriptjavascript
// 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

javascriptjavascript
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

javascriptjavascript
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;
  }
}
OptimizationAffectsImpactDifficulty
Defer non-critical JSLCPHighEasy
Preload LCP resourcesLCPHighEasy
Reserve space for dynamic contentCLSHighMedium
Break long tasks with yieldINPHighMedium
Move work to Web WorkersINPVery HighHard
Lazy load third-party scriptsLCP, INPHighEasy
Use CSS containmentCLSMediumEasy
Debounce expensive handlersINPMediumEasy
Rune AI

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
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between FID and INP?

FID (First Input Delay) measured only the delay of the first interaction. INP (Interaction to Next Paint) replaced FID in March 2024 and measures the responsiveness of all interactions throughout the page lifecycle, reporting the worst interaction (at the 98th percentile). INP is a stricter metric that catches slow interactions users encounter after initial page load.

How does JavaScript affect LCP?

JavaScript affects LCP in two ways: render-blocking scripts delay the browser from parsing HTML and rendering content, and JavaScript that dynamically inserts the LCP element (hero image, heading) delays its appearance. Minimize render-blocking JS with defer/async, inline critical JS, and ensure the LCP element is in the initial HTML rather than injected by JavaScript.

Why do third-party scripts disproportionately affect Web Vitals?

Third-party scripts (analytics, ads, chat widgets) often load synchronously, compete for main thread time, insert content causing layout shifts, and are outside your control for optimization. They can add 1-3 seconds to LCP and cause CLS scores above 0.25. Managing them with priority-based lazy loading limits their impact.

How do I optimize INP for complex form interactions?

Break validation and computation into async chunks using `scheduler.yield()` or `setTimeout(0)`. Provide immediate visual feedback (CSS class toggle) before running expensive logic. Move heavy computation (data transformation, search filtering) to Web Workers. Debounce rapid sequential inputs with 100-200ms delays.

What is a good target for each Core Web Vital?

im for LCP under 2.0 seconds (not just 2.5s), CLS under 0.05 (not just 0.1), and INP under 150ms (not just 200ms). These targets put you well within the "good" range and provide buffer. Measure at the 75th percentile of real user data, not just lab tests, since real-world conditions are harder.

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.