DEV Community

Cover image for Advanced JS Execution: Promises, Workers, and Comlink
Mohammad Rajaei Monfared
Mohammad Rajaei Monfared Subscriber

Posted on

Advanced JS Execution: Promises, Workers, and Comlink

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:

  1. Why async can still freeze the UI
  2. How a worker fixes it with postMessage
  3. 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
Enter fullscreen mode Exit fullscreen mode

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}`;
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Keep UI on the main thread
  2. Put the loop in worker.js
  3. Send data with postMessage
  4. 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);
};
Enter fullscreen mode Exit fullscreen mode

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}`;
});
Enter fullscreen mode Exit fullscreen mode

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..."
Enter fullscreen mode Exit fullscreen mode

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:

  1. The worker exposes an API object
  2. The main thread wraps the worker
  3. 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 });
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Before you adopt it, you need three things:

  1. Cross-origin isolation headers (COOP: same-origin, COEP: require-corp)
  2. A race strategy (split ranges, single writer, or Atomics)
  3. 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]);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 (classic vs { 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:

  1. Raw postMessage (example 02)
  2. Comlink (example 03)
  3. A pool (examples 05 / 06)
  4. 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)