Core Web Vitals in Practice: Find the JavaScript Blocking Your Page
How do you find and reduce JavaScript that delays real user interactions?
In this article 13 sections
If a button feels slow to press, the browser is almost always stuck running one long, uninterruptible task instead of many short ones. Open your browser's performance profiler, record the interaction, and look for a single task over 50 milliseconds sitting between the click and the next paint. That task is what you fix, and the fix is almost never "write less JavaScript." It's "don't run it all at once."
The short version
- Interaction to Next Paint (INP) measures the whole gap between a user's input and the frame that shows the result. It's a Core Web Vital as of March 2024.
- A "long task" is any task that keeps the main thread busy for more than 50 milliseconds. One long task inside a click handler can eat most of your INP budget by itself.
- Chunking work into equal-sized pieces doesn't reliably fix this. What matters is the time each piece takes, not how many items it processes.
- The fix is to yield to the main thread between chunks of work, using
scheduler.yield()where it's supported and asetTimeoutfallback where it isn't. - Lab tools (DevTools, Lighthouse) show you a task on one machine, once. Field data (real-user INP) is what Google actually scores, and the two can disagree.
What is Interaction to Next Paint, and why does JavaScript cause it?
INP is the Core Web Vital that replaced First Input Delay in March 2024. Google's guidance breaks a single interaction into three phases: input delay, which "starts when the user initiates an interaction with the page, and ends when the event callbacks for the interaction begin to run"; processing duration, "the time it takes for event callbacks to run to completion"; and presentation delay, the time before the browser can paint the frame that shows the result.inp
The thresholds, measured at the 75th percentile of real visits and segmented across mobile and desktop: 200 milliseconds or less is good, over 500 milliseconds is poor, and everything between needs improvement.inp
JavaScript is almost always the reason processing duration blows the budget. If your click handler runs one function that takes 300 milliseconds, you've already spent one and a half times the entire "good" budget before the browser can paint anything, before it can handle the next tap, before it can even show a pressed state on the button the user just touched.
What counts as a long task?
The Long Tasks API defines it plainly: any task that takes longer than 50 milliseconds.longtasks Above that line, the browser can't interrupt what it's doing to respond to input or paint a frame. It's not that 50ms is a magic cliff. It's that the browser's main thread is single-threaded and cooperative: once a task starts, it runs to completion, and everything else waits.
This is why a page can have a fast Largest Contentful Paint and still feel sluggish. LCP measures how quickly content shows up. INP measures how quickly the page keeps responding after that, and a long task doesn't care whether the page already looks finished.
How do you find the interaction that is actually slow?
Start with whichever tool matches the question you're asking:
| Tool | Tells you | Doesn't tell you |
|---|---|---|
| Chrome DevTools Performance panel | Exactly which function blocked the main thread, and for how long, on this one recording | Whether real visitors hit the same interaction, or how their devices compare to yours |
| Lighthouse (lab) | A reproducible score under fixed throttling, good for catching regressions in CI | Real-world INP, which Lighthouse doesn't measure at all (it estimates Total Blocking Time instead) |
PerformanceObserver({type: 'longtask'}) in the field |
Real tasks over 50ms on real visits, attributable to a script and container | Nothing about tasks under 50ms, which can still add up |
The web-vitals library's INP attribution build |
Which specific interaction produced your field INP, plus its slowest event handler | Won't run in every browser; INP field measurement needs enough interactions to report |
For a first pass, record the interaction in DevTools, expand the Main track, and look for the widest red-flagged block. That block has a name (usually the function or event handler) and a duration. That's your starting point, not a guess. If you're doing this as part of a broader pass before a redesign or migration, it's one item on a longer website audit checklist worth working through at the same time.
What does a blocking task actually look like? A reproducible example
Here's a minimal case you can paste into any console. It sorts 4,000 items with a deliberately expensive comparator, synchronously, inside a click handler:
function expensiveCompare(a, b) {
let x = 0;
for (let i = 0; i < 900; i++) x += Math.sin(a * i) - Math.cos(b * i);
return x > 0 ? 1 : -1;
}
button.addEventListener('click', () => {
const data = Array.from({ length: 4000 }, () => Math.random());
data.sort(expensiveCompare); // the whole sort is one task
});
Measured with performance.now() around the click, on an unthrottled desktop Chrome 152 with no other tabs competing for the main thread (September 16, 2026): the handler didn't return control to the browser for 181 milliseconds, and the next available paint opportunity landed at the same mark. One task, one number, and it already eats most of the 200ms "good" INP budget before input delay or presentation delay are even counted.
Does splitting the work into equal chunks fix it?
The obvious first fix is to break the 4,000 items into, say, four batches of 1,000 and yield between them. Here's what that actually measured on the same machine, same data, same comparator:
| Chunk | Items | Duration |
|---|---|---|
| 1 | 1,000 | 78.8ms |
| 2 | 1,000 | 98.1ms |
| 3 | 1,000 | 105.9ms |
| 4 | 1,000 | 103.2ms |
Every single chunk still crossed the 50ms line. The mistake is treating "1,000 items" as a fixed unit of work. This particular sort inserts each item into a growing sorted array, so later insertions cost more than earlier ones: equal item counts are not equal time, and that's true of a lot of real UI code (filtering a list that's mutating, re-rendering rows whose complexity varies, laying out text of different lengths). Chunk by a measured time budget, not by a count you picked because it looked round.
How do you actually yield to the main thread?
The current guidance from web.dev's long tasks article recommends scheduler.yield() where it's available, falling back to a zero-delay setTimeout where it isn't (it explicitly no longer recommends isInputPending(), which was the earlier advice):longtasks
function yieldToMain() {
if (globalThis.scheduler?.yield) {
return scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
scheduler.yield() has real support now (Chrome and Edge 129+, Firefox 142+) but not everywhere (Safari doesn't have it yet), which is exactly why the fallback matters.
Applying that pattern with a time budget instead of an item count (process items until roughly 12ms have passed, then yield, and repeat) turned the same 4,000-item sort into 34 chunks, the longest of which measured 12.1ms. The click handler returned control to the browser in 13.3ms instead of 181ms: a 13.6x improvement in how quickly the page could respond to the next input. Total CPU time to finish went up slightly, to 406ms, because yielding has a small cost of its own. You're not making the work cheaper. You're making sure the browser never has to make the user wait through a single 181ms block to get any of it.
(Our own measurement used a MessageChannel-based yield rather than the setTimeout fallback above, specifically because setTimeout callbacks can get clamped hard in a backgrounded tab, which would have made the "after" numbers depend on whether the tab was in focus. Both post a task back onto the browser's queue; MessageChannel is just less penalized when the tab isn't visible. For most UI code running in a foregrounded tab, the scheduler.yield()/setTimeout pattern above is the one to reach for first.)
What changed when we applied this to our own site this week
This isn't only a synthetic example. On September 15, 2026, we found two service pages on upforge.io scoring under our own 90+ mobile Lighthouse bar, and JavaScript weight was part of both. ServiceHero, the component every service page shares, was prefetching two routes on first load whether or not a visitor ever hovered them, which cost 57KB of RSC payload per page before anyone asked for it. Turning that off (prefetch={false}, letting Next.js prefetch on hover and touch intent instead) and trimming unused JavaScript from a case study page (70KB flagged by Lighthouse) took that case study's mobile score from 80 to 94 and the maintenance page from 88 to 97, measured the same day, same URL, same mobile throttling profile, before and after.
That fix is a different mechanism from the chunking example above (less JavaScript shipped, rather than the same JavaScript run in smaller pieces), but it's the other half of the same reader question: finding and reducing the JavaScript that delays a real interaction. Sometimes the answer is "don't block the thread." Sometimes it's "don't send the code at all until someone's about to need it," which is also why the rendering strategy you pick up front matters: a page that ships less client JavaScript to begin with has fewer long tasks to go chunk later. A proper audit checks both. If you want that kind of pass over your own site's performance, that's the conversation to start.
Where do Lighthouse and field data disagree, and why does it matter?
Lighthouse runs once, on one simulated device, under fixed network and CPU throttling, and it doesn't measure INP directly: it estimates Total Blocking Time as a proxy. Real INP comes from actual visits, on whatever phone and network a visitor actually has, measured at the 75th percentile across your traffic.inp Those numbers can and do disagree. A page can pass Lighthouse comfortably and still post a "needs improvement" field INP, because your median visitor's phone is nowhere near your test machine, or because the slow interaction only shows up on a page state Lighthouse never triggers (a filled-out form, a populated cart, a search with real results in it).
Treat Lighthouse as a fast way to catch an obvious regression before it ships. Treat field data, whether from the Chrome UX Report or your own web-vitals instrumentation, as the actual scorecard. If the two disagree, believe the field data and go find out why.
Frequently Asked Questions
Is a long task always something the user notices?
Not always. A long task that happens while nothing is animating and no input arrived during it can pass unnoticed. It becomes visible the moment it overlaps with a click, a scroll, or a keystroke, which is exactly why the same code can feel fine in a quiet demo and sluggish under real traffic.
Does this apply to work other than JavaScript, like layout or CSS?
Yes. Anything that runs on the main thread counts toward a task's duration, including forced synchronous layout ("layout thrashing," where reading a layout property like offsetHeight right after changing a style forces the browser to recalculate early) and expensive style recalculation. The fix is the same principle: find the task, measure it, and don't make the user wait through all of it at once.
What is a realistic INP target for a page with a lot of interactivity?
The same 200ms "good" threshold applies regardless of how interactive the page is, which is part of why it's a hard target for complex dashboards and editors. The practical path is usually the one in this guide: audit your actual event handlers for tasks over 50ms, chunk the expensive ones by time budget, and re-measure. There's no separate, easier bar for busier pages.
Want a second pair of eyes on it?
The steps above will find the obvious cases yourself: record the interaction, find the task over 50ms, chunk it by time budget instead of item count. If you'd rather have someone else run that pass across a whole site, including the JavaScript-weight side of the problem (what's shipping, not just what's blocking), talk to us about performance or start with a free website audit to see what's actually slowing your pages down before you commit to fixing anything.
Sources and how this was measured
The synchronous-vs-chunked numbers above came from a script run in a Chromium 152 instance with no CPU throttling applied, measured with performance.now() around a scripted click and a requestAnimationFrame callback as the next-paint proxy, on September 16, 2026. That's a reasonable way to see the relative effect of yielding, not a stand-in for a real mid-range phone: run the same two snippets in DevTools with 4x or 6x CPU throttling turned on if you want numbers closer to what your mobile visitors experience. Nothing here claims a specific ranking or AI-answer placement outcome; it's a description of what we measured and how to reproduce it.
Sources
- web.dev, "Optimize Interaction to Next Paint" (published May 19, 2023; last updated September 2, 2025). https://web.dev/articles/optimize-inp
- web.dev, "Optimize long tasks" (published September 30, 2022; last updated December 19, 2024). https://web.dev/articles/optimize-long-tasks
Founder & CEO, Upforge
Ramsey Deal is the Founder and CEO of Upforge, a web development and software engineering company focused on building high performance SaaS products and custom web applications. With over 8 years of experience across development, branding, and growth, Ramsey brings a full stack perspective to building digital products that not only function but drive measurable business outcomes. Upforge is the evolution of Uptrade Media, a full service marketing and branding agency originally founded by Ramsey. Through that work, he led hundreds of projects spanning logo design, brand systems, video production, paid advertising, and go to market strategy, helping businesses launch and scale from the ground up. In addition to Upforge, Ramsey is the founder of Sonor, a SaaS platform that provides Next.js based web applications with an AI first backend covering SEO, analytics, and reputation management. This experience building both client projects and internal products informs his approach to development, with a focus on speed, scalability, and real world performance. Ramsey’s work sits at the intersection of product, engineering, and marketing. He focuses on building software that aligns with positioning, user behavior, and conversion strategy, not just technical requirements. He regularly writes about SaaS development, web architecture, and the realities of launching and scaling digital products.
Why trust this article
Written by Ramsey Deal, Founder & CEO of Upforge, with 8+ years of experience specializing in SaaS Development, Web Application Development, Startup MVP Development.
Last updated: September 16, 2026