Fix Interaction to Next Paint (INP) Issues | OpsBlu Docs

Fix Interaction to Next Paint (INP) Issues

How to diagnose and fix INP issues. Covers the three phases of interaction latency (input delay, processing, presentation delay), Long Animation Frames...

What INP Measures

Interaction to Next Paint (INP) replaced First Input Delay (FID) as a Core Web Vital in March 2024. While FID only measured the delay before the first interaction's event handler ran, INP measures the full round-trip latency of every interaction — from user input to the next frame painted on screen — and reports the worst one (technically the 98th percentile across the session).

Rating Threshold Meaning
Good ≤ 200ms User perceives the interaction as instant
Needs Improvement 200–500ms Noticeable lag, especially on mobile
Poor > 500ms Interface feels broken or frozen

What counts as an interaction: clicks, taps, and keyboard input (keydown/keyup). Scrolling and hovering do not count.

The Three Phases of Interaction Latency

Every interaction has three measurable phases. Fixing INP requires knowing which phase is slow:

┌─────────────┐    ┌─────────────────┐    ┌──────────────────────┐
│ Input Delay  │ → │ Processing Time  │ → │ Presentation Delay   │
│              │    │                  │    │                      │
│ Time waiting │    │ Event handler    │    │ Style recalc, layout,│
│ for main     │    │ execution time   │    │ paint, compositing   │
│ thread       │    │ (your code)      │    │ (rendering work)     │
└─────────────┘    └─────────────────┘    └──────────────────────┘

Input delay — The interaction happened but the main thread was busy (running another script, parsing CSS, etc.). The browser queues the event until the thread is free. This phase is slow when long tasks block the main thread.

Processing time — Your event handler code runs. This is the part you have direct control over. Slow processing means your click/keypress handler does too much work synchronously.

Presentation delay — After your handler finishes, the browser must recalculate styles, run layout, paint, and composite the new frame. This phase is slow when your handler triggers expensive DOM changes, large layout shifts, or paints many elements.

Diagnosing INP

Step 1: Identify the Slow Interaction

// Use web-vitals library to capture INP with attribution
import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  console.log('INP value:', metric.value, 'ms');
  console.log('Rating:', metric.rating);

  const entry = metric.attribution.eventEntry;
  console.log('Event type:', metric.attribution.eventType);
  console.log('Event target:', metric.attribution.eventTarget);

  // Phase breakdown
  console.log('Input delay:', metric.attribution.inputDelay, 'ms');
  console.log('Processing:', metric.attribution.processingDuration, 'ms');
  console.log('Presentation:', metric.attribution.presentationDelay, 'ms');
});

This tells you: what element was interacted with, what type of interaction it was, and which of the three phases took the most time. That determines your fix.

Step 2: Use Long Animation Frames API

The Long Animation Frames (LoAF) API shows exactly what scripts ran during slow frames, replacing the older Long Tasks API with much more useful attribution:

// Observe long animation frames (>50ms)
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // entry.duration = total frame time
    // entry.blockingDuration = time the frame blocked input
    console.log(`LoAF: ${entry.duration}ms (blocking: ${entry.blockingDuration}ms)`);

    // Which scripts contributed?
    for (const script of entry.scripts) {
      console.log(`  Script: ${script.sourceURL}`);
      console.log(`  Function: ${script.sourceFunctionName}`);
      console.log(`  Duration: ${script.duration}ms`);
      console.log(`  Type: ${script.invokerType}`); // 'event-listener', 'user-callback', etc.
      console.log(`  Invoker: ${script.invoker}`);  // e.g., 'BUTTON#submit.onclick'
    }
  }
});
observer.observe({ type: 'long-animation-frame', buffered: true });

Browser support: Chrome 123+ (March 2024). Not yet in Firefox or Safari. Use as a diagnostic tool, not a production dependency.

