You wrap a slow loop in an async function. You await a Promise. You expect the page to stay smooth.
Instead the tab freezes. Scroll stops. Clicks queue up. The spinner never moves.
Promises did not break. They solved a different problem than the one you had.
Promises help you wait. They do not move CPU work off the main thread. Web Workers do.
This article builds that idea in order:
- Why
asyncstill freezes the UI - How a worker fixes it with
postMessage
- How Comlink, SharedArrayBuffer, and worker pools fit when the app grows
We reuse one example the whole way: crunch a large array of numbers (think pixels or analytics rows) without locking the page.
Runnable examples (this GitHub repo)
Every major step has a small project you can clone and run. They live in for install commands.
| Example | Folder | What you learn |
|---|---|---|
| Frozen UI | postMessage keeps UI responsive | |
| Comlink | Comlink + SharedArrayBuffer + COOP/COEP | |
| Browser pool | 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, Ping button, Vite setup).
The Problem
Many developers assume:
If the code uses
async/await/ Promises, the UI stays smooth.
That is only true for waiting (network, timers, disk). It is false for computing on the main thread.
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}`;
});
Expected: status updates, page stays clickable, then "Done".
Actual: UI freezes until the loop finishes. Try it in .
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}`;
});
Expected: "Working..." stays visible, Ping UI keeps counting, then "Done" appears.
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..."
Chunking the loop with setTimeout(0) on the main thread can soften small freezes. For large jobs it gets messy (progress, cancellation, slower total time). A worker is the clearer architecture.
Next Step: Comlink (RPC-style Workers)
Raw postMessage turns into a homemade protocol (type, payload, request IDs, error shapes). That gets noisy when the worker has many methods.
.
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. Use 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 Copying Data Is the Bottleneck: SharedArrayBuffer
postMessage clones or transfers data. For huge frames or tensors shared by several workers, that traffic can dominate cost.
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.
Main (UI)
│ Comlink (start job / progress / done)
Worker pool
│ SharedArrayBuffer (pixels, tensors)
WASM / SIMD kernels
Runnable project (Vite sets COOP/COEP):
await worker.method() ergonomics in the browser
SOCIAL SHARE CARD GENERATOR