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:#000The 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
SharedArrayBufferrequires special HTTP headers on the server to prevent Spectre attacks:Cross-Origin-Opener-Policy: same-originandCross-Origin-Embedder-Policy: require-corp. Without these headers,SharedArrayBufferisn’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:#fffCases 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/awaitis 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
postMessageandaddEventListenerdirectly, 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.
Transferablefor large data — use ownership transfer instead of structured clone for largeArrayBuffers; this avoids expensive memory copying.SharedArrayBuffer+Atomicsfor 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.