Step 3: Chrome DevTools Performance Panel

  1. Open DevTools → Performance → check "Web Vitals" checkbox.
  2. Click Record, perform the slow interaction, stop recording.
  3. In the timeline, look for the interaction marker (labeled "INP" if it's the worst).
  4. Zoom into the interaction. You'll see:
    • Input delay: gap between the interaction marker and when the event handler starts.
    • Processing: the event handler task(s) in the flame chart.
    • Presentation: style recalc, layout, paint, and composite tasks after the handler.
  5. Click on long tasks in the flame chart to see which functions consumed time.

Step 4: Field Data (Real Users)

Lab tools often miss real INP because they can't replicate all user interaction patterns. Use field data:

  • Chrome User Experience Report (CrUX): chrome-ux-report BigQuery dataset or PageSpeed Insights — shows 75th percentile INP from real Chrome users.
  • Google Search Console: Core Web Vitals report flags pages with poor INP.
  • RUM tools: Send INP data to your analytics platform using the web-vitals library (shown above).

Fixing Input Delay

Input delay is caused by long tasks occupying the main thread when the user interacts. The user's event handler can't run until the current task finishes.

Yield to the Main Thread

Break long tasks into smaller chunks so the browser can process pending interactions between them:

// Before: one long synchronous task
function processLargeDataset(items) {
  items.forEach(item => expensiveOperation(item));
}

// After: yield between chunks using scheduler.yield()
async function processLargeDataset(items) {
  const CHUNK_SIZE = 50;
  for (let i = 0; i < items.length; i += CHUNK_SIZE) {
    const chunk = items.slice(i, i + CHUNK_SIZE);
    chunk.forEach(item => expensiveOperation(item));

    // Yield to let browser process pending interactions
    await scheduler.yield();  // Chrome 129+
    // Fallback: await new Promise(resolve => setTimeout(resolve, 0));
  }
}

scheduler.yield() is purpose-built for this — it yields to the browser but keeps your task's priority in the queue (unlike setTimeout(0) which drops to the back of the queue).

Reduce Third-Party Script Impact

Third-party scripts (analytics, ads, chat widgets) are the most common cause of input delay because they run long tasks on the main thread that you don't control:

<!-- Load non-critical third-party scripts after the page is interactive -->
<script src="https://example.com/widget.js" async defer></script>

<!-- Or load via requestIdleCallback -->
<script>
  requestIdleCallback(() => {
    const s = document.createElement('script');
    s.src = 'https://example.com/chat-widget.js';
    document.head.appendChild(s);
  });
</script>

Code Splitting

Large JavaScript bundles that parse on page load create long tasks that cause input delay for early interactions:

// Before: everything loaded upfront
import { heavyChartLibrary } from './charts';

button.addEventListener('click', () => {
  heavyChartLibrary.render(data);
});

// After: load on demand
button.addEventListener('click', async () => {
  const { heavyChartLibrary } = await import('./charts');
  heavyChartLibrary.render(data);
});

Fixing Processing Time

Processing time is the part you directly control — it's how long your event handler takes to execute.

Keep Event Handlers Lean

// Before: handler does everything synchronously
button.addEventListener('click', () => {
  const data = computeExpensiveAnalytics();    // 80ms
  updateDOM(data);                              // 40ms
  sendToServer(data);                           // network call
  logInteraction();                             // 10ms
});
// Total: 130ms+ of synchronous work

// After: only do visual updates synchronously
button.addEventListener('click', () => {
  // Immediate visual feedback
  button.classList.add('loading');

  // Defer non-visual work
  requestIdleCallback(() => {
    const data = computeExpensiveAnalytics();
    sendToServer(data);
    logInteraction();
  });

  // Schedule DOM update for next frame
  requestAnimationFrame(() => {
    updateDOM(computeQuickPreview());
  });
});

Move Heavy Computation to Web Workers

// worker.js
self.onmessage = ({ data }) => {
  const result = expensiveComputation(data);
  self.postMessage(result);
};

// main.js
const worker = new Worker('worker.js');

button.addEventListener('click', () => {
  button.classList.add('loading');  // instant visual feedback
  worker.postMessage(inputData);
});

worker.onmessage = ({ data }) => {
  updateDOM(data);
  button.classList.remove('loading');
};

Debounce Rapid Interactions

Keyboard input (search boxes, autocomplete) fires events on every keystroke:

// Before: runs on every keypress
input.addEventListener('input', (e) => {
  const results = searchDatabase(e.target.value);  // expensive
  renderResults(results);
});

// After: debounce to avoid processing every keystroke
let timeout;
input.addEventListener('input', (e) => {
  clearTimeout(timeout);
  timeout = setTimeout(() => {
    const results = searchDatabase(e.target.value);
    renderResults(results);
  }, 150);
});

Fixing Presentation Delay

Presentation delay is caused by expensive rendering work after your event handler completes — large layout recalculations, painting many elements, or forced synchronous layouts.

Avoid Layout Thrashing

// BAD: forces synchronous layout on every iteration
elements.forEach(el => {
  const height = el.offsetHeight;          // forces layout (read)
  el.style.height = (height * 2) + 'px';  // invalidates layout (write)
  // Next iteration's offsetHeight forces another layout
});

// GOOD: batch reads, then batch writes
const heights = elements.map(el => el.offsetHeight);  // all reads
elements.forEach((el, i) => {
  el.style.height = (heights[i] * 2) + 'px';          // all writes
});

Reduce DOM Size

Pages with more than 1,400 DOM elements have measurably slower rendering. Pages over 3,000 elements are consistently poor:

// Check your DOM size
console.log('DOM elements:', document.querySelectorAll('*').length);

Fixes: virtualize long lists (only render visible rows), remove hidden elements instead of using display: none, lazy-load off-screen content with content-visibility: auto.

Use CSS Containment

Tell the browser that changes inside a container won't affect elements outside it:

/* The browser can skip recalculating layout/style/paint
   for this element when something else on the page changes */
.card {
  contain: layout style paint;
}

/* content-visibility skips rendering entirely for off-screen elements */
.below-fold-section {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px;  /* estimated height to prevent CLS */
}

Framework-Specific Patterns

React

// Use useTransition for non-urgent state updates
import { useTransition } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    setQuery(e.target.value);          // urgent: update input immediately
    startTransition(() => {
      setResults(filterData(e.target.value));  // non-urgent: can be interrupted
    });
  }

  return (
    <>
      <input value={query} />
      {isPending ? <Spinner /> : <ResultsList results={results} />}
    </>
  );
}

