Core Web Vitals 2026: Fix Interaction to Next Paint
SitePoint

Core Web Vitals 2026: Fix Interaction to Next Paint

Why INP Is the Core Web Vital You Can't Ignore in 2026

Interaction to Next Paint (INP) remains the most punishing Core Web Vital for most websites heading into 2026. According to Chrome UX Report data, roughly 40% of origins on mobile still fail to meet INP thresholds (check the live CrUX dashboard for current numbers), making it a persistent ranking liability that lowers user experience scores.

This is not a theoretical problem. Sites with poor INP see higher bounce rates and lower engagement, including shorter session durations and fewer clicks per session.

INP officially replaced First Input Delay (FID) as the responsiveness metric in March 2024. FID was easy to pass. INP is not. It captures the full cost of every interaction on a page, not just the first click. That shift exposed latency that was always there but never measured.

This tutorial walks through the concrete techniques needed to diagnose and fix INP issues: breaking long tasks with scheduler.yield(), optimizing event handlers, reducing presentation delay, and taming third-party scripts. Each section includes working code examples targeting a JavaScript-focused tech stack. At the end, a complete optimization checklist provides a reusable reference for ongoing performance work.

What Is Interaction to Next Paint (and Why Did It Replace FID)?

How INP Differs from FID

FID only measured how long the browser waited before processing the first interaction on a page. It timed the gap before the browser began processing the first click, tap, or keypress. It told you nothing about what happened after that, and it ignored every subsequent interaction entirely.

INP takes a fundamentally different approach. It measures the full lifecycle of interactions, covering input delay (the wait before the handler runs), processing time (the handler itself), and presentation delay (the time from handler completion to the next frame being painted).

Critically, INP reports the single worst interaction latency for sessions with 50 or fewer interactions; for sessions exceeding 50 interactions, it uses approximately the 98th percentile value to exclude extreme outliers. For pages with complex filtering, form validation, or dynamic UI updates, this makes INP a far harder metric to pass and a far more honest reflection of what users actually experience.

INP Thresholds: Good, Needs Improvement, Poor

Google defines three INP threshold bands:

  • Good: 200 milliseconds or less
  • Needs Improvement: between 200 and 500 milliseconds
  • Poor: greater than 500 milliseconds

These are evaluated at the 75th percentile of page loads in the Chrome UX Report, meaning 75% of a site's user sessions must report an INP of 200ms or less for the origin to be classified as "good." This percentile-based approach prevents sites from hiding behind median performance while a quarter of users suffer.

Diagnosing INP Issues: Where to Start

Measuring INP in the Field

The most reliable INP data comes from real users. PageSpeed Insights surfaces origin-level and URL-level INP data from CrUX, collected from opted-in Chrome users over a rolling 28-day window. This is the same data Google uses for ranking signals. Note that CrUX requires a sufficient volume of real-user traffic; low-traffic origins may not have enough data to populate INP scores.

For granular, per-interaction analysis, the web-vitals JavaScript library provides real-user monitoring (RUM) with full attribution. The attribution build identifies the exact element, event type, and handler responsible for poor scores. Requires web-vitals v3.0.0 or later. Install via npm (npm install web-vitals@^3) or load via CDN using the package's ESM bundle. The attribution fields available differ between v3 and v4; the snippet below is compatible with both.

import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  const attr = metric.attribution;
  console.log({
    inp: metric.value,
    interactionTarget: attr.interactionTarget,
    interactionType: attr.interactionType,
    // v3: longestInteractionEntry | v4+: longAnimationFrameEntries
    longestEntry: attr.longestInteractionEntry ?? attr.longAnimationFrameEntries ?? null,
  });
}, { reportAllChanges: true });

This logs every INP candidate to the console, including which DOM element triggered it and what event type was involved. In production, replace console.log with a beacon to an analytics endpoint. The longAnimationFrameEntries field is available in web-vitals v4+ and populated only in Chrome 123+ via the Long Animation Frames API. In web-vitals v3, use longestInteractionEntry instead. In browsers that support neither, these fields will be undefined.

Profiling INP in the Lab

