Siddhant Deval
Siddhant Deval
frontend5 min read

INP: Interaction Responsiveness & Long Task Attribution

INP is not a first-impression metric — it measures the worst interaction latency across the entire session, which means a single 500ms click handler that runs once per session can fail INP for every user who triggers it.

INP: Interaction Responsiveness & Long Task Attribution

INP is not a first-impression metric. LCP measures what the user sees when the page first loads. INP measures what happens every time they touch your application — every click, every keypress, every tap — for the entire session. And it reports the 98th percentile of all those interactions. One button click handler that takes 600ms to complete, triggered once in a user session by 5% of your users, fails INP for those users. There is no averaging it away with fast interactions. There is no burying it in low-traffic pages. The scheduler sees every interaction and records the worst one.


1. The Broken Pattern: Measuring the Wrong Thing

JAVASCRIPT
// ❌ Broken diagnosis — console.time only measures the handler's own execution
document.querySelector('#process-btn').addEventListener('click', () => {
  console.time('click')
  processDataSynchronously(largeDataset) // 300ms
  updateUIState()                         // 50ms
  console.timeEnd('click')               // Logs: "click: 350ms"

  // What console.time does NOT measure:
  // — Input Delay: how long the main thread was busy BEFORE this handler ran
  // — Presentation Delay: how long the rendering pipeline took AFTER the handler ran
  // Total INP for this interaction: potentially 600ms+
})

Engineers see 350ms in the console and conclude the interaction is within budget. The browser scores the same interaction at 620ms and fails INP. The missing 270ms is invisible to console.time — it lives in the Input Delay and Presentation Delay phases that bracket the handler execution.


2. INP Defined: Three Phases, One Score

INP is the 98th percentile of all interaction duration scores across a user session. Each interaction duration spans three phases:

User input (click/keydown/pointerdown)
│
├── INPUT DELAY ──────────────────────────────────────────────────────────────
│   Main thread was busy when the event fired.
│   Duration: time from input event until the browser's event callback queue runs.
│   Caused by: long tasks running at the time of input (analytics, image decode,
│              layout recalculation, other event handlers).
│
├── PROCESSING TIME ──────────────────────────────────────────────────────────
│   The event handler(s) execute.
│   Duration: from first handler start to last handler complete.
│   Caused by: slow event handlers, synchronous DOM mutations, expensive calculations.
│
└── PRESENTATION DELAY ───────────────────────────────────────────────────────
    The rendering pipeline runs: rAF callbacks → Style → Layout → Paint → Composite.
    Duration: from last handler complete to frame committed to the compositor.
    Caused by: ResizeObserver/MutationObserver callbacks, forced layout (reflow),
               large DOM subtrees being style-recalculated.

Total INP = Input Delay + Processing Time + Presentation Delay

Thresholds:

Score Threshold
✅ Good ≤ 200ms
⚠️ Needs Improvement ≤ 500ms
❌ Poor > 500ms

3. Why INP Replaced FID

FID (First Input Delay) was deprecated as a Core Web Vital in March 2024. The replacement is INP. Understanding why reveals what INP measures that FID missed:

Property FID INP
Which interactions First user interaction only All interactions in the session
What is measured Input Delay phase only Input Delay + Processing Time + Presentation Delay
Aggregation Single value (first event) 98th percentile (worst)
Threat model Slow initial page load blocking the first click Slow event handlers anywhere in the app at any time

FID missed what developers call the "second mountain" — applications that load fast but slow down after user interactions (state updates, client-side data fetches, re-renders). A heavy onClick handler on a product page never affects FID if the user clicks the button after the page has hydrated. INP catches it every time.


4. Long Animation Frames (LoAF)

The Long Animation Frames API (LoAF) identifies animation frames that took more than 50ms and attributes them to the scripts that caused the delay. Use PerformanceObserver with type: 'long-animation-frame'.

Performance / Safety Warning

Browser support: LoAF is supported in Chrome 123+ and Edge 123+ only as of 2026. Firefox and Safari do not support it. Always add a feature-detection guard — do not assume the observer registers in all browsers.

