View contents
JavaScript Performance Guide: Freeing Up the Main Thread
Introduction
JavaScript is the most common cause of slow INP and low scores on Lighthouse's "Minimize main thread work" audit. Every script your page loads has to be downloaded, parsed, compiled, and executed on the main thread—the same thread that processes clicks, scrolling, and typing. This guide covers concrete techniques for reducing that work. The UXR SEO Analyzer reports INP and flags heavy third-party JavaScript in its "Performance" tab.
Unlike CSS or images, JavaScript costs the main thread at every stage of its lifecycle—download, parse, compile, and execute—so a script that looks small on the network waterfall can still be expensive if it does a lot of work once it runs. Measuring only file size misses this; the techniques below target actual main thread time instead.
When Lighthouse Flags Your JavaScript
Chrome documents explicit thresholds for two related audits:
- Reduce JavaScript execution time: Lighthouse shows a warning when JavaScript execution takes longer than 2 seconds, and the audit fails when execution takes longer than 3.5 seconds
- Minimize main thread work: Lighthouse flags pages that keep the main thread busy for longer than 4 seconds during load
- Reduce the impact of third-party code: Lighthouse flags third-party scripts that block the main thread for 250 ms or longer
These three numbers—2s, 3.5s, 4s, and 250ms—are the concrete targets to measure your page against, not arbitrary thresholds.
Technique 1: Code Splitting
Instead of shipping a single bundle with all the application's code, split JavaScript by route or by component, so the browser only downloads and executes what the current page needs:
// Before: static import, everything in the initial bundle
import Dashboard from './Dashboard';
// After: dynamic import, only loaded when needed
const Dashboard = React.lazy(() => import('./Dashboard'));
Any route the user never visits never gets downloaded, parsed, or executed—removing that main thread work entirely.
Technique 2: Remove Unused Code
Modern bundlers' "tree shaking" removes exports that no module imports, but it only works well with ES module code that has no hidden side effects. Regularly audit with Chrome DevTools' Coverage report to find JavaScript that gets downloaded but never executed.
Technique 3: Use Web Workers for Heavy Computation
Any calculation that doesn't need to touch the DOM—parsing large datasets, image processing, math-heavy computation—can move to a Web Worker, which runs on a separate thread and doesn't block page interactivity:
// main.js
const worker = new Worker('data-processor.js');
worker.postMessage(largeDataset);
worker.onmessage = (e) => renderResults(e.data);
// data-processor.js — runs off the main thread
self.onmessage = (e) => {
const result = processHeavyComputation(e.data);
self.postMessage(result);
};
Technique 4: Break Up Long Tasks
Chrome's Long Animation Frames API measures frames longer than 50ms and calculates a "blockingDuration"—the sum of the portion of each task exceeding that threshold. Reducing or eliminating blockingDuration by breaking long tasks into smaller chunks is key to improving responsiveness as measured by INP, even if total work duration doesn't change.
// Before: one long task blocks the main thread
processAllItems(items);
// After: split into chunks, yielding control between each one
async function processInChunks(items) {
for (const chunk of chunksOf(items, 50)) {
processChunk(chunk);
await new Promise(resolve => setTimeout(resolve, 0)); // yield the thread
}
}
Technique 5: Debounce Heavy Event Handlers
Event handlers that run on every scroll, resize, or input event can saturate the main thread. Use debouncing or throttling to limit how often heavy logic runs.
Auditing Third-Party Scripts
Third-party scripts—analytics, live chat, ad pixels—are a frequent source of main thread work you don't directly control. Use Chrome DevTools' Network tab and Performance panel to identify which ones block the thread for more than 250 ms, and evaluate whether they can be lazy-loaded or removed.
An effective strategy is the "facade" pattern: render a lightweight placeholder (for example, a static thumbnail of an embedded video) and load the real widget's heavy script only when the user interacts with it. This delays third-party execution until it actually delivers value, instead of competing with your main content for the thread from the very start.
Framework-Specific Costs
Modern frameworks add their own main thread work on top of application code:
- Hydration in SSR/SSG frameworks (React, Vue): the browser has to re-run the component tree on the client to attach event listeners, even though the HTML is already rendered. This can dominate main thread work on pages with many interactive components
- Unnecessary re-renders: components that re-render without their output changing burn CPU cycles for no benefit; use React or Vue DevTools profiling to spot them
- Importing entire utility libraries: pulling in a whole library to use one function bloats the initial bundle; prefer specific imports when your bundler supports them
Selective or partial hydration—hydrating only the visible interactive components instead of the whole tree at once—is the most effective technique for reducing this cost in large SSR applications.
A Practical Example
A listing page reports an INP of 620 ms—in the "Poor" zone. The breakdown of the slowest interaction (opening a filter panel) shows: input delay 40 ms, processing duration 540 ms, presentation delay 40 ms. Processing duration dominates, so the problem is in the filter's event handler, not prior main thread work.
Profiling with the Performance panel reveals the handler synchronously recalculates the result count for all 40 filter categories on every click. The fix: split that recalculation into chunks that yield the thread every 50ms, and memoize counts that haven't changed since the last render. INP drops into the "Good" zone without touching any other part of the code.
JavaScript Performance Checklist
- [ ] JavaScript split by route with dynamic imports
- [ ] Unused code removed (verified with the Coverage report)
- [ ] Heavy computation moved to Web Workers when it doesn't require the DOM
- [ ] Long tasks broken into chunks that yield the main thread
- [ ] Scroll/resize/input event handlers debounced or throttled
- [ ] Third-party scripts audited; none block the thread for more than 250 ms
Frequently Asked Questions
Does code splitting improve LCP or only INP?
Mainly INP and JavaScript execution time. However, if the LCP element depends on a React component hydrating, reducing the initial bundle can also speed up that render.
How many chunks should I use when breaking up a long task?
There's no fixed number—the goal is for no individual chunk to exceed the 50ms that the Long Animation Frames API defines as the long-task threshold. Adjust chunk size based on your real users' hardware.
Can Web Workers access the DOM?
Not directly. Web Workers run in a separate global context with no DOM access; they must communicate with the main thread via messages (postMessage) so it can update the UI.
Does the facade pattern work for every third-party script?
It works best for clearly interactive widgets—embedded videos, live chat, maps. For analytics scripts that must run on initial load, the right strategy is loading them with async at the lowest network priority possible, not deferring their execution entirely.
Related Guides
References
- Chrome Developers - Reduce JavaScript execution time
- Chrome Developers - Minimize main thread work
- Chrome Developers - Reduce the impact of third-party code
- Chrome Developers - Long Animation Frames API
