Diagnosing INP Over 200ms: Step-by-Step Profiling of Input Delay, Processing Duration, and Presentation Delay
You can clearly feel it when you press a button and the screen responds slowly — but even when you open the DevTools timeline, it's not immediately obvious "where it's breaking." I was in that situation for quite a while myself. I couldn't tell whether I needed to dig into the event handler, look at rendering, or whether the main thread was blocked somewhere earlier. If you don't know the cause, you end up digging in the wrong place.
Since Google officially replaced FID with INP as a Core Web Vitals metric in March 2024, there's one more reason to get this number right. According to the HTTP Archive 2025 Web Almanac, 77% of all mobile pages achieve Good (≤200ms) — but only 53% of the top 1,000 most popular sites achieve Good. The higher the traffic and the more complex the features, the harder it gets, and it has a direct impact on SEO.
After reading this article, you'll be able to look at a single line of Attribution data and immediately see the next optimization direction. We'll cover how to combine Chrome DevTools, the web-vitals v4 Attribution build, and the Long Animation Frames API (LoAF) to identify which of the three sub-phases (Input Delay, Processing Duration, Presentation Delay) is slow, and apply the appropriate fix pattern for each. React/Next.js code is included as well, so this should be immediately useful for those struggling in framework environments.
Core Concepts
INP is the Sum of Three Phases
If you only think of INP as "the time from pressing a button to seeing the screen change," your optimization scope narrows considerably. INP is actually the sum of these three intervals:
INP = Input Delay + Processing Duration + Presentation Delay| Phase | Definition | Main Causes |
|---|---|---|
| Input Delay | Wait from user input → event handler start | Other Long Tasks occupying the main thread |
| Processing Duration | Total execution time of the event handler (click, keyup, etc.) | Heavy synchronous operations inside the handler |
| Presentation Delay | Handler completion → actual screen update (paint) | Style recalculation, layout, excessive DOM changes |
Why distinguish between three phases? Because the cause of each phase is entirely different. Input Delay must be solved outside the handler, Processing Duration requires working inside the handler, and Presentation Delay requires touching the rendering pipeline. Without identifying the cause, you end up optimizing the wrong place.
Good / Needs Improvement / Poor Thresholds
The INP rating criteria are as follows. Judgment is based on the 75th percentile (p75).
| Range | Rating |
|---|---|
| ≤ 200ms | ✅ Good |
| 200ms ~ 500ms | ⚠️ Needs Improvement |
| > 500ms | ❌ Poor |
What is p75? When you collect data from 100 users, it's the value at the 25th position from the slowest end. "Most users are fine, only some are slow" doesn't hold up under INP standards. The p75 of the slowest interaction among all interactions within a session is the benchmark.
Practical Application
The most efficient debugging workflow is to first use Field data to narrow down "where it's breaking," then reproduce it with DevTools to pinpoint the cause.
Example 1: Collecting Field Data with web-vitals v4 Attribution
Lab data alone isn't enough. You need to know which element actual users are clicking when INP fires to get at the real cause. Using the Attribution build of web-vitals v4, you can integrate LoAF data and get the source script URL directly.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const { value, attribution } = metric;
const {
interactionTarget, // Element where interaction occurred (CSS selector)
interactionType, // "pointer" | "keyboard"
inputDelay, // Input Delay (ms)
processingDuration, // Processing Duration (ms)
presentationDelay, // Presentation Delay (ms)
longAnimationFrameEntries, // Associated LoAF entries
} = attribution;
console.log(`INP: ${value}ms | Target: ${interactionTarget}`);
console.log(` InputDelay: ${inputDelay}ms`);
console.log(` Processing: ${processingDuration}ms`);
console.log(` Presentation: ${presentationDelay}ms`);
// Identify source script URL via LoAF (including 3rd-party)
// sourceURL may be empty for anonymous functions or inline scripts
longAnimationFrameEntries.forEach((entry) => {
entry.scripts.forEach((script) => {
console.log(` Script: ${script.sourceURL || '(anonymous)'} (${script.duration}ms)`);
});
});
// In practice, send to an analytics service, not console
sendToAnalytics(metric);
}, { reportAllChanges: true }); // Report all interactions, not just the worst| Property | Meaning |
|---|---|
interactionTarget |
Which element — this lets you quickly find the reproduction path |
inputDelay |
A large value signals that another Long Task is blocking the main thread |
processingDuration |
Signals that the handler itself is heavy |
presentationDelay |
Signals that rendering cost is high |
longAnimationFrameEntries |
Which scripts blocked a frame, down to the source URL |
LoAF (Long Animation Frames API) An API officially released in Chrome 123 that provides script execution time and attribution (which file it came from) for frames that took more than 50ms. Unlike the Long Tasks API, which only measured task-level execution time, LoAF includes rendering time, making it useful for diagnosing Presentation Delay as well.
Example 2: Reproducing the Bottleneck Phase with Chrome DevTools
Once you've captured a signal from Field data indicating "this phase is slow on this element," you can reproduce the interaction in DevTools to identify the exact function causing the issue.
1. DevTools (F12) → Performance tab
2. Click "Record" in the top left
3. Perform the interaction that feels slow (button click, text input, etc.)
4. Click "Record" again to stop
5. In the Interactions track at the top of the timeline, click an interaction block marked with hatching (over 200ms)
6. In the Summary section at the bottom of the Performance panel, check the breakdown of Input Delay / Processing / Presentation
7. Click the longest Task in the Flame Chart → identify the culprit functionDevTools results on your development machine can differ significantly from real user devices (especially entry-level mobile). Setting the CPU throttle to 4x slowdown in the Performance tab gives you a measurement closer to real-world conditions.
Example 3: When Processing Duration is Long — Splitting Tasks with scheduler.yield()
If the handler is the culprit, this is the first thing to try. Using scheduler.yield() lets you hand the main thread back to the browser in the middle of execution, allowing the INP frame commit to be processed first.
// CPU-bound synchronous operation wrapped in an async wrapper
async function handleClick(_event: MouseEvent) {
// 1. Immediate UI feedback first
updateButtonState('loading');
// 2. Yield the main thread to the browser → INP frame commits at this point
// 'scheduler' in window check: directly accessing scheduler in environments where it doesn't exist causes a ReferenceError
if ('scheduler' in window && 'yield' in window.scheduler) {
await window.scheduler.yield();
} else {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
// 3. Perform heavy work afterward
await processHeavyData();
updateButtonState('done');
}scheduler.yield() was officially released in Chrome 129 (August 2024 stable channel). Case studies have reported p75 INP reductions of 60–65%. Combined with the fallback (setTimeout(0)), it delivers results regardless of browser support, making it a pattern worth adopting right now.
Example 4: When Input Delay is Long — Long Task Chunking
A long Input Delay means another task is already holding the main thread. If you have a loop processing large amounts of data, a chunking pattern that yields every 5ms is effective.
// Bad: processing hundreds of items at once creates a single task exceeding 50ms
function processItems(items: Item[]) {
items.forEach(item => expensiveOperation(item));
}
// Good: periodic main thread yielding with 5ms chunking
async function processItemsYielding(items: Item[]) {
const DEADLINE_MS = 5;
let deadline = performance.now() + DEADLINE_MS;
for (const item of items) {
expensiveOperation(item);
if (performance.now() >= deadline) {
if ('scheduler' in window && 'yield' in window.scheduler) {
await window.scheduler.yield();
} else {
await new Promise<void>((r) => setTimeout(r, 0));
}
deadline = performance.now() + DEADLINE_MS;
}
}
}Example 5: Improving Bundle Splitting and Hydration Input Delay in React/Next.js
When interactions are slow immediately after page load in a React app, there's a good chance hydration is consuming the entire main thread. One important distinction here:
React.lazy() + Suspense is code splitting. It's a technique for deferring JS bundle loading to reduce the initial parsing burden, and it works in CSR environments too. React 18's Selective Hydration is different — it's a feature that processes hydration independently per Suspense boundary in a ReactDOM.hydrateRoot + streaming SSR environment. The two techniques differ in both effect and applicable conditions. Adding lazy() in a CSR environment can reduce Input Delay caused by bundle size, but it does not mean that hydration itself is handled non-blockingly.
// CSR + code splitting: defers the bundle of a heavy component to reduce initial parsing burden
import { Suspense, lazy } from 'react';
const HeavyWidget = lazy(() => import('./HeavyWidget'));
export default function Page() {
return (
<main>
<CriticalContent /> {/* Renders immediately */}
<Suspense fallback={<Skeleton />}>
<HeavyWidget /> {/* Bundle deferred */}
</Suspense>
</main>
);
}To also leverage selective hydration in an SSR/Next.js environment, ReactDOM.hydrateRoot and streaming rendering need to be combined. The Next.js App Router supports this structure by default.
Real-World Case Studies
Wix achieved a 40% improvement in interaction speed by combining selective hydration with Suspense. With hydration processed independently per Suspense boundary, the blocking intervals that affected the entire page were eliminated.
Preply got INP into the Good range without the App Router, using only event handler separation and code splitting. They reduced Processing Duration by separating heavy handlers with lazy() and adjusting the processing order — a case worth referencing because it can be approached without a framework migration.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Reflects entire session | While FID measured only the first input, INP looks at the p75 of all interactions within a session, making it much closer to the real user experience |
| Granular diagnosis | The three sub-metrics clearly distinguish where the bottleneck is, making "what to fix" explicit |
| 3rd-party attribution | With LoAF + web-vitals Attribution, even when external scripts are the cause, you can pinpoint down to the source URL |
| Direct SEO impact | As a Core Web Vitals metric, improvements are directly tied to search rankings |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Lab vs. Field gap | Cases that don't reproduce in DevTools (slow devices, battery saver mode) pull up the actual p75 | Always supplement with RUM data (web-vitals, DebugBear, etc.) |
| Outlier impact | A single extremely slow interaction can affect the 75th percentile | Monitor p95 and p99 as well and manage outliers separately |
| Framework differences | React synthetic events, Angular Zone.js, and Vue's reactivity system each affect Processing Duration differently | Combine framework-specific Profilers with DevTools |
RUM (Real User Monitoring) Performance data collected from actual users' browsers. Unlike Lab data measured directly in DevTools, it reflects a variety of device and network environments. Representative tools include DebugBear, SpeedCurve, and RUMvision.
The Most Common Mistakes in Practice
- Judging "it's fixed" based only on Lab data — Just because it doesn't reproduce in DevTools doesn't mean there's no problem in the Field. Validating with RUM data is necessary. Honestly, everyone falls into this trap at least once.
- Still using
isInputPending()— This was a recommended pattern in the past but is now heading toward deprecation. It has a bug where it can returnfalseeven when real input is present, so replacing it with thescheduler.yield()orsetTimeoutpattern is recommended. - Optimizing only the event handler without distinguishing the three phases — If Input Delay is the cause, no matter how lightweight you make the handler, the metric won't improve. Check the Attribution data first to see which phase is taking the most time, and the direction will become clear.
Closing Thoughts
The key to INP optimization is not the number 200ms itself, but first understanding which of the three sub-phases is leaking time. Attempting optimization by guessing, without Attribution data, means wasting time in the wrong place.
Three steps you can start right now:
- Set up collection: After
pnpm add web-vitals, add the Attribution code to production. Configure it to send data to an analytics service rather thanconsole.log, and you'll be able to immediately see which element's which phase is taking the most time in real user environments. - Read the diagnosis: Check which of
inputDelay/processingDuration/presentationDelayis the largest. Knowing this alone tells you whether the next action is "Long Task chunking," "handler separation," or "rendering optimization." - Apply the top-priority fix: If Processing Duration is large, try adding the
scheduler.yield()fallback pattern to the handler. It's one of the methods that shows the fastest INP improvement relative to the amount of code change.
References
- Interaction to Next Paint (INP) | web.dev
- Manually diagnose slow interactions in the lab | web.dev
- Find slow interactions in the field | web.dev
- Optimize long tasks | web.dev
- Long Animation Frames API | Chrome for Developers
- The Long Animation Frame API has now shipped | Chrome Blog
- Performance panel overview | Chrome DevTools
- How To Improve INP With Chrome DevTools | DebugBear
- How To Fix Missing INP Attribution Data With The LoAF API | DebugBear
- The Definitive Guide to Long Animation Frames (LoAF) | SpeedCurve
- How To Improve INP: Yield Patterns | Jacob 'Kurt' Groß
- React 19.2 Further Advances INP Optimization | Web Performance Calendar
- Improving INP with React 18 and Suspense | Vercel
- 40% Faster Interaction: How Wix Solved React's Hydration Problem | Wix Engineering
- How Preply improved INP on a Next.js application | Medium
- web-vitals v4 to support LoAF + INP breakdown | RUMvision
- GoogleChrome/web-vitals GitHub
- INP in Frameworks | Chrome Aurora
- Understanding INP | Google Codelabs