Why the main thread is the bottleneck
A task that runs for more than 50 milliseconds is called a long task. While it runs, the browser cannot respond to input or paint a new frame, so the user experiences lag (source 1). Long tasks come from many places: evaluating large bundles at start-up, hydration, data processing in event handlers, third-party tags, and layout work forced by code that reads geometry right after changing the DOM.
Chrome's Long Animation Frames API looks at the problem from the user's side. Instead of single tasks, it reports frames whose rendering update was delayed beyond 50 ms, and it attributes the delay to the scripts involved, including the function, source file and how the script was invoked (source 2). Together with traces, that attribution is what lets us name causes precisely rather than pointing at "too much JavaScript".
What we work on
- Start-up cost. How much script is parsed and run before the page can respond, and how much of it the first screen needs.
- Long handlers. Click, input and keyboard handlers that do too much synchronously, such as filtering large lists, recalculating layouts or updating many components at once.
- Layout thrashing. Code that alternates between writing to the DOM and reading layout values, forcing the browser to recalculate layout repeatedly within one task. Long Animation Frames reports this as forced style and layout duration per script (source 2).
- Re-render storms. Framework state changes that cause far more components to re-render than the change requires.
- Timers and observers. Polling, scroll handlers and observers that wake the main thread more often than needed.
- Third-party code inside the app. Analytics, chat widgets and tag managers measured in context, so the team can weigh their value against their cost.
Techniques the plan may include
Breaking work into smaller pieces and yielding between them is the core technique. Chrome's documentation recommends scheduler.yield() because, unlike a setTimeout based yield, it continues your remaining work ahead of other queued tasks, so the rest of your code is not pushed to the back of the line (source 3). It is available in Chromium-based browsers from version 129 and in Firefox from version 142, and not in Safari at the time of writing, so plans include a fallback (source 3).
Other techniques depend on the cause: moving pure computation to a Web Worker, deferring work until after the next paint, virtualizing long lists, memoizing expensive renders, code splitting rarely used features, and removing dependencies whose cost outweighs their use. The long tasks guide explains several of these with code.
How the engagement runs
- 01
Trace on realistic hardware settings
We record start-up and key interactions with CPU throttling, because a task that takes 40 ms on a developer laptop can take several times longer on a mid-range phone.
- 02
Attribute
Each long task and long frame is traced back to a script, function or component, with screenshots from the trace.
- 03
Size and rank
Causes are ranked by how often users hit them and how long they block, not by how easy they are to fix.
- 04
Plan
Specific, code-level suggestions where we have source access, or module-level suggestions where we only have bundles and source maps.
What you receive
A written report with annotated traces, a ranked change list, example code for the main techniques in your framework, and a walkthrough. You can add pull request review and retesting if your team wants a second pair of eyes during implementation.
An invented example of the work
A dashboard updates twelve charts when a user changes a date range. In a throttled trace, the change handler runs as one 700 ms task: it recalculates every aggregate, updates state for each chart and triggers a full re-render. Long Animation Frames attribution points at the handler as the invoker, with a large forced layout share caused by code that measures chart containers after each update.
The plan for that case would read roughly like this: update the date control and show a pending state first, then yield; batch container measurements before any DOM writes; move aggregate calculation for large ranges into a Web Worker; and render charts that are off screen only when they scroll into view. Each step is small, and together they turn one long task into several short ones with a paint early on. The numbers here are illustrative; the pattern is common in data-heavy apps.
Keeping the gains
Main-thread work tends to creep back as features are added. We suggest guardrails your team can keep after the engagement: a performance budget for script evaluation on key routes in continuous integration, a trace recorded on a throttled profile before major releases, and field monitoring of INP phases so a regression shows up within days rather than a month later.
Questions about main-thread work
Is a smaller bundle always faster?
Smaller tends to help, but when and how code runs matters as much as its size. A small script that runs a heavy loop in a click handler can hurt more than a large one that loads lazily and idles. We look at execution, not only bytes.
Should we move everything to Web Workers?
No. Workers suit pure computation that does not touch the DOM, such as parsing or sorting large data. Work that must update the page still returns to the main thread, and moving data between threads has its own cost.
Does this help Interaction to Next Paint?
Directly. Input delay grows when the main thread is busy at the moment of a tap, and processing time grows when handlers do too much. Both are parts of INP (source 4).
Sources
- Optimize long tasks, web.dev Primary source (Google)
- Long Animation Frames API, Chrome for Developers Primary source (Google)
- Use scheduler.yield() to break up long tasks, Chrome for Developers Primary source (Google)
- Interaction to Next Paint (INP), web.dev Primary source (Google)