Debouncing delays work. A Web Worker removes it. That distinction sounds small, but it’s the reason so many teams “fix” a slow interaction only to see their Interaction to Next Paint (INP) score barely move. If you’ve been treating these two techniques as interchangeable solutions to a sluggish main thread, this post is for you.
INP became a Core Web Vital because it measures something users feel directly: the lag between tapping a button, typing in a field, or opening a menu, and the browser actually painting a response. Long tasks on the main thread are the primary cause of that lag, and Web Workers are one of the few tools that address the root cause rather than rearranging the symptoms. Let’s separate the myths from what the browser is actually doing.
Myth #1: “A slow interaction means my event handler code is slow.”
This is the assumption most developers start with, and it’s only half true.
Reality: The main thread doesn’t care where the work came from.
INP measures the total time from user input to the next paint, and that clock includes far more than your onClick handler. It includes any queued layout work, style recalculation, third-party scripts still running, and — critically — any JavaScript task that happens to be occupying the thread at the exact moment the user tapped the screen. A 200ms JSON.parse() call kicked off by an unrelated background fetch can block a button click just as effectively as a bloated click handler.
The main thread is single-threaded and strictly ordered. Whatever got there first has to finish before your response to the user can even begin. That’s the entire problem Web Workers exist to solve: they give you a second thread with its own event loop, so heavy computation doesn’t sit in that queue at all.
Myth #2: “Debouncing or throttling solves long-task problems.”
Debouncing feels like a performance fix because it reduces how often a function runs. It doesn’t reduce how long that function takes when it finally does run.
Reality: Debouncing hides the cost; it doesn’t remove it.
Consider a search-as-you-type feature that filters 50,000 records on every keystroke. Debouncing to fire only after 300ms of inactivity means the filter runs less frequently — but when it does run, it still blocks the main thread for however long that filtering operation takes. If that operation takes 180ms, the user’s next interaction (say, clicking a result) still has to wait behind it. You’ve reduced the frequency of jank, not its severity.
// Debounced, but still fully blocking when it fires
const debouncedFilter = debounce((query) => {
const results = massiveArray.filter(item => item.name.includes(query)); // still 180ms on main thread
renderResults(results);
}, 300);
Moving that same filtering logic into a worker doesn’t just delay the cost — it relocates it entirely, off the thread that paints your UI and responds to input.
// worker.js
self.onmessage = (e) => {
const { query, data } = e.data;
const results = data.filter(item => item.name.includes(query));
self.postMessage(results);
};
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ query, data: massiveArray });
worker.onmessage = (e) => renderResults(e.data);
The main thread is now free to handle clicks, scrolls, and paints while the filtering happens elsewhere. This is the difference that shows up in your INP field data.
Myth #3: “Web Workers can’t touch the DOM, so they’re not useful for UI performance.”
It’s true that workers have no access to document, window, or the DOM tree. Many developers stop there and conclude workers are irrelevant to front-end performance work.
Reality: Most expensive main-thread tasks never needed the DOM in the first place.
Look at what actually eats main-thread time in a typical interaction: parsing large JSON payloads, sorting or filtering big arrays, running validation logic across a large form, computing diffs, encoding or decoding data, running client-side search indexes, or crunching numbers for a chart library. None of that requires reading or writing to the DOM — it’s pure computation that happens to run on the main thread only because that’s where the code was written.
The pattern is almost always the same: do the expensive computation in the worker, send back a plain data result, and let the main thread handle only the cheap, unavoidable part — updating the DOM with that result.
// A common misconception: "workers can't help my UI"
// Reality: the UI update is cheap. The computation before it wasn't.
worker.onmessage = (e) => {
// This part is fast — a few DOM writes
listElement.textContent = e.data.summary;
};
Once you start categorizing tasks as “pure computation” versus “DOM manipulation,” you’ll notice that a surprising share of your long tasks fall into the first bucket.
Myth #4: “Setting up a Web Worker is too much overhead for the gain.”
There’s a real cost here worth naming honestly: workers can’t share memory directly with the main thread by default, so data has to be serialized and copied through postMessage. For very large or very frequent transfers, that serialization cost can eat into the savings.
Reality: For most CPU-bound tasks, the transfer cost is trivial compared to the computation cost.
Serializing a 2MB JSON object typically costs single-digit milliseconds. If the computation you’re offloading takes 150ms, that’s still a 140ms-plus win for the main thread — and, importantly, all of it now happens without competing with paint or input handling. When transfer size becomes a genuine bottleneck, Transferable objects (like ArrayBuffer) let you hand off memory without copying it at all, which removes even that small cost.
There’s also tooling that reduces the boilerplate significantly. Libraries like Comlink let you call worker functions as if they were local async functions, hiding the message-passing entirely:
// Using Comlink to avoid manual postMessage plumbing
import { wrap } from 'comlink';
const workerApi = wrap(new Worker('worker.js'));
const results = await workerApi.filterData(query, massiveArray);
The setup cost is real but small, and it’s paid once per feature — not once per interaction.
Finding the Tasks Worth Offloading
Not every function belongs in a worker. The candidates worth targeting share three traits: they run on the main thread, they take a measurable chunk of time (generally 50ms or more), and they don’t require synchronous DOM access mid-execution. Chrome DevTools’ Performance panel, along with the newer Long Animation Frames (LoAF) API, will show you exactly which scripts are stretching your INP by attributing blocking time to specific functions and their call stacks. Start there rather than guessing — offloading the wrong 10ms function into a worker adds complexity without meaningfully improving what users experience.
Before you reach for another debounce wrapper or a requestIdleCallback hack, ask yourself a simpler question: is this task actually expensive, or is it just badly timed? If it’s expensive, no amount of scheduling trickery will make the main thread stop feeling it — only moving the work off that thread will. Audit one slow interaction on your site this week using the Performance panel, and see how much of the blocking time could simply live somewhere else.
🔗 Recommended Reading
- The Repeat-Visit Paradox: How Service Workers Quietly Reshape Core Web Vitals
- Edge Computing and Core Web Vitals: What Actually Moves the Needle
- Your DOM Has a Weight Problem: A Step-by-Step Guide to Fixing Excessive DOM Size
- Core Web Vitals in Single-Page Applications: Why the Basics Aren't Enough
- Performance Budgets, Ranked: The 5 Metrics Worth Enforcing (and How to Do It)