Chrome DevTools provides lab-based profiling that complements field data. The Performance panel includes an "Interactions" track that visualizes each discrete user interaction as a horizontal bar, with its total duration broken down into input delay, processing time, and presentation delay.

Recent Chrome DevTools versions (approximately Chrome 124+) annotate the specific interaction that would be reported as INP, making it straightforward to identify the worst offender during a profiling session.

The workflow: open DevTools, navigate to the Performance panel, click Record, perform the interactions suspected of causing poor INP, then stop the recording. The Interactions track highlights each one. Clicking an interaction reveals the associated long task in the flame chart. Lighthouse Timespan mode also supports interaction auditing, allowing testers to record a sequence of actions and receive INP-specific diagnostics.

Common Culprits at a Glance

The most frequent sources of poor INP fall into four categories:

  • Heavy event handlers - things like complex form validation, client-side filtering, or large-scale DOM mutations triggered by user actions - remain the single largest contributor.
  • Third-party scripts that block the main thread during or immediately after an interaction follow close behind.
  • Layout thrashing: JavaScript reads and writes to the DOM in alternating sequence, forcing the browser to recalculate layout repeatedly.
  • A large DOM amplifies rendering cost, making the presentation delay phase of INP disproportionately expensive even for otherwise simple handlers.

Fix 1: Break Up Long Tasks with scheduler.yield()

Why Long Tasks Kill INP

The browser's main thread is single-threaded. The browser classifies any task longer than 50ms as a "long task," and long tasks block the browser from processing pending user interactions and painting frames. Suppose a 300ms JavaScript task is running when a user clicks a button. The browser cannot respond until that task completes. The entire 300ms becomes input delay, directly inflating INP.

Yielding back to the main thread between chunks of work gives the browser the opportunity to process pending interactions and render the next frame.

Using the Scheduler API (with Fallback)

The scheduler.yield() API provides a way to explicitly yield to the main thread and schedule its continuation at "user-visible" priority, allowing the browser to process pending rendering tasks before resuming, unlike setTimeout(resolve, 0), which enqueues the continuation at a lower default macrotask priority.

As of 2025, scheduler.yield() is supported in Chromium-based browsers (Chrome 115+, Edge 115+) but not in Firefox or Safari; the fallback executes for all non-Chromium traffic.

async function yieldToMain() {
  if (globalThis.scheduler?.yield) {
    try {
      return await scheduler.yield();
    } catch {
      // scheduler.yield() rejected (e.g., AbortSignal cancellation); fall through
    }
  }
  return new Promise((resolve) => {
    setTimeout(resolve, 0);
  });
}

This utility uses scheduler.yield() when available and falls back to setTimeout(resolve, 0) otherwise. The try/catch ensures that if scheduler.yield() rejects (for example, due to future AbortSignal support), the function still resolves via the setTimeout fallback rather than propagating an unhandled rejection. The fallback is imperfect since setTimeout does not guarantee priority re-queuing, but it still breaks the long task and allows the browser to paint.

Here is a before/after comparison demonstrating how to chunk a synchronous loop. The chunked version uses time-based yielding to ensure each chunk stays under the 50ms long-task threshold regardless of how expensive individual items are to render:

// BEFORE: one long task processing 500 items - blocks main thread
// (also: repeated getElementById calls add unnecessary per-iteration overhead)
function renderListSync(items) {
  const container = document.getElementById('container');
  if (!container) {
    console.error('[renderListSync] #container not found in DOM');
    return;
  }
  items.forEach((item) => {
    const el = document.createElement('div');
    el.textContent = item.label; // Safe: textContent; do not replace with innerHTML
    el.className = 'list-item';
    container.appendChild(el);
  });
}

// AFTER: chunked with yieldToMain() using a time-based budget
async function renderListChunked(items) {
  const container = document.getElementById('container');
  if (!container) {
    console.error('[renderListChunked] #container not found in DOM');
    return;
  }
  const CHUNK_BUDGET_MS = 40; // stay under 50ms long-task threshold
  let chunkStart = performance.now();
  for (let i = 0; i < items.length; i++) {
    const el = document.createElement('div');
    el.textContent = items[i].label;
    el.className = 'list-item';
    container.appendChild(el);
    if (performance.now() - chunkStart >= CHUNK_BUDGET_MS) {
      await yieldToMain();
      chunkStart = performance.now();
    }
  }
}

