고급 JS 실행: 약속, 작업자 및 Comlink

작성자

카테고리:

← 피드로
DEV Community · Mohammad Rajaei Monfared · 2026-07-22 개발(SW)

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.

If you want the foundation first — call stack, microtasks vs macrotasks, and the event loop — start with my earlier post: Why Your JavaScript Runs in the Wrong Order. This article picks up where that one leaves off: when waiting is not enough and you need another thread.

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.

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 that shows click counts on its label, 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: 85_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 heavy work, dismiss the alert, then try Ping UI or change the array length. The Ping button label (Ping UI (1), Ping UI (2), …) should stall — and so should the input. 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 tens of millions of 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 heavy work in worker.js — including building the big array
  3. Send only what you need with postMessage (here: the length)
  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 length = event.data;
  const numbers = Array.from({ length }, (_, i) => i);

  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(length) {
  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(length);
  });
}

button.addEventListener('click', async () => {
  status.textContent = 'Working...';
  const total = await processInWorker(85_000_000);
  status.textContent = `Done: ${total}`;
});

Enter fullscreen mode Exit fullscreen mode

Now the expected behavior matches reality: “Working…” stays visible, Ping UI keeps updating its label (Ping UI (1), Ping UI (2), …), 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(length) Sends a small number; main continues immediately Worker’s onmessage Builds the array and 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(length) ────────────────► receive length
await (main free for clicks)            build array + 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(length) 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(length) {
  const numbers = Array.from({ length }, (_, i) => i);

  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(85_000_000);

Enter fullscreen mode Exit fullscreen mode

Style Call site Who routes replies Raw postMessage { type, payload } You Comlink await api.calculate(length) 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);
// Workers fill and mutate their slices; main only allocates.
await api.processShared(buffer, start, end);

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.

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', [length, jobId]);

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({ length, jobId });

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, dismiss each alert, and click Ping UI in both. In 01 the button label freezes; in 02 it keeps counting. 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?

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다