When a user visits your site or app, their browser dedicates a single thread to running your JavaScript, handling their interactions, and painting what they see on the screen. This is the main thread, and it’s the direct link between your code and the person using it.
As developers, we often use it without considering the end user and their device, which could be anything from a mid-range phone to a high-end gaming rig. We don’t think about the fact that the main thread doesn’t belong to us; it belongs to them.
I’ve watched this mistake get repeated for years: we burn through the user’s main thread budget as if it were free, and then act surprised when the interface feels broken.
Every millisecond you spend executing JavaScript is a millisecond the browser can’t spend responding to a click, updating a scroll position, or acknowledging that the user did just try to type something. When your code runs long, you’re not causing "jank" in some abstract technical sense; you’re ignoring someone who’s trying to talk to you.
Because the main thread can only do one thing at a time, everything else waits while your JavaScript executes: clicks queue up, scrolls freeze, and keystrokes pile up hoping you’ll finish soon. If your code takes 50ms to respond nobody notices, but at 500ms the interface starts feeling sluggish, and after several seconds the browser may offer to kill your page entirely.
Users don’t know why the interface isn’t responding. They don’t see your code executing; they just see a broken experience and blame themselves, then the browser, then you, in that order.
The 200ms You Don’t Own
Browser vendors have spent years studying how humans perceive responsiveness, and the research converged on a threshold: respond to user input within 100ms and the interaction feels instant, push past 200ms and users notice the delay. The industry formalized this as the Interaction to Next Paint (INP) metric, where anything over 200ms is considered poor and now affects your search rankings.
But that 200ms budget isn’t just for your JavaScript. The browser needs time for style calculations, layout, and painting, so your code gets what’s left: maybe 50ms per interaction before things start feeling wrong. That’s your allocation from a resource you don’t own.
The Platform Has Your Back
The web platform has evolved specifically to help you be a better guest on the main thread, and many of these APIs exist because browser engineers got tired of watching developers block the thread unnecessarily.
Web Workers let you run JavaScript in a completely separate thread. Heavy computation, whether parsing large datasets, image processing, or complex calculations, can happen in a Worker without blocking the main thread at all:
// Main thread: delegate work and stay responsive
const worker = new Worker('heavy-lifting.js');
// Send a large dataset from the main thread to the worker
// The worker then processes it in its own thread
worker.postMessage(largeDataset);
// Receive results back and update the UI
worker.onmessage = (e) => updateUI(e.data);
Workers can’t touch the DOM, but that constraint is deliberate since it forces a clean separation between "work" and "interaction."
requestIdleCallback lets you run code only when the browser has nothing better to do. (Due to a WebKit bug, Safari support is still pending at time of writing.) When the user is actively interacting, your callback waits; when things are quiet, your code gets a turn:
requestIdleCallback((deadline) => {
// Process your tasks from a queue you created earlier
// deadline.timeRemaining() tells you how much time you have left
while (tasks.length && deadline.timeRemaining() > 0) {
processTask(tasks.shift());
}
// If there are tasks left, schedule another idle callback to complete later
if (tasks.length) requestIdleCallback(processRemainingTasks);
});
This is ideal for non-urgent work like analytics, pre-fetching, or background updates.
isInputPending (Chromium-only for now) is perhaps the most user-respecting API of the lot, because it lets you check mid-task whether someone is waiting for you:
function processChunk(items) {
// Process items from a queue one at a time
while (items.length) {
processItem(items.shift());
// Check if there’s pending input from the user
if (navigator.scheduling?.isInputPending()) {
// Yield to the main thread to handle user input,
// then resume processing after
setTimeout(() => processChunk(items), 0);
// Stop processing for now
return;
}
}
}
You’re explicitly asking "is someone trying to get my attention?" and if the answer is yes, you stop and let them.
The Subtle Offenders
The obvious main thread crimes like infinite loops or rendering 100,000 table rows are easy to spot, but the subtle ones look harmless.
Calling JSON.parse(), for example, on a large API response blocks the main thread until parsing completes, and while this feels instant on a developer’s machine, a mid-range phone with a throttled CPU and competing browser tabs might take 300ms to finish the same operation, ignoring the user’s interactions the whole time.
The main thread doesn’t degrade gracefully; it’s either responsive or it isn’t, and your users are running your code in conditions you’ve probably never tested.
Measure What You Spend
You can’t manage what you can’t measure, and Chrome DevTools’ Performance panel shows exactly where your main thread time goes if you know where to look. Find the "Main" track and watch for long yellow blocks of JavaScript execution. Tasks exceeding 50ms get flagged with red shading to mark the overtime portion. Use the Insights pane to surface these automatically if you prefer a guided approach. For more precise instrumentation, the performance.measure() API lets you time specific operations in your own code:
// Mark the start of a heavy operation
performance.mark('parse-start');
// The operation you want to measure
const data = JSON.parse(hugePayload);
// Mark the end of the operation
performance.mark('parse-end');
// Name the measurement for later analysis
performance.measure('json-parse', 'parse-start', 'parse-end');
The Web Vitals library can capture INP scores from real users across all major browsers in production, and when you see spikes you’ll know where to start investigating.
The Framework Tax
Before your application code runs a single line, your framework has already spent some of the user’s main thread budget on initialization, hydration, and virtual DOM reconciliation.
This isn’t an argument against frameworks so much as an argument for understanding what you’re spending. A framework that costs 200ms to hydrate has consumed four times your per-interaction budget before you’ve done anything, and that needs to be a conscious choice you’re making, rather than an accident.
Some frameworks have started taking this seriously: Qwik’s "resumability" avoids hydration entirely, while React’s concurrent features let rendering yield to user input. These are all responses to the same fundamental constraint, which is that the main thread is finite and we’ve been spending it carelessly.
Borrow, Don’t Take
The technical solutions matter, but they follow from a shift in perspective, and when I finally internalized that the main thread belongs to the user, not to me, my own decisions started to change.
Performance stops being about how fast your code executes and starts being about how responsive the interface stays while your code executes. Blocking the main thread stops being an implementation detail and starts feeling like taking something that isn’t yours.
The browser gave us a single thread of execution, and it gave our users that same thread for interacting with what we built. The least we can do is share it fairly.
Top comments (0)