HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 1012 min read

INP & Long Tasks — Yielding to the Main Thread

Your Handler Was Fast, the Main Thread Wasn't

Web developmentweb development / performance

The scene

Your search box filters a 5,000-row table. You profile the keypress handler: 0.9ms. Lighthouse gives you 98. You ship.

Two weeks later, field data (CrUX / web-vitals):

INP p75 = 470ms   → "Poor" (good is ≤200ms, poor is >500ms)

Nothing in your handler is slow. So what is 470ms?

INP is not handler duration

INP measures interaction → next paint, in three parts:

  1. Input delay — the browser got your click but the main thread was busy. Your handler hasn't even started.
  2. Processing — your listeners actually running.
  3. Presentation delay — handler done, but style/layout/paint for that frame still has to finish before the pixels land.

Devtools showed you part 2 only. Your 470ms is almost certainly 1 and 3: an analytics beacon serializing, a hydration chunk, or your own 300ms re-render of 5,000 rows blocking the paint you're being measured on.

A task over 50ms is a "long task". Anything a user taps during one eats that whole remainder as input delay.

Build the fix in 5 lines

The cure for a long task is to break it and actually yield:

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

async function chunked(items, work, size = 50) {
  for (let i = 0; i < items.length; i += size) {
    items.slice(i, i + size).forEach(work);
    await yieldToMain();          // paint + pending input get a turn
  }
}

What each piece teaches:

  • scheduler.yield() resumes at the FRONT of the task queue — you keep your place in line. setTimeout(0) goes to the back, so other work can jump ahead. Hence the ordered fallback, not setTimeout everywhere.
  • The await is the whole point: it ends the current task. Rendering can only happen between tasks.
  • size=50 is a dial. Measure, don't guess: you want each chunk under ~50ms on a mid-tier Android, not on your laptop.

What practitioners get wrong

  • await Promise.resolve() / queueMicrotask — microtasks run inside the same task. You yielded nothing. The frame still can't paint.
  • requestAnimationFrame for heavy work — rAF runs BEFORE paint, so you've just moved the block into the critical frame. Use it for DOM writes, not computation.
  • Debouncing input — hides processing cost but does nothing for presentation delay, and the final burst is still one long task.
  • Testing on a fast machine. Use Devtools → Performance → CPU throttle 4x, and Web Vitals extension. Lab numbers and INP disagree by design: INP is field, p75, worst interaction of the visit.
  • Chasing INP without finding the right interaction. The expensive one is usually a menu open or a tab switch, not the thing you assumed.

Real lever most of the time: render fewer nodes (virtualize the list) before you optimize the loop around them.

Your exercise

In any app you have, open Devtools → Performance, throttle CPU 4x, record, and click your heaviest control. Find the long task in the flame chart, then split one number: how much of the interaction was input delay vs processing vs presentation delay?

Then answer this: if your handler measures 2ms but INP is 400ms, which of the three parts can chunking with yieldToMain NOT fix — and why?