JAVASCRIPT
// LoAF — attribute long frames to specific scripts and interactions
// Feature-detect before observing — Firefox and Safari do not support this entryType
if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
  const observer = new PerformanceObserver(list => {
    list.getEntries().forEach(entry => {
      if (entry.duration > 50) { // Frame took more than 50ms
        console.group(`Long frame: ${entry.duration.toFixed(0)}ms`)

        // Which scripts ran during this frame?
        entry.scripts.forEach(script => {
          console.log('Script:', script.sourceURL)
          console.log('  Duration:', script.duration.toFixed(0) + 'ms')
          console.log('  Invoker type:', script.invokerType) // 'event-listener', 'user-callback', etc.
          console.log('  Invoker:', script.invoker) // e.g., 'BUTTON.onclick'
        })

        // Which interaction does this frame belong to?
        if (entry.firstUIEventTimestamp > 0) {
          console.log('Interaction timestamp:', entry.firstUIEventTimestamp)
        }

        console.groupEnd()
      }
    })
  })
  observer.observe({ type: 'long-animation-frame', buffered: true })
}

LoAF's scripts array is the critical diagnostic tool: it tells you not just that a frame was slow, but which event-listener at which source location caused it. Compare this to the deprecated Long Tasks API, which could only report that some long task occurred — without identifying which script.


5. Breaking Up Long Tasks: scheduler.yield() vs. the Alternatives

When a long task runs, it blocks the main thread — input events cannot fire, rendering cannot occur, and INP accumulates. The solution is to break the task into smaller chunks with explicit yield points.

5.1 The Options and Their Behavior

Architectural Note

scheduler.yield() browser support: Shipped in Chrome 129+, Edge 129+, and Firefox 142+. Safari does not yet support it. Always add a feature-detect fallback for Safari and older browsers.

JAVASCRIPT
// Option 1: scheduler.yield() — CORRECT for INP
// Yields to the browser's task queue and resumes AFTER higher-priority work
// (including pending input events) has been processed
// Shipped: Chrome 129+, Edge 129+, Firefox 142+ (Safari: use setTimeout fallback)
async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i])

    // Every 50 items, yield to let the browser handle pending interactions
    if (i % 50 === 0) {
      if ('scheduler' in globalThis && 'yield' in scheduler) {
        await scheduler.yield() // ← Correct: resumes before other queued tasks
      } else {
        await new Promise(resolve => setTimeout(resolve, 0)) // Safari fallback
      }
    }
  }
}

// Option 2: requestIdleCallback — WRONG for INP
// Only runs during browser idle time — does NOT yield to input events
// If the browser is busy with rendering, rIC doesn't fire
// If the user is actively interacting, rIC may delay until the interaction is over
window.requestIdleCallback(callback) // ← Does not help INP

// Option 3: setTimeout(fn, 0) — PARTIAL for INP
// Yields to the macrotask queue, which includes input events
// Less precise than scheduler.yield() — may be delayed by other macrotasks
// Degrades to 1ms minimum on inactive tabs (throttled)
await new Promise(resolve => setTimeout(resolve, 0)) // ← Helps, but less predictably
Mechanism Yields to input events? Priority-aware? Reliable timing?
scheduler.yield() ✅ Yes ✅ Yes ✅ Yes
requestIdleCallback ❌ No ❌ No ⚠️ Throttled
setTimeout(fn, 0) ✅ Yes ❌ No ⚠️ Throttled on inactive
queueMicrotask ❌ No (runs in same task) ❌ No —

5.2 Debouncing vs. Yielding — A False Equivalence

Debouncing and yielding solve different problems and are not interchangeable:

JAVASCRIPT
// Debouncing — reduces how OFTEN a task starts
// Does NOT help if the task itself is a long-running synchronous operation
const debouncedSearch = debounce((query) => {
  runExpensiveSearch(query) // Still a 400ms synchronous task when it runs
}, 300)
// INP impact: zero improvement — the task still blocks for 400ms when it fires

// Yielding — breaks a RUNNING task into smaller chunks
async function runExpensiveSearch(query) {
  const results = await fetchResults(query) // 50ms network
  for (let i = 0; i < results.length; i++) {
    processResult(results[i])               // 2ms per result × 200 = 400ms total
    if (i % 20 === 0) await scheduler.yield() // Yield every 40ms
  }
}
// INP impact: real improvement — no chunk takes more than 40ms
Crucial Requirement

