Multi Threading #

TypeScript runs on top of JavaScript — a language fundamentally designed as single-threaded. This isn’t a design flaw, but a deliberate decision to simplify programming: no race conditions, no deadlocks, no need for mutexes or semaphores. JavaScript’s concurrency model uses the event loop — a non-blocking mechanism that’s extremely efficient for I/O-bound work (network operations, file read/write). Problems arise when you need CPU-bound work — heavy computation that blocks the event loop and makes the application unresponsive. This is where Web Workers (browser) and Worker Threads (Node.js) come in: running heavy computation on separate threads without blocking the main thread. This article covers both in depth from a TypeScript perspective.

The Single-Threaded Model and Event Loop #

Before discussing multi-threading, it’s important to understand why TypeScript can handle thousands of concurrent operations without multiple threads:

flowchart LR
    A[TypeScript Code] --> B[Call Stack\nMain Thread]
    B --> C{Blocking\nOperation?}
    C -- No, pure I/O --> B
    C -- Yes, fetch, fs, timer --> D[Web APIs /\nlibuv]
    D --> E[Callback Queue /\nMicrotask Queue]
    E --> F[Event Loop]
    F -- Stack empty --> B

    style B fill:#339af0,color:#fff
    style D fill:#51cf66,color:#fff
    style F fill:#fcc419,color:#000

The event loop works very efficiently for I/O-bound work because I/O operations actually wait outside JavaScript (in the operating system) — the main thread is free to handle other things. But for CPU-bound work, the main thread must actively compute, and this blocks everything:

// I/O-bound — does NOT block the event loop (safe)
async function ambilData(): Promise<void> {
  const res = await fetch("https://api.example.com"); // Thread is free while waiting
  const data = await res.json();
  console.log(data);
}

// CPU-bound — BLOCKS the event loop (dangerous!)
function hitungPrimaBesar(batas: number): number[] {
  const prima: number[] = [];
  for (let n = 2; n <= batas; n++) {
    let adalahPrima = true;
    for (let i = 2; i * i <= n; i++) {
      if (n % i === 0) { adalahPrima = false; break; }
    }
    if (adalahPrima) prima.push(n);
  }
  return prima; // With batas=10_000_000, this can freeze the UI for several seconds!
}

Web Workers — Parallelism in the Browser #

Web Workers let you run JavaScript code on a separate thread inside the browser. Worker threads can’t access the DOM, but they can do heavy computation without freezing the user interface.

File Structure and TypeScript Configuration #

src/
├── main.ts          # Main thread — DOM access, UI
├── workers/
│   └── komputasi.worker.ts  # Worker — heavy computation
└── types/
    └── worker-messages.ts   # Shared types between main and worker
// src/types/worker-messages.ts
// Types shared between the main thread and worker — the key to type-safe communication

export type PesanKeWorker =
  | { tipe: "HITUNG_PRIMA"; batas: number; requestId: string }
  | { tipe: "KOMPRESI_DATA"; data: number[]; requestId: string }
  | { tipe: "BATALKAN"; requestId: string };

export type PesanDariWorker =
  | { tipe: "HASIL_PRIMA"; prima: number[]; requestId: string }
  | { tipe: "HASIL_KOMPRESI"; hasil: number[]; requestId: string }
  | { tipe: "PROGRESS"; persen: number; requestId: string }
  | { tipe: "ERROR"; pesan: string; requestId: string };

Worker Code #

// src/workers/komputasi.worker.ts
import type { PesanKeWorker, PesanDariWorker } from "../types/worker-messages";

// Inside a worker, 'self' refers to the DedicatedWorkerGlobalScope
// TypeScript knows this if lib is configured correctly

function hitungBilPrima(batas: number, requestId: string): void {
  const prima: number[] = [];

  for (let n = 2; n <= batas; n++) {
    let adalahPrima = true;
    for (let i = 2; i * i <= n; i++) {
      if (n % i === 0) { adalahPrima = false; break; }
    }
    if (adalahPrima) prima.push(n);

    // Send progress every 100,000 iterations
    if (n % 100_000 === 0) {
      const pesan: PesanDariWorker = {
        tipe: "PROGRESS",
        persen: Math.round((n / batas) * 100),
        requestId,
      };
      self.postMessage(pesan);
    }
  }

  const hasil: PesanDariWorker = {
    tipe: "HASIL_PRIMA",
    prima,
    requestId,
  };
  self.postMessage(hasil);
}