The synchronous version processes all 500 items in a single task. The chunked version yields whenever the elapsed time within the current chunk approaches 40ms, ensuring each chunk stays comfortably under the 50ms long-task threshold regardless of per-item rendering cost. Each yield point gives the browser a window to handle pending interactions and paint.

Fix 2: Optimize Event Handlers and Reduce Processing Time

Debounce for Inputs, Not Clicks

Debouncing delays execution until rapid-fire events stop, making it effective for search inputs where processing should only happen after the user pauses typing. Throttling limits execution to a fixed interval, making it suitable for scroll or resize-driven UI updates where some responsiveness during the event stream is necessary.

A key caveat: debouncing does not help click-driven INP. Clicks are discrete events, not rapid-fire streams. If a click handler is slow, debouncing it changes nothing. The optimization for click handlers is reducing the work inside them, not delaying when they fire.

Offload Non-Visual Work with requestIdleCallback and Web Workers

Event handlers often contain work that is not visually relevant to the interaction, such as analytics logging, telemetry computation, or state synchronization with external services. Moving this work out of the handler's synchronous path lets the browser paint immediately after the visual update completes.

requestIdleCallback is not supported in Safari. For cross-browser compatibility, use a fallback. Note: use globalThis instead of window to avoid ReferenceError in SSR, test environments, or Web Workers:

const scheduleIdle = globalThis.requestIdleCallback ?? ((fn) => setTimeout(fn, 0));

button.addEventListener('click', () => {
  // Visual update - runs immediately
  resultsPanel.classList.add('active');
  resultsPanel.textContent = computeVisibleResult();

  // Snapshot data synchronously while DOM is stable
  const analyticsSnapshot = {
    resultText: resultsPanel.textContent,
    timestamp: Date.now(),
  };

  // Non-visual work - deferred to idle period using snapshot, not live DOM node
  scheduleIdle(() => {
    const payload = buildAnalyticsPayload(analyticsSnapshot);
    const body = JSON.stringify(payload);
    const sent = navigator.sendBeacon('/analytics', body);
    if (!sent) {
      // Beacon queue full or payload too large; fall back to keepalive fetch
      fetch('/analytics', {
        method: 'POST',
        body,
        keepalive: true,
        headers: { 'Content-Type': 'application/json' },
      }).catch((err) => {
        console.error('[analytics] fallback fetch failed:', err);
      });
    }
  });
});

The visual update happens synchronously inside the click handler. The analytics data is captured as a snapshot while the DOM is stable, then deferred to an idle callback. This ensures the browser can paint the visual result immediately without waiting for non-essential work to complete.

Fix 3: Minimize Presentation Delay (Input to Next Paint)

Presentation delay is the time from when an event handler finishes executing to when the browser paints the next frame. This phase is often overlooked but can dominate INP when the DOM is large or when layout thrashing occurs.

Batch DOM Reads Before Writes

The browser must recalculate layout whenever JavaScript reads a layout-triggering property (like offsetHeight, getBoundingClientRect(), or scrollTop) after writing to the DOM. This forced synchronous layout can multiply rendering costs.

The fix is to batch all reads first, then perform all writes:

// BAD: alternating reads and writes cause layout thrashing
function updatePositionsBad(items) {
  items.forEach((item) => {
    const el = document.getElementById(item.id);
    const rect = el.getBoundingClientRect(); // read
    el.style.top = `${rect.top + 10}px`;     // write (invalidates layout)
    const newRect = el.getBoundingClientRect(); // read (forces layout)
    el.style.left = `${newRect.left + 5}px`;   // write
  });
}

// GOOD: batch reads, then batch writes
function updatePositionsGood(items) {
  const positions = items.map((item) => {
    const el = document.getElementById(item.id);
    return { el, rect: el.getBoundingClientRect() }; // all reads first
  });
  positions.forEach(({ el, rect }) => {
    el.style.top = `${rect.top + 10}px`;  // all writes after
    el.style.left = `${rect.left + 5}px`;
  });
}

Reduce DOM Size and Virtualize Long Lists

