The common rule of thumb is to never “block” the browser’s main thread when running JavaScript tasks. But is this a hard rule? Here’s a use case involving a screenshot extension where blocking the main thread was absolutely the right thing to do.
We’ve all heard of the sacred rule in modern web development, the rule never to be broken: “Never block the main thread.”
You almost can’t miss it as a web developer; it’s in almost every performance guide, and to be fair, it is good advice. We all know the browser’s main thread is single-threaded, meaning it can only do one thing at a time.
Plus, the main thread isn’t ours alone; we share it with the browser’s rendering engine, input handlers, and other critical tasks. As a result, the less time we hold onto the main thread, the more responsive an app feels. That leads us to share tasks with background workers as we’ve convinced ourselves there should be a hard line between the UI and any computation, and that line shouldn’t be crossed.
And that is what a “recommended” architecture looks like.
But sometimes, moving the data to a worker is slower than just letting the main thread do the work.
This became clear while building a Chrome extension with screenshotting features called Fastary. There was a persistent latency of about 2 to 3 seconds in all testing, even after using an Offscreen Document (a background process in Chrome extensions) to handle the canvas operations. A screenshot task should feel instant without lag, after all.
It is quite ironic that by reflex, we move work away from the main thread to avoid freezing the UI, but sometimes the act of moving that work — serializing, copying, and deserializing — can also freeze the UI. And sometimes the recommended approach of letting the background do the work can be slower than just doing the work on the main thread.
Let’s talk about that.
The Architecture Of Browser Context Isolation
To put things in perspective, let’s understand why we isolate browser contexts and how they communicate with each other, with emphasis on the communication part.
A browser is more than a single environment. Different environments are running at the same time, each having its own memory space, what it can access, and rules:
- The main thread is what we are most familiar with; this is where JavaScript logic runs, where the DOM lives, where styles get rendered, and where users interact.
- Web Workers are separate threads that can also execute JavaScript without DOM access. We mostly use these for heavy data tasks.
- Service Workers are network-related proxies in charge of intercepting network requests and can even run when the page is closed.
- Chrome extension contexts include background service workers, content scripts, and Offscreen Documents (the relevant ones for this article).
Each one of these is isolated from the others. A web worker or background script lives in a different memory space from the main thread. They cannot just reach and read each other’s variables or logic, and this is known as the “shared-nothing” architecture.
How do these isolated environments communicate? They explicitly message each other back and forth using APIs, like postMessage().
The Structured Clone Algorithm
postMessage() tells the browser to take a piece of data and deliver it to the context that requested it. But to do this, the browser relies on the Structured Clone Algorithm (SCA).
You’re probably familiar with JSON.stringify(). SCA is similar, but much stronger and smarter. In its simplest form, SCA is a deep, recursive copy operation — cloning. It walks through the entire data structure it is given, clones every single value, serializes it into a transportable format, ships those bytes to the target context, and then reconstructs the original object on the receiving side.
SCA is fast, or maybe fast-ish. For a small regular config object like {theme: "dark"}, it is imperceptible; you don’t even notice it. The story changes, however, when dealing with heavy data because the SCA is a synchronous blocking O(n) operation — the cost increases linearly with the size of your data.
To put that into perspective: a user clicks a button, and internally, an 8MB image payload is sent to a background worker for processing. When you call postMessage(), the main thread must immediately stop what it is doing to run this serialization and copying process.
So, if the time it takes to pack, ship, unpack the data, and return is longer than the time to just process the data on the main thread, why not do that instead?
What About Transferable Objects?
Developers who really pursue ultra-high-performance web apps usually use Transferable objects (e.g., ArrayBuffer, ImageBitmap, or MessagePort) to bypass the Structured Clone Algorithm. When you transfer an object, you’re not making a copy. Instead, the browser switches ownership of the data from one context to another.
The browser performs a hand-off whereby the sending context loses access to the data instantly, and the receiving context takes full control. It is actually insanely fast. According to Chrome Developers’ benchmark, transferring a massive 32MB ArrayBuffer can take under 7ms, compared to about 300ms when cloning with SCA. That’s a 43x speed boost.

But like all good things, there are downsides:
- You lose it once you send it. If the UI still needs that data (like to show an image preview), you can’t access it anymore.
- Not all data is transferable. A plain JS object is not. A Blob is not. Even a Base64 string is not.
- API limitations. In the context of browser extensions, Chrome’s internal messaging (
chrome.runtime.sendMessage) traditionally forces everything through JSON serialization.
So, for a screenshot extension, Transferable objects were not an option.
Why We Isolate Contexts Anyway
Offloading long-running CPU tasks to a background thread is absolutely the right thing to do. The browser needs to paint a new frame every 16.6ms to keep things fluid, meaning any task that takes more than 50ms is generally considered “long”. Offloading to the background is the right call in those situations.
The issue, however, is that we’ve turned “never block the main thread” into an absolute rule, without asking: is this task expensive to process, or expensive to move?
The rule is less “never block the main thread” than “never block the main thread for too long.”
When The Right Architecture Is The Wrong Architecture
The goal with the Fastary extension was to make it feel like a native app — running as smoothly and instantly as you would expect from a native experience.
Taking the recommended approach and using the Offscreen Document to handle DOM work in the background seemed like the right call. The Offscreen Document API is a clear winner on paper: you create a hidden, undisplayed document that runs entirely in the background, has a DOM, and supports Canvas. For tasks like cropping a screenshot, stitching multiple screenshots together, performing heavy image manipulation, or adding a watermark, the Offscreen Document was made for exactly that.
What this experience ultimately reveals is that architectural best practices are context-dependent. The overhead of serialization, transfer, and reconstruction can exceed the cost of the computation itself — and when it does, keeping work on the main thread is not a mistake. It is the right engineering decision. Before reaching for a worker, it is worth measuring both the cost of the work and the cost of moving it.