You wrap a slow loop in an async function. You sprinkle in await. You tell yourself the page will stay smooth because the work is "async now."
Then you click the button.
The tab locks. Scroll dies. The spinner never spins. Clicks pile up like unanswered mail. Thirty seconds later, the status finally says "Done" — and you wonder whether Promises are broken.
They are not. You asked them to solve a problem they were never designed for.
Promises help you wait. They do not move CPU work off the main thread. Web Workers do.
That distinction is the whole article. We will build it in order:
- Why
asynccan still freeze the UI - How a worker fixes it with
postMessage - How Comlink, SharedArrayBuffer, and pools fit when the app grows up
One example carries us through: crunch a large array of numbers (pixels, analytics rows, whatever) without locking the page.
Runnable examples
Every major step has a small project you can clone and run. They live in examples/. The repo README.md has install commands.
| Example | Folder | What you learn |
|---|---|---|
| Frozen UI | 01-main-thread-freeze |
async + heavy loop still blocks |
| Raw worker | 02-raw-web-worker |
postMessage keeps UI responsive |
| Comlink | 03-comlink-worker |
Call the worker like await api.calculate(...)
|
| Shared memory | 04-shared-array-buffer |
Comlink + SharedArrayBuffer + COOP/COEP |
| Browser pool | 05-workerpool |
Queue many jobs across workers |
| Node pool | 06-piscina-node |
Piscina on Node worker_threads
|
git clone https://github.com/mrajaeim/Advanced-JS-Execution.git
cd Advanced-JS-Execution/examples/02-raw-web-worker
npm install
npm run dev
The snippets below are shortened for reading. Prefer the GitHub projects when you want the full UI — status text, a Ping button, and Vite setup included.
The Problem
Most of us learn a comforting story about async JavaScript:
If the code uses
async/await/ Promises, the UI stays smooth.
That story is half true. It holds when you are waiting — for a network response, a timer, a disk read. It falls apart the moment you start computing on the main thread.
Here is the trap in miniature:
async function processOnMainThread(numbers) {
await Promise.resolve(); // yields once, then...
let total = 0;
for (let i = 0; i < numbers.length; i++) {
total += Math.sqrt(numbers[i] * numbers[i] + 1);
}
return total; // ...this still owns the main thread
}
button.addEventListener('click', async () => {
status.textContent = 'Working...';
const numbers = Array.from({ length: 8_000_000 }, (_, i) => i);
const total = await processOnMainThread(numbers);
status.textContent = `Done: ${total}`;
});
What you expect: the status updates, the page stays clickable, then "Done" appears.
What you get: a frozen tab until the loop finishes.
Open examples/01-main-thread-freeze, click Run, then hammer Ping UI. The pings stall. That is the main thread, busy doing math, unable to paint or handle input.
await Promise.resolve() only delayed the freeze by one microtask. The math never left the main thread. You scheduled a wait — then immediately monopolized the only thread the UI has.
Why It Happens
Promises resume on the same thread
A Promise is a promise of a value later. When it settles, .then and await continue as microtasks on the same main thread that started them.
MAIN THREAD
sync code → await (wait for I/O) → more sync code HERE
That shape is perfect for fetch. It is a disaster for a multi-second CPU loop, because the loop is not waiting for anything. It is work. And work on the main thread means the browser cannot reliably handle clicks, DOM updates, or smooth animation.
The main thread has one call stack
While that stack is chewing through your eight million iterations, nothing else gets a turn. The UI is not "slow." It is parked.
Workers are a second JavaScript thread
A Web Worker has its own call stack and its own event loop. It cannot touch the DOM — that is the trade. In exchange, it can grind through heavy work while the main thread stays free for people clicking buttons.
You talk with messages:
MAIN THREAD WORKER THREAD
UI, DOM, clicks heavy CPU work
│ postMessage(data) │
└──────────────────────────────────► │
│ │ compute
│ ◄──────────────────────────────────┘
onmessage(result)
| Approach | Where the loop runs | UI during work |
|---|---|---|
await + loop on main |
Main call stack | Frozen |
Worker + postMessage
|
Worker call stack | Responsive |
Here is the part that confuses people: the main thread can still use a Promise to wait for the worker's reply. That Promise is waiting for a message. The heavy work already ran somewhere else. Same await keyword. Completely different place the CPU spent its time.
How to Confirm It (Before You Fix It)
Before you reach for a worker, ask of each line: is this waiting, or is this computing?
await Promise.resolve(); // WAIT (cheap)
for (...) { ... } // COMPUTE on main (expensive)
If the expensive part is compute, another Promise will not save you. You need another thread.
In Chrome DevTools Performance, record a click. Look for a long yellow main-thread task that lines up with your loop. That block is the smoking gun — and the reason your spinner never moved.
Better Solution: Move the Loop to a Worker
Use a Promise for waiting. Use a Web Worker for heavy computing.
The pattern is simple once you say it out loud:
- Keep UI on the main thread
- Put the loop in
worker.js - Send data with
postMessage - Wrap the reply in a Promise so UI code can still
await
Full runnable app: examples/02-raw-web-worker.
worker.js
self.onmessage = (event) => {
const numbers = event.data;
let total = 0;
for (let i = 0; i < numbers.length; i++) {
total += Math.sqrt(numbers[i] * numbers[i] + 1);
}
self.postMessage(total);
};
main.js
function processInWorker(numbers) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.onmessage = (event) => {
resolve(event.data);
worker.terminate();
};
worker.onerror = (error) => {
reject(error);
worker.terminate();
};
worker.postMessage(numbers);
});
}
button.addEventListener('click', async () => {
status.textContent = 'Working...';
const numbers = Array.from({ length: 8_000_000 }, (_, i) => i);
const total = await processInWorker(numbers);
status.textContent = `Done: ${total}`;
});
Now the expected behavior matches reality: "Working..." stays visible, Ping UI keeps counting, and "Done" appears when the worker finishes. The await is still there — but it is waiting for a message, not for the main thread to finish math.
What each piece does
| Piece | Role |
|---|---|
new Worker(...) |
Starts a second JS thread (no DOM access) |
postMessage(numbers) |
Sends a clone of the data; main continues immediately |
Worker's onmessage
|
Runs the loop on the worker stack |
processInWorker's Promise |
Only waits for the reply — does not run the math |
MAIN WORKER
click → status = "Working..."
postMessage(numbers) ───────────────► receive array
await (main free for clicks) run loop
onmessage ←────────────────────────── postMessage(total)
status = "Done..."
You can soften small freezes by chunking a loop with setTimeout(0) on the main thread. For large jobs it gets messy fast — progress, cancellation, slower total time, harder reasoning. A worker is the clearer architecture: one place for UI, one place for grind.
Next Step: Comlink (RPC-style Workers)
Raw postMessage is honest and a little painful. As soon as the worker has more than one job, you invent a protocol: message types, payloads, request IDs, error shapes. You become a courier for your own functions.
Comlink is a thin RPC layer on top of workers. The idea is almost embarrassingly simple:
- The worker exposes an API object
- The main thread wraps the worker
- You call
await api.calculate(data)like a normal async module
Under the hood it still uses messages. The mental model does not change: wait with Promises, compute on a worker. Comlink just stops you from hand-writing the routing.
Runnable project: examples/03-comlink-worker.
Worker
import { expose } from 'comlink';
function calculate(numbers) {
let total = 0;
for (let i = 0; i < numbers.length; i++) {
total += Math.sqrt(numbers[i] * numbers[i] + 1);
}
return total;
}
expose({ calculate });
Main
import { wrap } from 'comlink';
const worker = new Worker(new URL('./worker.js', import.meta.url), {
type: 'module',
});
const api = wrap(worker);
const total = await api.calculate(numbers);
| Style | Call site | Who routes replies |
|---|---|---|
Raw postMessage
|
{ type, payload } |
You |
| Comlink | await api.calculate(data) |
Comlink |
Use raw messaging for one tiny job with zero dependencies. Reach for Comlink when the worker becomes a real API — image pipelines, inference helpers, multi-method tools. Comlink also supports transferables, typed APIs, and SharedArrayBuffer-friendly flows when you need them later.
When Copying Data Is the Bottleneck: SharedArrayBuffer
Sometimes the math is fine, and the tax is the copy. postMessage clones or transfers data. For huge frames or tensors shared by several workers, that traffic can dominate cost — you spend more time moving memory than processing it.
A SharedArrayBuffer is memory more than one thread can see at once. Workers mutate it in place. You still need careful partitioning or Atomics so threads do not corrupt each other. Shared memory without a race strategy is just a faster way to get weird bugs.
Main (UI)
│ Comlink (start job / progress / done)
Worker pool
│ SharedArrayBuffer (pixels, tensors)
WASM / SIMD kernels
Runnable project (Vite already sets COOP/COEP): examples/04-shared-array-buffer.
const buffer = new SharedArrayBuffer(length * Float32Array.BYTES_PER_ELEMENT);
await api.processShared(buffer, start, end); // mutates in place
Before you adopt it, you need three things:
- Cross-origin isolation headers (
COOP: same-origin,COEP: require-corp) - A race strategy (split ranges, single writer, or Atomics)
- Proof from profiling that cloning or transfer is actually the problem
Start with Comlink + normal messages or transferables. Add SharedArrayBuffer when memory movement — not the algorithm — is the cost.
Pools: Many Jobs, Not One Worker Per Click
Creating a worker has startup cost. Spawning a fresh one on every click is like hiring a new employee for every email. If the UI fires many jobs, use a pool: a small set of workers that share a queue.
| Need | Tool | Example |
|---|---|---|
| Browser job queue | workerpool | examples/05-workerpool |
Node worker_threads pool |
Piscina | examples/06-piscina-node |
Browser (workerpool):
import workerpool from 'workerpool';
const pool = workerpool.pool(new URL('./heavy.worker.js', import.meta.url).href);
const result = await pool.exec('calculate', [data]);
Node (Piscina):
import Piscina from 'piscina';
const pool = new Piscina({
filename: new URL('./worker.mjs', import.meta.url).href,
});
const result = await pool.run(payload);
Pick a stack by scenario
| Scenario | Stack |
|---|---|
| React / Vite UI, readable worker API | Comlink (+ workerpool if many jobs) |
| Browser AI / vision / big frames | Comlink or workerpool + WASM/ONNX, SharedArrayBuffer if needed |
| Node images, PDFs, compression, ML | Piscina |
A quick map of the libraries (numbers and APIs change over time):
| Package | Best at |
|---|---|
| Comlink |
await worker.method() ergonomics in the browser |
| workerpool | Queue + N workers (browser or Node) |
| Piscina | Production Node worker pool |
Warnings Worth Knowing
A few sharp edges show up again and again:
- No DOM in workers. Compute there. Update React or the DOM on the main thread.
-
Huge clones stall the main thread too. Prefer transferables for big
ArrayBuffers:worker.postMessage(buffer, [buffer]). -
Match worker type to how you load it (
classicvs{ type: 'module' }). A mismatch fails in ways that look mysterious at first. -
Wire
worker.onerror(and pool errors). Without that, failures look like hung Promises that never settle. -
Browser Workers ≠ Node
worker_threads. Same idea, different APIs and libraries. Do not paste one into the other and expect kindness.
When to Use What
| Situation | Use |
|---|---|
| Waiting on network, timers, IndexedDB | Promises / async
|
| Short work between awaits | Stay on the main thread |
| Large CPU work, UI must stay live | Web Worker |
| Worker grows into many methods | Comlink |
| Many concurrent jobs | workerpool (browser) or Piscina (Node) |
| Huge shared frames/tensors | SharedArrayBuffer after measuring |
| I/O-bound or tiny work | Do not add a worker |
Not every freeze needs a worker. Tiny work and pure I/O usually do not. Reach for parallelism when the UI must stay alive and the CPU has real work to do.
Conclusion
async does not mean "runs in parallel." It means "can wait without busy-waiting on this thread."
A Web Worker is the tool that runs heavy JavaScript somewhere else while the UI thread stays free. Wrap the reply in a Promise so your app code stays readable — the await is for the message, not for the math.
Then climb only as far as you need:
- Raw
postMessage(example 02) - Comlink (example 03)
- A pool (examples 05 / 06)
- SharedArrayBuffer (example 04) when memory movement is the bottleneck
The fastest way to feel the difference: clone the repo, run 01 next to 02, and click Ping UI in both. That comparison teaches the architecture faster than any diagram.
What was the first job you tried to "fix" with async/await that still froze the tab?
Top comments (0)