JAVASCRIPT / PROMISES AND ASYNC PATTERNS
Workers for CPU-heavy work off the main thread
Move blocking computation into a Web Worker and wrap its postMessage exchange in a promise so the page stays responsive during heavy jobs.
What you will learn
- Spot CPU-bound work: a promise around a tight loop still freezes the page
- Wrap one worker job in one promise: resolve in onmessage, reject in onerror
- Use Promise.all across several workers to get real multi-core parallelism
- Transfer ArrayBuffers with the transfer list to avoid cloning large payloads
Understanding Workers for CPU-heavy work off the main thread
Everything earlier in this section moves work in time, not in space: a promise records that a result will arrive later, and the event loop fills the gaps where the thread would otherwise sit idle waiting on a socket or a timer. A tight numeric loop creates no gap at all, because it holds the call stack from its first iteration to its last, so wrapping it in `new Promise` or marking the function `async` only changes when the result is announced, never who computes it. That is why a 400ms hash or sort stalls scrolling, animation and clicks no matter how modern the syntax around it looks. Running two pieces of JavaScript at the same instant requires a second thread, and in the browser that thread is a Worker.
Picture a worker as a second JavaScript runtime rather than a background function: it has its own thread, its own global object (`self`, no `window` and no DOM), its own heap, and its own event loop with its own microtask queue. It cannot see your variables, so there is nothing to lock and no data race on ordinary objects; the single channel is `postMessage`, and values crossing it are deep-copied by the structured clone algorithm or explicitly handed over with a transfer list. Isolation is the price of parallelism here: the worker gets a real core, and you give up shared state in exchange.
The interface is event-shaped, `postMessage` out and `onmessage` in, which is the callback style from earlier lessons, so the natural bridge is one promise per job: resolve inside `onmessage`, reject inside `onerror`. Once each job is a promise, `Promise.all` over several workers finally means what people assume it means, because those jobs genuinely run at once on different cores, whereas `Promise.all` over CPU-bound promises on one thread is a sequential run with extra bookkeeping. Budget for the overhead, though: spawning a worker costs a few milliseconds and cloning costs time proportional to payload size, so the pattern pays for jobs measured in tens of milliseconds, not for doubling a number.
Because the worker holds no reference to your objects, everything it needs must be sent as data, which also forces a useful design habit: the code you move is a pure function of its input.
const workerSource = `
self.onmessage = (event) => {
const limit = event.data;
let count = 0;
for (let n = 2; n < limit; n++) {
let prime = true;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) { prime = false; break; }
}
if (prime) count++;
}
self.postMessage(count);
};
`;
const url = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' }));
const worker = new Worker(url);
function countPrimes(limit) {
return new Promise((resolve, reject) => {
worker.onmessage = (event) => resolve(event.data);
worker.onerror = (event) => reject(new Error(event.message));
worker.postMessage(limit);
});
}
console.log('main: job posted');
countPrimes(1000000).then((count) => {
console.log('main: primes below 1000000 =', count);
worker.terminate();
URL.revokeObjectURL(url);
});
console.log('main: main thread still free, clicks and paints keep working');A promise only controls when a result is announced; only a worker changes which thread does the computing.
Worked examples
A promise around a loop still blocks
Shows that promise syntax does not move computation off the main thread, which is the reason workers exist.
function countMultiples(n) {
return new Promise((resolve) => {
let hits = 0;
for (let i = 0; i < n; i++) {
if (i % 7 === 0) hits++;
}
resolve(hits);
});
}
setTimeout(() => console.log('timer: I was due immediately'), 0);
console.log('before');
countMultiples(300000000).then((hits) => console.log('promise resolved with', hits));
console.log('after');Example explained
Line 1The executor passed to `new Promise` runs synchronously, so all 300 million iterations happen before the constructor returns.
Line 2`resolve` is therefore called while the stack is still deep in that call, and `.then` merely queues a microtask.
Line 3`after` prints before the resolution handler, proving the promise added a scheduling hop and nothing else.
Line 4The 0ms timer prints last because it became due during the loop and had to wait for the stack to empty; a real page would have dropped every frame in that window.
Progress events plus one settled promise
A worker can post many messages while a job runs, so progress stays a stream of events and only the final message settles the promise.
const source = `
self.onmessage = (event) => {
const total = event.data;
let acc = 0;
for (let i = 1; i <= total; i++) {
acc += i;
if (i % (total / 4) === 0) {
self.postMessage({ type: 'progress', percent: (i / total) * 100 });
}
}
self.postMessage({ type: 'done', acc });
};
`;
const worker = new Worker(URL.createObjectURL(new Blob([source], { type: 'text/javascript' })));
function sumTo(total) {
return new Promise((resolve) => {
worker.onmessage = ({ data }) => {
if (data.type === 'progress') console.log('progress:', data.percent + '%');
else resolve(data.acc);
};
worker.postMessage(total);
});
}
sumTo(40000000).then((acc) => {
console.log('sum:', acc);
worker.terminate();
});Example explained
Line 1`i % (total / 4) === 0` fires four times, so the worker sends four progress messages plus one final message from a single job.
Line 2The same `onmessage` callback receives all five, but only the `done` branch calls `resolve`, because a promise can settle once.
Line 3Each progress log is handled on the main thread, which is free to touch the DOM while the worker keeps summing.
Line 4`acc` stays exact because 800000020000000 is well under 2^53, so no floating point drift appears in the result.
Transfer a buffer instead of copying it
Demonstrates ownership transfer, where a large ArrayBuffer crosses threads with no clone and becomes detached in the sender.
const source = `
self.onmessage = (event) => {
const bytes = new Uint8Array(event.data);
let sum = 0;
for (let i = 0; i < bytes.length; i++) {
bytes[i] = bytes[i] * 2;
sum += bytes[i];
}
self.postMessage({ sum, buffer: event.data }, [event.data]);
};
`;
const worker = new Worker(URL.createObjectURL(new Blob([source], { type: 'text/javascript' })));
worker.onmessage = ({ data }) => {
console.log('sum from worker:', data.sum);
console.log('buffer came back with byteLength', data.buffer.byteLength);
worker.terminate();
};
const buffer = new ArrayBuffer(1024 * 1024);
new Uint8Array(buffer).fill(1);
console.log('before send, byteLength:', buffer.byteLength);
worker.postMessage(buffer, [buffer]);
console.log('after send, byteLength:', buffer.byteLength);Example explained
Line 1The second argument to `postMessage` is the transfer list, so ownership of the megabyte moves to the worker rather than being duplicated.
Line 2`buffer.byteLength` reads 0 immediately after the send because the ArrayBuffer is now detached in this thread; any view over it is empty.
Line 3The worker doubles the bytes in place and transfers the same buffer back, so nothing is ever cloned in either direction.
Line 4Drop both transfer lists and the code still works, but structured clone copies 1MB each way, which is the cost that makes small jobs not worth exporting.
Important notes
A worker reply is delivered as a task, not a microtask, so it can never interrupt your synchronous code and its delivery is delayed by a busy main thread; the worker finishing early does not mean your callback runs early.
Structured clone carries data only: functions throw DataCloneError, and class instances arrive as plain objects without their prototype. Also `new Worker('job.js')` needs a same-origin http(s) page, which is why the Blob URL form is convenient in an online editor.
Common mistakes
Wrapping the heavy loop in `new Promise` or an `async` function and expecting a responsive page: the executor body runs synchronously, so frames are still dropped and only the announcement of the result moves to a microtask.
Constructing a new Worker on every call, for example inside a keystroke handler, and never calling `terminate()`: each construction spawns a thread and re-parses the script, so startup cost swamps the job and abandoned threads accumulate.
Assigning `worker.onmessage` per job while two jobs are in flight: the second assignment replaces the first handler, so one promise never settles and a reply resolves the wrong job. Send an id with each message and route replies by id.
Try it yourself
Change, predict, then run
In a browser editor, start a `setInterval` that logs a rising counter every 100ms, then compute `fib(35)` recursively twice: once inside a promise on the main thread and once inside a Blob worker whose reply you await. Note which version lets the counter keep ticking during the computation.
Open the JavaScript workspaceCheck your understanding
You have four independent jobs, each about 200ms of pure computation, running on a four-core machine. Which arrangement finishes the whole batch fastest?
- Wrap each job in `new Promise` on the main thread and await `Promise.all` of the four
- Post all four jobs to one worker and await `Promise.all` of the four replies
- Post each job to its own worker and await `Promise.all` of the four job promises
- Split each job into `setTimeout(..., 0)` chunks and await all of them
Show answer
Four workers are four threads, so the operating system can place them on four cores and the batch takes roughly the time of the slowest job. The tempting wrong answer is the single worker: it does unblock the page, but a worker has one thread and one event loop, so its four queued jobs still run one after another for about 800ms. `Promise.all` never creates concurrency, it only waits on work that is already running, and timer chunking keeps the page alive while making the total slightly worse.