Back to Hub
ENGINEERING •

True Multithreading in the Browser, Without the Pain.

JavaScript is single-threaded. That one fact explains half of all janky web apps: parse a big JSON file, crunch pixels, or run a search across ten thousand files on the main thread, and your UI freezes until it's done. The browser's answer is Web Workers — real OS-level threads you can spin up from JavaScript.

Here's the part most tutorials skip: workers aren't just "background threads." Combined with SharedArrayBuffer, transferable objects, and OffscreenCanvas, they form a complete multithreading toolkit. Every example below runs as-is — paste it into a NitroIDE preview (which is a real browser iframe) and watch it work.

Your first worker in 30 seconds

You don't even need a separate file. Create a worker from a Blob URL inline:

// Heavy work leaves the main thread entirely
const workerCode = ` onmessage = (e) => { let sum = 0; for (let i = 0; i < e.data; i++) sum += Math.sqrt(i); postMessage(sum); // result comes back when ready }; `;
const worker = new Worker(
  URL.createObjectURL(new Blob([workerCode], { type: 'text/javascript' }))
);
worker.onmessage = (e) => console.log('done:', e.data);
worker.postMessage(50_000_000); // UI stays silky while this crunches

The main thread never blocks. Animations keep running, clicks keep registering. That alone fixes most "the page froze" bugs.

Real-world proof: The Monaco editor inside NitroIDE runs its TypeScript language features in a web worker. Autocomplete, hover info, and error checking all happen off the main thread — which is why typing stays instant even in large files.

Stop copying: transferable objects

By default, postMessage clones your data (structured clone). Sending a 100 MB image buffer to a worker means copying 100 MB. Transferables fix that — ownership of the buffer moves to the worker with zero copying:

const buffer = new ArrayBuffer(100 * 1024 * 1024);
// The second argument TRANSFERS ownership — no copy, no clone
worker.postMessage({ pixels: buffer }, [buffer]);
console.log(buffer.byteLength); // 0 — it's gone from this thread now

Rule of thumb: if the buffer is bigger than a few hundred KB, transfer it. Cloning is fine for small JSON-ish messages.

Shared memory with SharedArrayBuffer

Sometimes transfer isn't enough — you want two threads reading and writing the same memory, like a progress counter or a streaming log. That's SharedArrayBuffer plus Atomics:

const shared = new SharedArrayBuffer(4);
const counter = new Int32Array(shared);
// Main thread: wait efficiently until the worker signals
worker.postMessage(shared);
// Inside the worker: Atomics.add(counter, 0, 1); Atomics.notify(counter, 0);
Atomics.wait(counter, 0, 0); // sleeps — burns zero CPU
console.log('worker finished, counter =', counter[0]);

Two honest caveats. First, SharedArrayBuffer requires cross-origin isolation — your page must serve the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers, or the constructor throws. Second, Atomics.wait is only allowed inside workers; calling it on the main thread throws. Design accordingly: the main thread posts messages, workers do the waiting.

Render off-thread with OffscreenCanvas

Canvas drawing on the main thread competes with layout, input, and your framework. OffscreenCanvas moves rendering into a worker — perfect for minimaps, visualizations, and live previews:

const canvas = document.querySelector('canvas');
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
// Inside the worker:
// const ctx = canvas.getContext('2d');
// (function frame() { ctx.fillRect(...); requestAnimationFrame(frame); })();

Note: inside a worker there's no requestAnimationFrame on window — but OffscreenCanvas contexts support their own animation loop patterns, and a simple setInterval-driven or message-driven render loop works fine.

Pattern: a lock-free ring buffer for logs

When a worker produces data faster than the main thread consumes it (think high-frequency logs or sensor streams), a ring buffer over shared memory is the classic answer — the writer never blocks, the reader never misses:

// 1024-slot ring buffer of float64 values in shared memory
const SIZE = 1024;
const sab = new SharedArrayBuffer(8 * SIZE + 8);
const head = new Int32Array(sab, 0, 1); // write cursor
const data = new Float64Array(sab, 8); // the ring
// Writer (worker): data[head[0] % SIZE] = v; Atomics.add(head, 0, 1);
// Reader (main): poll Atomics.load(head, 0), drain new slots.

When NOT to reach for workers

Workers have startup cost (a few ms) and every message crosses a serialization boundary. Don't use them for tiny tasks, DOM access (workers have no DOM — that's the point), or anything that needs synchronous return values. A good rule: if it takes less than ~16 ms, keep it on the main thread; if it can jank a frame, move it.

Try it now: NitroIDE's preview pane is a real browser iframe with full worker support. Paste the Blob-worker example above into a new HTML file, open the console, and confirm the page stays responsive while 50 million square roots get crunched in the background.

Move Work Off the Main Thread.

Prototype worker-based architectures instantly — no build step, no server, just your browser.

Launch NitroIDE