10LOC
Bunadvanced

Parallel work with Bun's Worker

Published May 21, 2027

export const countPrimesOnWorker = (from: number, to: number) => {
  const workerCode = `
    self.onmessage = ({ data: [from, to] }) => {
      let count = 0;
      for (let n = from; n < to; n++) {
        let isPrime = n > 1;
        for (let d = 2; d * d <= n; d++) if (n % d === 0) { isPrime = false; break; }
        if (isPrime) count++;
      }
      postMessage(count);
    };
  `;
  const worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "application/javascript" })));

  return new Promise<number>((resolve) => {
    worker.onmessage = (event) => {
      resolve(event.data);
      worker.terminate();
    };
    worker.postMessage([from, to]);
  });
};

const ranges: [number, number][] = [[0, 250_000], [250_000, 500_000], [500_000, 750_000], [750_000, 1_000_000]];
const counts = await Promise.all(ranges.map(([from, to]) => countPrimesOnWorker(from, to)));

console.log(`primes below 1,000,000: ${counts.reduce((a, b) => a + b, 0)}`);

What

Bun implements the standard Worker API (the same one browsers expose) backed by real OS threads. new Worker(url) starts one; postMessage/onmessage move data across the thread boundary, and — same as in a browser — a blob: URL can hand a worker its code as an in-memory string instead of a separate file.

Why it matters

JavaScript's single-threaded event loop is fine for I/O-bound work but a real bottleneck for CPU-bound work — a tight loop blocks everything else on that thread, event loop included. Bun's Worker moves that loop onto its own OS thread, and unlike browsers, Bun's version doesn't require { type: "module" } to use import/export, and its postMessage has fast paths for plain strings and simple objects that make it 2-241x faster than Node's worker_threads equivalent for typical payloads. Splitting one CPU-bound task across several workers lets it use more than one core, something no amount of async/await on a single thread can do.

How it works

  • runOnWorker builds each worker's entire source as a template string and wraps it in a Blob, then URL.createObjectURL(...) turns that blob into a URL Worker can load — this keeps the whole example in one file instead of needing a separate worker.ts alongside it.
  • Inside the worker string, self.onmessage receives whatever the main thread posts — here, a [from, to] range — and the worker counts primes in that slice with an ordinary nested loop.
  • postMessage(count) inside a worker is automatically routed to the parent; the main thread's worker.onmessage receives it and resolves the wrapping Promise.
  • Four workers each get a disjoint slice of the 0-1,000,000 range; Promise.all waits for all four, and the counts are summed once every worker has reported back.
  • worker.terminate() runs right after the result arrives — without it, each worker would keep its own event loop alive indefinitely waiting for more messages that never come.

Gotchas

  • A real project should give each worker its own worker.ts file and load it with new Worker(new URL("./worker.ts", import.meta.url)) instead of a blob string — the blob approach here trades that clarity for keeping this example runnable as a single file.
  • Forgetting worker.terminate() (or never letting the worker's event loop go idle) leaks a thread per call — Bun.spawn-style cleanup discipline applies here too.
  • postMessage uses the structured clone algorithm for anything beyond a string or simple flat object — functions, class instances with methods, and DOM-like objects either fail to clone or lose their prototype, so pass plain data across the boundary, not behavior.

Related

  • Bun.spawn — a separate process with its own memory space, appropriate when the work needs isolation (a crash in one doesn't take down the others) rather than just parallelism.
  • node:worker_threads's Worker is API-compatible in Bun for code already written against Node's version; the standard Worker global shown here is Bun's preferred, browser-aligned API.