Vue

// Use v-memo to skip re-rendering unchanged list items
// <div v-for="item in list" :key="item.id" v-memo="[item.id, item.updated]">

// Use shallowRef for large objects that don't need deep reactivity
import { shallowRef } from 'vue';
const largeDataset = shallowRef(initialData);

// Trigger update explicitly
largeDataset.value = { ...largeDataset.value, newProp: 'value' };

Next.js / React Server Components

// Move data-heavy rendering to server components (zero client JS)
// app/dashboard/page.tsx (server component by default)
export default async function Dashboard() {
  const data = await fetchDashboardData();
  return <DashboardView data={data} />;  // rendered on server, no INP impact
}

// Only interactive parts become client components
'use client';
export function FilterDropdown({ options }) {
  // This is the only JS sent to the client
  const [selected, setSelected] = useState(options[0]);
  return <select => setSelected(e.target.value)}>...</select>;
}

Measuring Progress

After making changes, verify improvement:

  1. Lab check: Run Lighthouse with "Timespan" mode (not just Navigation) to capture interaction INP during a recorded session.
  2. Field validation: Deploy changes, wait 28 days for CrUX data to update, check PageSpeed Insights or Search Console.
  3. RUM monitoring: Use the web-vitals attribution build in production to continuously track INP and identify regressions.

Track INP alongside the phase breakdown (input delay / processing / presentation) — raw INP alone doesn't tell you what to fix next.