// Event listener with safe typing
self.addEventListener("message", (event: MessageEvent<PesanKeWorker>) => {
  const pesan = event.data;

  switch (pesan.tipe) {
    case "HITUNG_PRIMA":
      hitungBilPrima(pesan.batas, pesan.requestId);
      break;
    case "KOMPRESI_DATA":
      // Compression implementation...
      break;
    case "BATALKAN":
      // A worker can't really be cancelled from inside,
      // but it can use a flag to stop early
      break;
  }
});

Using the Worker on the Main Thread #

// src/main.ts
import type { PesanKeWorker, PesanDariWorker } from "./types/worker-messages";

// A type-safe wrapper class for Web Workers
class WorkerPrima {
  private worker: Worker;
  private pendingRequests = new Map<
    string,
    {
      resolve: (prima: number[]) => void;
      reject: (error: Error) => void;
      onProgress?: (persen: number) => void;
    }
  >();

  constructor() {
    // Vite/webpack know how to handle this Worker import
    this.worker = new Worker(
      new URL("./workers/komputasi.worker.ts", import.meta.url),
      { type: "module" }
    );

    this.worker.addEventListener(
      "message",
      (event: MessageEvent<PesanDariWorker>) => {
        this.tanganiPesan(event.data);
      }
    );

    this.worker.addEventListener("error", (event) => {
      console.error("Worker error:", event.message);
    });
  }

  private tanganiPesan(pesan: PesanDariWorker): void {
    const pending = this.pendingRequests.get(pesan.requestId);
    if (!pending) return;

    switch (pesan.tipe) {
      case "HASIL_PRIMA":
        this.pendingRequests.delete(pesan.requestId);
        pending.resolve(pesan.prima);
        break;

      case "PROGRESS":
        pending.onProgress?.(pesan.persen);
        break;

      case "ERROR":
        this.pendingRequests.delete(pesan.requestId);
        pending.reject(new Error(pesan.pesan));
        break;
    }
  }

  hitungPrima(
    batas: number,
    onProgress?: (persen: number) => void
  ): Promise<number[]> {
    const requestId = crypto.randomUUID();

    return new Promise((resolve, reject) => {
      this.pendingRequests.set(requestId, { resolve, reject, onProgress });

      const pesan: PesanKeWorker = {
        tipe: "HITUNG_PRIMA",
        batas,
        requestId,
      };
      this.worker.postMessage(pesan);
    });
  }

  hentikan(): void {
    this.worker.terminate();
  }
}

// Usage — without freezing the UI
async function jalankan(): Promise<void> {
  const workerPrima = new WorkerPrima();

  console.log("Mulai menghitung... UI tetap responsif!");

  const prima = await workerPrima.hitungPrima(
    1_000_000,
    (persen) => console.log(`Progress: ${persen}%`)
  );

  console.log(`Ditemukan ${prima.length} bilangan prima`);
  workerPrima.hentikan();
}

Worker Threads in Node.js #

Node.js provides the worker_threads module for running JavaScript on separate threads. Its API is similar to Web Workers but with additional capabilities like SharedArrayBuffer and workerData.

Basic Worker in Node.js #

// src/workers/kalkulasi.worker.ts — Node.js worker file
import { parentPort, workerData, isMainThread } from "worker_threads";

// Make sure this file runs as a worker, not the main thread
if (isMainThread) {
  throw new Error("File ini harus dijalankan sebagai worker thread!");
}

interface DataWorker {
  angka: number[];
  operasi: "jumlah" | "rata-rata" | "maks" | "min";
}

interface HasilWorker {
  hasil: number;
  durasi: number;
}

const { angka, operasi } = workerData as DataWorker;
const mulai = Date.now();

let hasil: number;

switch (operasi) {
  case "jumlah":
    hasil = angka.reduce((a, b) => a + b, 0);
    break;
  case "rata-rata":
    hasil = angka.reduce((a, b) => a + b, 0) / angka.length;
    break;
  case "maks":
    hasil = Math.max(...angka);
    break;
  case "min":
    hasil = Math.min(...angka);
    break;
}

const hasilAkhir: HasilWorker = {
  hasil,
  durasi: Date.now() - mulai,
};

parentPort?.postMessage(hasilAkhir);
// src/main.ts — Node.js main thread
import { Worker, isMainThread } from "worker_threads";
import path from "path";