A large DOM makes every style recalculation and paint operation more expensive. For lists with hundreds or thousands of items, virtualization libraries like react-window or @tanstack/virtual render only the visible items plus a small overscan buffer, dramatically reducing the number of DOM nodes that need to be recalculated during interactions.

// Example using @tanstack/virtual for a virtualized list
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items }) {
  const parentRef = React.useRef(null);
  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,
    overscan: 5,
  });

  return (
    <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {items[virtualItem.index].label}
          </div>
        ))}
      </div>
    </div>
  );
}

Fix 4: Tame Third-Party Scripts

Third-party scripts - analytics, ads, chat widgets, social embeds - are among the most common causes of poor INP. They often execute heavy JavaScript on the main thread during or immediately after user interactions.

Audit and Replace Heavy Embeds

Start by auditing all third-party scripts on your page. Tools like Lighthouse, WebPageTest, and the Coverage panel in DevTools can identify which scripts are consuming the most CPU time.

For each script, consider:

  • Can it be removed entirely?
  • Can it be loaded lazily (only when the user interacts with it)?
  • Can it be replaced with a lighter alternative?

Apply Facades

A facade is a lightweight placeholder that mimics the appearance of a third-party widget but loads the real script only when the user interacts with it. This is particularly effective for embedded videos, chat widgets, and social media feeds.

<!-- Facade for a YouTube embed -->
<div class="youtube-facade" data-video-id="dQw4w9WgXcQ">
  <div class="youtube-facade-thumbnail">
    <img src="https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg" alt="Video thumbnail" loading="lazy">
    <button class="youtube-facade-play-button" aria-label="Play video">▶</button>
  </div>
</div>

<script>
document.querySelectorAll('.youtube-facade').forEach((facade) => {
  facade.addEventListener('click', () => {
    const videoId = facade.dataset.videoId;
    const iframe = document.createElement('iframe');
    iframe.src = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
    iframe.allow = 'autoplay; encrypted-media';
    iframe.allowFullscreen = true;
    facade.replaceWith(iframe);
  }, { once: true });
});
</script>

Dynamic Imports and Worker Isolation

For third-party scripts that cannot be removed or replaced, consider:

  • Dynamic imports: Load the script only when needed using import().
  • Web Worker isolation: Move heavy third-party logic to a Web Worker using libraries like comlink to communicate with the main thread without blocking it.
// Dynamic import of a heavy analytics library
button.addEventListener('click', async () => {
  // Visual update first
  resultsPanel.classList.add('active');
  resultsPanel.textContent = computeVisibleResult();

  // Load analytics library asynchronously
  const { trackEvent } = await import('./heavy-analytics.js');
  trackEvent('button_click', { buttonId: button.id });
});

Complete INP Optimization Checklist

Use this checklist as a reusable reference for ongoing performance work:

  1. Measure INP in production using the web-vitals attribution build and CrUX field data.
  2. Profile the worst interactions in Chrome DevTools' Performance → Interactions track.
  3. Break long tasks (>50 ms) into smaller chunks using scheduler.yield() with a setTimeout fallback.
  4. Defer non-visual work (analytics, telemetry) out of event handlers via requestIdleCallback or Web Workers.
  5. Batch DOM reads before writes to eliminate forced synchronous layouts and layout thrashing.
  6. Reduce DOM size and virtualize long lists to lower style-recalculation and paint costs.
  7. Audit third-party scripts and apply facades, dynamic imports, or Worker isolation for heavy embeds.
  8. Verify the fix by confirming the 75th-percentile INP stays at or below 200 ms after the next CrUX data cycle.

INP Is a Moving Target: Stay Proactive

INP optimization is not a one-time fix. As your application evolves - adding new features, updating dependencies, or changing third-party providers - INP scores can regress. Establish a performance budget for INP, monitor it in CI/CD pipelines, and make responsiveness testing part of your regular development workflow.

The techniques in this guide provide a solid foundation, but the landscape continues to evolve. New APIs like the Long Animation Frames API and improvements to scheduler.yield() will make diagnosis and remediation easier over time. Stay current with Chrome DevTools releases, CrUX data trends, and the web-vitals library to keep your INP scores in the "good" range.

Comments

No comments yet. Start the discussion.