Debounce and throttle are input-rate controls — they reduce the frequency of function calls. They do not help INP when the underlying function is itself a long-running task. Use debounce/throttle to reduce unnecessary calls, then use scheduler.yield() to break up the calls that do execute.


6. startTransition and useDeferredValue as INP Tools

React's concurrent rendering features integrate with the browser's input event priority to reduce INP from heavy state updates.

TYPESCRIPT
// startTransition — marks a state update as non-urgent
// React will pause this render if an input event arrives
import { startTransition, useState } from 'react'

function SearchPage() {
  const [inputValue, setInputValue] = useState('')
  const [searchQuery, setSearchQuery] = useState('')

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value) // Urgent — updates the input immediately

    startTransition(() => {
      setSearchQuery(e.target.value) // Non-urgent — React may pause/interrupt this
    })
  }

  return (
    <>
      <input value={inputValue} onChange={handleChange} /> {/* Always responsive */}
      <SearchResults query={searchQuery} />  {/* May be mid-render when interrupted */}
    </>
  )
}
TYPESCRIPT
// useDeferredValue — defer an expensive render to avoid blocking input
// WARNING: causes a double render — first with the old value, then with the new value
// The double render cost can worsen INP if the deferred render is expensive
function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query)
  // deferredQuery lags behind query during rapid typing
  // The results panel updates after the input has settled

  const isStale = query !== deferredQuery

  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      <ExpensiveResultsList query={deferredQuery} />
    </div>
  )
}
Performance / Safety Warning

useDeferredValue causes a double render on every deferred update — first with the old value (immediate, cheap), then with the new value (deferred, expensive). If the deferred render is still expensive after useDeferredValue, the double render adds to Processing Time and can worsen INP. Use useDeferredValue only when the deferred render is fast (< 50ms) and the primary benefit is input responsiveness during rapid keystrokes.


7. Diagnosing INP in Production

JAVASCRIPT
// Production INP instrumentation using the web-vitals library
import { onINP } from 'web-vitals/attribution'

onINP(({ value, rating, attribution }) => {
  analytics.track('inp', {
    value,   // INP duration in ms
    rating,  // 'good' | 'needs-improvement' | 'poor'

    // Which interaction type failed?
    interactionType: attribution.interactionType, // 'click', 'keyboard', 'tap'

    // Where did the time go?
    inputDelay: attribution.inputDelay,
    processingDuration: attribution.processingDuration,
    presentationDelay: attribution.presentationDelay,

    // Which element was interacted with?
    interactionTarget: attribution.interactionTargetElement?.outerHTML?.slice(0, 100),
  })
})

Triage priority based on the attribution breakdown:

Dominant phase Root cause Fix
Input Delay > 100ms Long task running at moment of input Reduce long tasks outside handlers (analytics, resize, scroll)
Processing Time > 100ms Slow event handler Yield with scheduler.yield(); move heavy work off main thread
Presentation Delay > 100ms ResizeObserver/MutationObserver after handler Defer observer callbacks; avoid forced layout

Summary

Concept Rule
INP definition 98th percentile of all interaction durations (Input Delay + Processing Time + Presentation Delay) across the entire session.
Why it replaced FID FID measured only the first interaction's input delay. INP measures every interaction's full three-phase duration.
scheduler.yield() The correct API for breaking long tasks to yield to input events. requestIdleCallback does not yield to input events and does not help INP.
Debounce vs. yield Debounce reduces call frequency. Yield breaks running task duration. Use both, for different problems.
startTransition Marks state updates as non-urgent — React can pause and resume them around incoming input events.
useDeferredValue Causes a double render. Only beneficial when the deferred render is fast (<50ms) and rapid keystroke input responsiveness is the goal.

What's Next

In Part 8, we address CLS — Cumulative Layout Shift. The most misunderstood Core Web Vital: the fix is not width and height attributes. It is giving the browser's layout engine a geometry reservation before paint, and knowing that transform animations never cause CLS while top/left/margin always do. Part 8: CLS →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Core Web Vitals#INP#Performance#Long Tasks#LoAF#scheduler
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.