Your LCP is green, the bundle is optimized, Lighthouse hands you a 95, and someone on the team still says "the app feels slow." They're not wrong. They're describing something your load metric can't see: what happens when they tap a button and the interface takes a beat to react. That feeling has a name, and since 2024 it's a Core Web Vital: INP, Interaction to Next Paint. It measures the worst-case latency between a user interacting and the screen responding. A product can load blazingly fast and still feel like junk, because loading and responding are two different problems. This is the one I've had the hardest time explaining to people who only stare at the load number.
What INP measures, and why LCP doesn't cover it
LCP (Largest Contentful Paint) answers "how long until the main content showed up?" It's a startup problem: it happens once, at the beginning. INP is something else entirely. Across the whole session, every click, every tap, every keystroke fires a cycle of "browser receives the event → runs your JavaScript → repaints." INP keeps the worst of those latencies, and that's your grade.
The distinction matters because modern apps live in interaction, not in load. A dashboard, an editor, a filter panel: the user loads once and then interacts a thousand times. If every filter they toggle freezes the UI for 300ms, your perfect LCP won't save you. A good INP threshold is 200ms; above 500ms it feels broken.
The culprit is almost always the same: long tasks
The browser runs your JavaScript on a single thread. While a function is running, it can't repaint or handle another event until that function returns. If clicking kicks off a computation or a render that takes 250ms, then for those 250ms the interface is dead: the button doesn't show its active state, nothing moves. That's a "long task," and it's 90% of the INP problems I've debugged.
The classic pattern: a search input that, on every keystroke, filters and re-renders a list of 2,000 items. Each press rebuilds the whole tree before letting the browser paint the very text you're typing. The result is an input that stutters even though the filtering is trivial.
// Find where the time goes: log any task that blocks >50ms
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long task:', Math.round(entry.duration), 'ms', entry);
}
}).observe({ type: 'longtask', buffered: true });
Before optimizing anything, I measure. The longtask PerformanceObserver tells you which interactions block the thread and for how long. Don't guess: it's almost never what you think.
Fixing it in React: separate the urgent from what can wait
The key idea is that not everything an interaction triggers is equally urgent. When you type into a search box, showing the character you just typed is extremely urgent; recomputing the filtered list can wait 50ms without anyone noticing. React 18 gave me the tools to say exactly that.
useDeferredValue marks a value as "low priority": React paints the urgent input (the text in the box) first and recomputes the derived work afterward, without blocking.
function Search({ items }) {
const [query, setQuery] = useState('');
const deferred = useDeferredValue(query);
// Recomputed from the deferred value, not on every keystroke
const filtered = useMemo(
() => items.filter((i) => i.name.includes(deferred)),
[items, deferred],
);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<List items={filtered} />
</>
);
}
The input responds instantly because its render doesn't wait for the filtering. For heavier actions — switching tabs, applying a filter that reshuffles half the screen — I reach for useTransition, which wraps the expensive update and lets the click show its feedback before the work starts:
const [pending, startTransition] = useTransition();
function onFilter(next) {
setActiveFilter(next); // urgent: the button marks itself now
startTransition(() => {
setResults(recompute(next)); // non-urgent: doesn't block the click
});
}
When there's no React involved: yield the thread
Sometimes the heavy work isn't a render, it's a loop: processing a CSV, transforming a large JSON. There the fix is to slice the long task into chunks and hand control back to the browser between them, so it can handle clicks and repaint. The modern way is scheduler.yield(); the timeless fallback is setTimeout(0).
async function process(rows) {
for (let i = 0; i < rows.length; i++) {
work(rows[i]);
if (i % 100 === 0) await yieldToMain(); // breathe every 100 rows
}
}
const yieldToMain = () =>
'scheduler' in window && 'yield' in scheduler
? scheduler.yield()
: new Promise((r) => setTimeout(r, 0));
If the work is genuinely CPU-bound and doesn't need the DOM, the right answer isn't chunking but moving it into a Web Worker: another thread, zero blocking of the main one. But for most cases, yielding the thread every N iterations already turns a freeze into something imperceptible.
What I learned by measuring, not guessing
INP taught me to stop conflating "fast to load" with "fast to use." They're different axes, and the user feels the second one most, because they spend 99% of their time interacting, not loading. The good news is it almost always gets fixed without rewriting anything: you find the long task with longtask, decide what's urgent and what can wait, and use the right tool — useDeferredValue, useTransition, yielding the thread, or a worker. My rule is simple: if an interaction blocks the thread for more than 200ms, it's not an optional micro-optimization, it's a quality-perception bug. And perception bugs are the ones that make people say "I don't know, it feels slow" and not come back.