interface DataWorker {
  angka: number[];
  operasi: "jumlah" | "rata-rata" | "maks" | "min";
}

interface HasilWorker {
  hasil: number;
  durasi: number;
}

function jalankanWorker(data: DataWorker): Promise<HasilWorker> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(
      path.resolve(__dirname, "./workers/kalkulasi.worker.js"),
      { workerData: data }
    );

    worker.on("message", (hasil: HasilWorker) => resolve(hasil));
    worker.on("error", reject);
    worker.on("exit", (kode) => {
      if (kode !== 0) {
        reject(new Error(`Worker berhenti dengan kode: ${kode}`));
      }
    });
  });
}

// Run several calculations in parallel
async function main(): Promise<void> {
  const dataSet = Array.from({ length: 10_000_000 }, (_, i) => i + 1);

  console.log("Menjalankan kalkulasi paralel...");

  const [hasilJumlah, hasilRataRata, hasilMaks] = await Promise.all([
    jalankanWorker({ angka: dataSet, operasi: "jumlah" }),
    jalankanWorker({ angka: dataSet, operasi: "rata-rata" }),
    jalankanWorker({ angka: dataSet, operasi: "maks" }),
  ]);

  console.log(`Jumlah: ${hasilJumlah.hasil} (${hasilJumlah.durasi}ms)`);
  console.log(`Rata-rata: ${hasilRataRata.hasil} (${hasilRataRata.durasi}ms)`);
  console.log(`Maks: ${hasilMaks.hasil} (${hasilMaks.durasi}ms)`);
}

main().catch(console.error);

Worker Pool — Managing Multiple Workers #

Creating a new worker for every task is expensive. A worker pool maintains a collection of ready-to-use workers:

// src/worker-pool.ts
import { Worker } from "worker_threads";
import path from "path";

interface Task<T, R> {
  data: T;
  resolve: (result: R) => void;
  reject: (error: Error) => void;
}

class WorkerPool<T, R> {
  private workers: Worker[] = [];
  private workerTersedia: number[] = [];
  private antrean: Task<T, R>[] = [];
  private readonly ukuranPool: number;

  constructor(
    private readonly workerScript: string,
    ukuranPool?: number
  ) {
    // Default: the number of available CPUs, capped at 4 workers
    const { cpus } = require("os");
    this.ukuranPool = ukuranPool ?? Math.min(cpus().length, 4);
    this.inisialisasi();
  }

  private inisialisasi(): void {
    for (let i = 0; i < this.ukuranPool; i++) {
      const worker = new Worker(this.workerScript);
      const id = this.workers.push(worker) - 1;
      this.workerTersedia.push(id);

      worker.on("message", (hasil: R) => {
        const task = this.antrean.shift();
        if (task) {
          task.resolve(hasil);
        } else {
          this.workerTersedia.push(id);
        }
      });

      worker.on("error", (error) => {
        const task = this.antrean.shift();
        if (task) {
          task.reject(error);
        }
        this.workerTersedia.push(id);
      });
    }
  }

  eksekusi(data: T): Promise<R> {
    return new Promise((resolve, reject) => {
      const task: Task<T, R> = { data, resolve, reject };

      const workerId = this.workerTersedia.shift();
      if (workerId !== undefined) {
        this.workers[workerId].postMessage(data);
        // Store resolve/reject for use when the message arrives
        // (A full implementation needs to manage the workerId → task mapping)
      } else {
        // All workers are busy — put it in the queue
        this.antrean.push(task);
      }
    });
  }

  async hentikanSemua(): Promise<void> {
    await Promise.all(this.workers.map((w) => w.terminate()));
  }

  get statistik() {
    return {
      total: this.ukuranPool,
      tersedia: this.workerTersedia.length,
      sibuk: this.ukuranPool - this.workerTersedia.length,
      antrean: this.antrean.length,
    };
  }
}

SharedArrayBuffer and Ownership Transfer #

Inter-thread communication via postMessage does a structured clone by default — data is copied, not shared. For large data, this can be slow. There are two more efficient ways:

Transferable — Ownership Transfer #

// Transfer an ArrayBuffer to the worker — without copying the data
// After the transfer, the buffer can no longer be accessed on the main thread

const buffer = new ArrayBuffer(1024 * 1024); // 1 MB
const view = new Uint8Array(buffer);
view.fill(42);

