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.
- 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.
- Field: RUM with
web-vitalsonINP+ attribution (event target, timing breakdown when available). - Lab: reproduce the slow control (search typeahead, open mega-menu, add to cart).
- Performance panel: enable interactions / long tasks; look for long // tasks around the event.
- 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:
- Break up long tasks (>50ms) so the browser can take input between chunks.
- Defer non-critical work with
requestIdleCallback(with timeout) orscheduler.postTaskwhere available. - Delay third-party scripts until after first interactions or consent.
- 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:
- Paint feedback first — pressed state, optimistic UI, spinner, open class.
- Defer non-visual work to after paint (
requestAnimationFramedouble-rAF patterns carefully, orqueueMicrotaskonly for true micro work — prefer macrotask yield for heavy jobs). - Move pure computation to a Worker when it’s large and serializable.
- 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.
- Prefer compositor-friendly transitions (
transform,opacity) for interaction feedback. - Avoid toggling classes that reflow large subtrees if a smaller subtree would do.
- Content-visibility / virtualization for long pages.
- Containment (
contain: layout/content-visibility) where appropriate to limit style/layout scope. - 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:
- Load after interaction or idle.
- Facade pattern for chat/video embeds (click-to-load).
- Strict allowlists; remove dead tags.
- 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
- Capture worst INP interaction from RUM (page + element + device).
- Repro on mid-tier mobile throttling.
- Profile long tasks + scripting + rendering.
- Ship a thin-handler or yield fix.
- 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.
Related on this site
- Core Web Vitals
- LCP optimization tactics
- Browser rendering pipeline
- Critical rendering path — load path; still free main thread early
- Long Tasks API
- Event timing for INP
- The Event Loop
Further reading
- web.dev: Optimize Interaction to Next Paint
- web.dev: INP
- web.dev: Optimize long tasks
- MDN: Event Timing API
- Chrome:
scheduler.yield
Related guides
- Idle Work SchedulingrequestIdleCallback, idle deadlines, and what work is safe to defer without breaking UX or analytics.
- scheduler.yield and Schedulingscheduler.yield, postTask priorities, and how modern scheduling APIs improve INP over setTimeout hacks.
- Core Web VitalsLCP, INP, and CLS — what field data measures, good thresholds, and fixes that actually move the needle (per web.dev guidance).
- CLS Optimization TacticsFix cumulative layout shift: dimensions, font metrics, reserved slots, and stable late-loading UI.
- LCP Optimization TacticsDeep tactics for Largest Contentful Paint — discovery, priority, bytes, server delay, and render delay — beyond the CWV overview.