ESC

Type to search the knowledge base.

INP Optimization Tactics

Fix Interaction to Next Paint for real — input delay, handler cost, presentation delay, long tasks, and yielding patterns that move field INP.

intermediate5 min read
  • performance
  • inp
  • web-vitals
  • main-thread

INP (Interaction to Next Paint) measures how quickly the page responds to clicks, taps, and key presses across the whole visit, reporting a high-percentile latency. Good: ≤ 200ms at the 75th percentile. FID is gone; INP is the interactivity Core Web Vital.

This is a tactics page. For the metric overview and thresholds, see Core Web Vitals. For frame stages after you change styles, see the rendering pipeline.

Docs: web.dev INP, Optimize INP, Event Timing API.

Anatomy of one interaction

Input occurs
  → input delay      (main thread busy; can’t start handler)
  → processing time  (your event handlers + framework work)
  → presentation delay (style/layout/paint until next paint)
= interaction latency (until next paint with visual update)

INP cares about next paint, not “handler returned.” If you finish JS but the browser can’t paint the toggle/spinner for another 200ms of layout work, users still feel lag.

Find the slow interaction

Lab load scores don’t produce INP by themselves — you must perform interactions.

  1. Field: RUM with web-vitals onINP + attribution (event target, timing breakdown when available).
  2. Lab: reproduce the slow control (search typeahead, open mega-menu, add to cart).
  3. Performance panel: enable interactions / long tasks; look for long // tasks around the event.
  4. React: Profiler + why a click re-rendered half the tree.
import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log(metric.value, metric.attribution);
});

Without a target selector, “optimize React” is not a plan.

Tactic 1 — Shrink input delay (clear the main thread)

Input delay rises when the main thread is already busy: long tasks from hydration, analytics, parsing big JSON, synchronous third parties.

Fixes:

  1. Break up long tasks (>50ms) so the browser can take input between chunks.
  2. Defer non-critical work with requestIdleCallback (with timeout) or scheduler.postTask where available.
  3. Delay third-party scripts until after first interactions or consent.
  4. Avoid expensive synchronous work on timers that collide with user input.
async function processAll(items, onProgress) {
  const CHUNK = 32;
  for (let i = 0; i < items.length; i++) {
    work(items[i]);
    if (i % CHUNK === 0) {
      onProgress?.(i);
      // Yield to input + rendering
      await yieldToMain();
    }
  }
}

function yieldToMain() {
  if ('scheduler' in globalThis && scheduler.yield) {
    return scheduler.yield();
  }
  return new Promise((r) => setTimeout(r, 0));
}

Yielding mid-hydration or mid-filter is often the difference between “page feels stuck” and “typing stays live.”

Tactic 2 — Keep handlers thin

Bad pattern: click → validate → filter 10k rows → write localStorage → send analytics → then update UI.

Better:

  1. Paint feedback first — pressed state, optimistic UI, spinner, open class.
  2. Defer non-visual work to after paint (requestAnimationFrame double-rAF patterns carefully, or queueMicrotask only for true micro work — prefer macrotask yield for heavy jobs).
  3. Move pure computation to a Worker when it’s large and serializable.
  4. Debounce/throttle input handlers (search) with care for a11y (announce results).
button.addEventListener('click', (e) => {
  button.disabled = true;
  button.setAttribute('aria-busy', 'true');
  // Visual intent scheduled; heavy work after yield
  queueMicrotask(() => {});
  void (async () => {
    await yieldToMain();
    await saveDraft();
    button.disabled = false;
    button.removeAttribute('aria-busy');
  })();
});

Framework note: setState that triggers a massive re-render is processing time + presentation delay. Memoize expensive lists, virtualize, split state so the click doesn’t rebuild the world.

Tactic 3 — Kill forced layout in event paths

Classic thrash:

items.forEach((el) => {
  const h = el.offsetHeight; // read → force layout
  el.style.height = h + 10 + 'px'; // write
});

Interleaved reads/writes force synchronous layout mid-handler — pure INP poison. Batch reads, then writes. Prefer CSS for layout when possible. See rendering pipeline.

Tactic 4 — Reduce presentation delay

After JS, the browser still styles, lays out, paints.

  1. Prefer compositor-friendly transitions (transform, opacity) for interaction feedback.
  2. Avoid toggling classes that reflow large subtrees if a smaller subtree would do.
  3. Content-visibility / virtualization for long pages.
  4. Containment (contain: layout / content-visibility) where appropriate to limit style/layout scope.
  5. Don’t animate layout properties on the interaction’s visual response path.

Tactic 5 — Framework-specific hotspots

React

  • Concurrent features: keep typing responsive with transitions for non-urgent updates (startTransition) so urgent input isn’t blocked behind heavy renders.
  • Virtualize large lists (react-window / tanstack virtual).
  • Avoid rendering 500 uncontrolled inputs on every keystroke of one filter.
  • Hydration: selective hydration / islands so one island’s work doesn’t monopolize forever.

General SPA

  • Route transitions: show immediate pending UI; don’t block paint on full data.
  • Global store updates: narrow subscriptions.
  • CSS-in-JS runtime costs on every interaction — measure if you see style recalculation spikes.

Tactic 6 — Third parties and tags

Tag managers often inject long tasks at the worst time. Strategies:

  1. Load after interaction or idle.
  2. Facade pattern for chat/video embeds (click-to-load).
  3. Strict allowlists; remove dead tags.
  4. Sandbox where possible; never at the cost of XSS mistakes.

What not to confuse with INP

Metric / idea Relationship
TBT (lab) Rough proxy for main-thread busyness at load — not INP
FID Retired; first interaction only
FPS while scrolling Related smoothness; scroll isn’t the same event set as INP
LCP Load; separate budget (LCP tactics)

A page can have great LCP and terrible INP (heavy after-load JS). Optimize both.

Validation loop

  1. Capture worst INP interaction from RUM (page + element + device).
  2. Repro on mid-tier mobile throttling.
  3. Profile long tasks + scripting + rendering.
  4. Ship a thin-handler or yield fix.
  5. Confirm field p75 moves over a full traffic week — not only Lighthouse.

Interview angle

Define INP and 200ms good threshold. Split input delay / processing / presentation. Give one fix each (yield long tasks, thin handlers + urgent UI, avoid layout thrash / heavy render). Mention field interactions vs load-only lab scores.

Further reading

Related guides