// Transfer ownership to the worker
worker.postMessage({ buffer }, [buffer]);

// After this, the buffer is no longer valid on the main thread
// view.byteLength === 0 — the buffer has been transferred

// In the worker, receive and use the buffer
self.addEventListener("message", (event) => {
  const { buffer } = event.data as { buffer: ArrayBuffer };
  const data = new Uint8Array(buffer);
  // Process the data...
  // Transfer it back to the main thread
  self.postMessage({ hasil: buffer }, [buffer]);
});

SharedArrayBuffer — Shared Memory #

// SharedArrayBuffer — a buffer accessible from the main thread AND the worker simultaneously
// NOTE: Requires security headers on the server (COOP + COEP)

const buffer = new SharedArrayBuffer(4); // 4 bytes = 1 32-bit integer
const counter = new Int32Array(buffer);

// Main thread
worker.postMessage({ counter }); // Not copied — SHARED

// Worker
self.addEventListener("message", (event) => {
  const { counter } = event.data as { counter: Int32Array };

  // Use Atomics for concurrency-safe operations
  Atomics.add(counter, 0, 1); // Atomically increment counter[0]
  Atomics.notify(counter, 0); // Notify threads waiting on this
});

// Main thread: wait for the change
Atomics.wait(counter, 0, 0); // Wait until counter[0] isn't 0
console.log("Counter:", counter[0]); // 1
SharedArrayBuffer requires special HTTP headers on the server to prevent Spectre attacks: Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without these headers, SharedArrayBuffer isn’t available in modern browsers. In Node.js, there’s no such restriction.

When to Use Worker Threads #

This question is often answered wrong. Worker threads have overhead — thread creation, data serialization, communication. The benefits are only real for work heavy enough to justify it:

flowchart TD
    A{Type of Work?} --> B[I/O-bound\nfetch, file, DB]
    A --> C[Light CPU-bound\n< 50ms]
    A --> D[Heavy CPU-bound\n> 100ms]

    B --> E[async/await + event loop\nAlready optimal, worker NOT needed]
    C --> F[Probably doesn't need a worker\nOverhead can exceed the benefit]
    D --> G{Can it be parallelized?}

    G -- Yes, independent data --> H[Worker Threads\nHighly Recommended]
    G -- No, sequential --> I[Optimize the algorithm\nWorkers don't help]

    style E fill:#51cf66,color:#fff
    style F fill:#fcc419,color:#000
    style H fill:#339af0,color:#fff
    style I fill:#ff6b6b,color:#fff

Cases Right for Worker Threads #

✓ Image or video processing (resize, filter, encode)
✓ Intensive mathematical computation (machine learning inference, cryptography)
✓ Parsing large files (large CSV/JSON, complex XML)
✓ Data compression/decompression
✓ Text search in large corpora
✓ Complex rendering (3D, raytracing)

Cases That Don’t Need Worker Threads #

✗ Database operations (already async via the driver)
✗ HTTP requests (async via fetch/axios)
✗ Small file I/O (Node.js fs is already async)
✗ Simple data transformations (map, filter on small arrays)
✗ Query string parsing, JSON.parse for small data

Summary #

  • JavaScript/TypeScript is fundamentally single-threaded — the event loop handles concurrency for I/O-bound work very efficiently without multiple threads.
  • Worker threads are only needed for CPU-bound work — if you’re waiting on network or files, async/await is enough; worker threads only help when the main thread is busy computing.
  • Define shared message types between the main thread and worker in a separate file — this is the only way to get full type safety on inter-thread communication because workers and the main thread don’t share type scope directly.
  • Build a type-safe wrapper class for workers — instead of using postMessage and addEventListener directly, wrap them in a class exposing a familiar Promise API.
  • A Worker Pool is more efficient than a worker per task — creating a new worker for every task has overhead; maintain a pool of ready-to-use workers for recurring tasks.
  • Transferable for large data — use ownership transfer instead of structured clone for large ArrayBuffers; this avoids expensive memory copying.
  • SharedArrayBuffer + Atomics for shared state — only needed for extreme cases where the worker and main thread must share memory; requires special HTTP headers in the browser.
  • Measure before optimizing — worker thread overhead (creation, serialization, communication) can be larger than the benefit for light tasks; profile first before adding worker thread complexity.

← Previous: Vendoring   Next: I/O →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact