Why Is the Rule Everyone Repeats Wrong?
Never block the main thread. You have heard it, I have repeated it, and it is close enough to true that it survives unexamined for years.
Victor Ayomipo pulled at it in a piece for Smashing Magazine and landed somewhere more useful: never block the main thread for too long. That is not a softening. It is a different instruction, because it turns an absolute into a budget, and budgets can be measured.
For a motion designer, this matters more than it does for most web developers. Your animation lives on that thread unless it happens to be one of the properties the compositor handles. Every choice about where computation happens is, indirectly, a choice about whether your easing curve holds.
Which Three Numbers Are Worth Memorising?
The budget has edges, and they are specific:
- 16.6ms is the frame. At 60fps the browser has that long to paint. Miss it and you have dropped a frame, which a viewer reads as a stutter rather than as a number.
- 50ms is where a task officially becomes a long task. Past that, input feels unresponsive rather than merely slow.
- Roughly 1 second of blocking can be defensible after an explicit user action, because the user has a mental model of having asked for something.
That last one is the interesting concession. There is a real difference between an interface that freezes while you scroll, which feels broken, and one that pauses briefly after you clicked "export", which feels like work happening. Same technical event, entirely different reading.
Why Can Moving Work Off-Thread Cost More?
Here is the part that changed how I think about it.
Ayomipo built a Chrome screenshot extension and did the correct thing: pushed image processing into an Offscreen Document so the main thread stayed free. The result was a 2 to 3 second lag. The image payload was over a megabyte of Base64, and shipping it across contexts meant JSON serialisation in both directions. The transfer dwarfed the work.
His fix was to process on the main thread in the content script. Fewer round trips, and device pixel ratio calculations became straightforward because he was already in the right context.
The framework he draws out of it is worth stealing:
- Compute-heavy work is CPU bound. Transfer costs are small relative to processing. Move it off-thread and you win.
- Data-heavy work is dominated by the size of what you are passing. Move it off-thread and you pay for the move twice, once out and once back.
Most motion-adjacent work I do falls into the second category more often than I expected. Reading pixel data off a canvas, measuring a large DOM subtree, preparing keyframes from a big JSON payload. The computation is trivial. The data is not.
What Does Sending Data to a Worker Actually Cost?
The mechanism behind the lag deserves a closer look, because it tells you what to do instead of just what to avoid.
When you call postMessage(), the browser runs the structured clone algorithm: it walks the object, copies it, and rebuilds it on the other side. Ayomipo points out that this copy is synchronous and its cost grows with the size of the data. A tiny settings object is free. His example of an 8MB image payload is not, because the sending thread is busy serialising for the whole copy.
The escape hatch is a transferable object. Instead of copying, the browser moves ownership. MDN lists ArrayBuffer, ImageBitmap, OffscreenCanvas, MessagePort and VideoFrame among the types that qualify, and the second argument to postMessage() is where you name them:
const pixels = new Uint8Array(1024 * 1024 * 8);
worker.postMessage(pixels, [pixels.buffer]);
console.log(pixels.byteLength); // 0, the buffer now belongs to the worker
Two details trip people up. A typed array isn't transferable, only its underlying .buffer is. And after the transfer the sender can't touch the data: MDN says reading or writing the detached object throws, and its byteLength reads 0.
How much does it save? The oft-quoted benchmark comes from a Chrome Developers post that sent a 32MB ArrayBuffer to a worker and back. Structured cloning averaged 302ms for the round trip. Transferring it took 6.6ms. That's roughly a 45x gap, and it's the number Ayomipo cites. Treat it as an order of magnitude rather than a promise, though. The post was last updated in 2011, so the hardware and browsers in it are ancient, and your payload won't match a clean 32MB buffer. It's the reason to measure, not a substitute for measuring.
Now the awkward part for the screenshot extension. What it moved around was a Base64 data URL, and a string is a primitive: it gets copied, not transferred. MDN's list has no string in it, and Ayomipo notes the same about Base64 and Blobs. He also notes that Chrome's extension messaging has traditionally forced JSON serialisation on top of that. Holding the capture as an ArrayBuffer or ImageBitmap from the start could have changed the arithmetic, but the capture call he used returns a data URL string, so the cost arrived pre-baked.
There was a second, sneakier cost. His crop coordinates came from getBoundingClientRect(), which reports CSS pixels, while the native capture is in physical pixels. A 400x300 selection on a display with a device pixel ratio of 2 is an 800x600 capture. An Offscreen Document has no physical display, so its ratio defaults to 1, and the crop lands in the wrong place unless you pass the ratio along and write the scaling yourself. Running in the real tab removed that whole class of bug.
His summary formula is worth pinning above your desk: total time is serialisation cost, plus transit, plus background processing time, plus deserialisation cost. If the processing term dominates, isolate the work. If the other three add up to more, don't.
For motion work there's one place where transfer is nearly free. A canvas is itself transferable. transferControlToOffscreen() hands a canvas to a worker, and the compatibility data lists it as supported in Chrome 69, Firefox 105 and Safari 16.4. The drawing for a heavy canvas animation can then live off the main thread without shipping pixels back and forth.
What Does This Mean for Animation Specifically?
If you are animating transform and opacity, the compositor handles it and main thread congestion matters less. That is the whole reason those two properties get recommended so relentlessly. web.dev's guidance on compositor-only properties is blunt about it: those are the only two properties where that is true today.
The preview above runs two dots over the same 240px. They look identical on an idle page, and that is exactly what makes the difference easy to miss:
.dot--compositor { animation: slide-transform 1.6s ease-in-out infinite alternate; }
.dot--layout { animation: slide-left 1.6s ease-in-out infinite alternate; }
@keyframes slide-transform { to { transform: translateX(240px); } }
@keyframes slide-left { to { left: 244px; } }
The blue dot can be driven without the main thread. The orange one goes through layout and paint on every frame, so it shares a queue with your JavaScript. Put a long task on that queue and the blue dot has no reason to stall. The orange one does. If the animation needs a property that isn't on that short list, the FLIP technique that web.dev suggests remaps the motion onto transform and opacity changes instead.
One caveat from the same page: promoting an element to its own layer with will-change: transform isn't free. Each layer costs memory and GPU bandwidth, so reserve it for things that actually move.
Everything else is exposed. Animate width, top, filter or anything that triggers layout, and your animation is queueing behind whatever else the main thread is doing. A 200ms task in the middle of a 300ms transition does not slow the transition down. It removes twelve frames from it.
So the practical question during a motion build is not "is this blocking?" but "is this blocking while something is moving?". Those are different problems with different fixes:
- Blocking before the animation starts is usually fine, and often better than a spinner.
- Blocking during it is never fine, regardless of how briefly.
- Blocking after it completes is fine and nobody will notice.
That reframing does more for real work than any amount of moving code into workers.
What If You Can't Move the Work and Can't Afford the Block?
Plenty of tasks sit in the middle. Too long to run in one go while something is moving, too data-heavy to ship to a worker. Slicing is the third option, and scheduler.yield() is the tool built for it.
You call await scheduler.yield() inside a long function and the browser gets a chance to paint and handle input before your code resumes. The continuation is prioritised, so it runs ahead of other queued tasks such as third-party scripts, which is what separates it from a bare setTimeout. MDN's own example is a motion designer's friend:
button.addEventListener("click", async () => {
showSpinner();
await scheduler.yield();
doSlowContentSwap();
});
The spinner paints first, then the heavy work runs. That's the "block before the animation starts" case from earlier, done on purpose.
Support is the catch, and it moves. The compatibility data I checked on the day I updated this page lists Chrome 129, Edge 129 and Firefox 142 as supporting it, with Safari still absent. So you need a fallback: feature-detect, and wrap setTimeout in a promise when scheduler.yield isn't there. You lose the prioritised continuation but keep the yield. Feature detection is a one-liner:
const yieldToMain = () =>
globalThis.scheduler?.yield
? scheduler.yield()
: new Promise((resolve) => setTimeout(resolve, 0));
Don't yield on every iteration, either. web.dev notes that each yield carries some overhead and suggests batching small jobs so you yield only after roughly 50ms of work. A loop that checks performance.now() against a deadline does it in three lines.
Does the One-Second Allowance Survive Contact With INP?
Here's where I'd push back on the easy reading of the one-second rule. Interaction to Next Paint, the responsiveness metric that replaced First Input Delay, grades an interaction at 200ms or less as good, up to 500ms as needing improvement, and anything above 500ms as poor. A full second of blocking, if the next paint has to wait for it, lands in the poor bucket.
That doesn't make Ayomipo wrong. A screenshot tool the user triggered on purpose isn't a page-load scenario, and a pause after a deliberate click reads as work. But if the thing you're blocking is on a public page that gets measured, the spinner-then-yield pattern above is the version that satisfies both the human and the metric. Paint the acknowledgement first. Block second.
Why Measure Before You Move Anything?
The advice I would actually give: do not guess at which category your task is in. Ayomipo recommends performance.mark() and performance.measure(), and it takes about a minute to wire up.
performance.mark("transfer-start");
worker.postMessage(payload);
// in the worker's message handler, back on the main thread:
performance.mark("transfer-end");
performance.measure("transfer", "transfer-start", "transfer-end");
console.table(performance.getEntriesByType("measure"));
Compare that number against the time the processing itself takes. If transfer is the larger figure, moving the work off-thread has made things worse, and no amount of architectural correctness will change that.
I would run this before refactoring anything into a worker, because "put it in a worker" is one of those recommendations that sounds unarguable and occasionally makes an interface slower.
What Is the Opinion You Can Disagree With?
Blanket performance rules survive because they are safe to repeat, not because they are right. "Never block the main thread" is in the same family as "never use !important" and "always use semantic HTML": correct often enough that repeating it costs you nothing, and wrong often enough that following it blindly produces worse work.
The version you should carry into a motion build is duller and more useful. You have 16.6ms per frame. Spend it deliberately. Know whether your bottleneck is compute or data before you move it anywhere. And treat blocking during an animation as the one hard rule, because that is the case where the user sees the cost rather than merely waiting for it.
If you want the counterpart to this on the CSS side, the Chrome 150 motion features piece covers what the browser now handles for you, which is the cheapest way to keep work off the main thread: not doing it there in the first place.