Language and Runtime Concurrency Models: JavaScript and TypeScript

JavaScript gives each page, worker or Node.js thread exactly one thread of execution — an *agent* — that runs one piece of code at a time to completion.

Learning Objectives

Explain the run-to-completion model of a single agent; tasks, microtasks and the event loop; promises and async/await.

Use Web Workers and Node.js worker threads, child processes, SharedArrayBuffer and Atomics, streams with backpressure, AbortController, and promise combinators.

Distinguish browser and Node.js host APIs.

Plain-Language Explanation

JavaScript gives each page, worker or Node.js thread exactly one thread of execution — an *agent* — that runs one piece of code at a time to completion. Concurrency comes from the event loop overlapping waits; parallelism comes only from additional agents (workers) or processes, which communicate by messages and, optionally, shared memory.

Precise Technical Explanation

Run-to-completion model for a single agent A function, once started, is never interrupted by other JavaScript in the same agent; callbacks and continuations run only when the stack is empty. There are no data races on ordinary objects — but there are logical races across awaits.

Tasks, microtasks and event loop Module 11: a task, then all microtasks, then rendering/next task; Node's libuv phases with process.nextTick.

Module 9: states, chaining, combinators, unhandled rejections.

async / await Module 10: suspend on await; errors propagate as rejections; for await over async iterables.

Browser workers run scripts in separate agents with their own event loops; communication via postMessage (structured clone or transferable ArrayBuffers); SharedWorker and service workers are related agents. No DOM access in workers.

Node.js worker threads node:worker_threads: Worker, parentPort, workerData, MessageChannel, transfer lists, SharedArrayBuffer. Each worker has its own V8 isolate and event loop (demonstrated below).

node:child_process: spawn, exec, execFile, fork (Node-to-Node IPC channel); cluster for multi-process servers.

SharedArrayBuffer and Atomics where available Module 7: shared raw memory plus sequentially consistent Atomics; browsers require cross-origin isolation; Atomics.wait is not allowed on the browser main thread (use Atomics.waitAsync).

Streams and backpressure Node streams (highWaterMark, write() returning false, 'drain', pipeline) and WHATWG Web Streams (ReadableStream pull model, desiredSize, pipeTo) — Module 11 and below.

AbortController and cancellation patterns AbortController/AbortSignal cancel fetch, timers (timers/promises), streams, event listeners ({ signal }); AbortSignal.timeout(ms) and AbortSignal.any([...]) compose deadlines and parent cancellation.

all, allSettled, any, race (Module 9).

Browser versus Node.js host APIs child_process, cluster process.nextTick, setImmediate

SharedArrayBuffer (cross-origin isolated) Node streams + WHATWG streams not on main thread Atomics.wait allowed

Key Terms

One JS execution thread with its own event loop A callback is never interrupted by other JS in its agent Separate agent for parallel work Copying messages between agents

Object whose ownership moves (zero-copy) Cancellation token for JS APIs

Diagram or Execution Timeline

eventloop

Syntax and API Overview

const { Worker } = require("node:worker_threads");          // Node.js
const w = new Worker(new URL("./job.js", import.meta.url));   // browser: new Worker(url)
w.postMessage(data); w.on("message", handle);                  // browser: w.onmessage = handle
const ac = new AbortController(); fetch(url, { signal: ac.signal }); ac.abort();

Complete Code Example

CPU work on the main thread versus a worker pool, transferring an ArrayBuffer, a child process, an AbortSignal that cancels a timer, and Web Streams backpressure (Node.js 24, one real run):

A typed concurrency limiter in TypeScript (run directly by Node.js 24's type stripping):

const { Worker, isMainThread, parentPort, workerData } = require("node:worker_threads");
const { execFileSync } = require("node:child_process");
const { setTimeout: sleep } = require("node:timers/promises");

function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }

if (!isMainThread) {
  if (workerData.kind === "fib") parentPort.postMessage(fib(workerData.n));
  else {
    const view = new Uint8Array(workerData.buffer);       // the transferred buffer now belongs to this worker
    parentPort.postMessage(view.reduce((a, b) => a + b, 0));
  }
  return;
}

const runWorker = (data, transfer = []) => new Promise((resolve, reject) => {
  const w = new Worker(__filename, { workerData: data, transferList: transfer });
  w.once("message", resolve);
  w.once("error", reject);
});

(async () => {
  let t0 = performance.now();
  const inline = [fib(35), fib(35), fib(35), fib(35)];
  const mainMs = performance.now() - t0;
  t0 = performance.now();
  const parallel = await Promise.all([35, 35, 35, 35].map((n) => runWorker({ kind: "fib", n })));
  const workerMs = performance.now() - t0;
  console.log(`4 x fib(35): main thread ~${Math.round(mainMs / 100) * 100} ms, 4 workers ~${Math.roun
…
4 x fib(35): main thread ~500 ms, 4 workers ~200 ms, same results: true
transferred ArrayBuffer: worker summed 1024; main's buffer.byteLength is now 0 (detached)
child process: child pid differs: true
AbortSignal cancelled the timer: AbortError (user navigated away)
ReadableStream (highWaterMark 2): before reading, the source produced only 2 chunks
after reading everything: 10 chunks
type Task<T> = () => Promise<T>;

async function mapLimit<T>(tasks: Task<T>[], limit: number): Promise<T[]> {
  const results: T[] = new Array(tasks.length);
  let next = 0;
  let active = 0;
  let peak = 0;
  async function lane(): Promise<void> {
    while (next < tasks.length) {
      const index = next++;                      // safe: no other code runs between the check and the increment
      active++;
      peak = Math.max(peak, active);
      results[index] = await tasks[index]();
      active--;
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, lane));
  console.log(`peak concurrency ${peak} (limit ${limit})`);
  return results;
}

const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const jobs: Task<string>[] = Array.from({ length: 8 }, (_, i) => async () => { await wait(10 + (i % 3) * 10); return `job-${i}`; });

mapLimit(jobs, 3).then((r) => console.log(r.join(" ")));
peak concurrency 3 (limit 3)
job-0 job-1 job-2 job-3 job-4 job-5 job-6 job-7

Line-by-Line Explanation

fib(35) four times on the main thread runs sequentially; four workers compute in parallel on separate cores (each worker has its own V8 isolate). Worker start-up costs tens of milliseconds, so for small jobs (try fib(30)) workers are *slower* — a pool that reuses workers fixes that.

transferList: [buffer] moves the memory to the worker; the main thread's ArrayBuffer becomes detached (byteLength 0) — zero-copy, single owner.

execFileSync(process.execPath, …) starts a separate Node process (different PID).

timers/promises setTimeout accepts an AbortSignal; aborting rejects it with an AbortError carrying the reason as cause.

ReadableStream with highWaterMark: 2 calls pull only until its queue holds 2 chunks — backpressure built into the pull model.

mapLimit: the check next < tasks.length and next++ run without interruption (run-to-completion), so lanes never take the same index; at most limit tasks are awaited at once.

Interleaving or State Trace

mapLimit with 3 lanes: lanes take indexes 0, 1, 2 synchronously; each awaits; whichever task finishes first resumes its lane, which takes index 3, and so on — results stored by index preserve input order.

Correctness and Safety Analysis

Within one agent, code between awaits is atomic; across agents, only messages (copied or transferred) or Atomics on shared memory communicate. The limiter is correct because index allocation contains no await.

Common Mistakes and Misconceptions

CPU-heavy work on the main thread or server event loop.

Expecting postMessage to share objects (it copies).

Using a transferred buffer afterwards.

Promise.all over thousands of requests with no limit.

Forgetting to pass signal down to inner fetch calls.

Failure Modes and Debugging Steps

Event-loop lag metrics; --cpu-prof; worker 'error'/'exit' events; node --trace-warnings for unhandled rejections; Chrome DevTools for workers (each appears as a separate target).

Performance and Scalability Implications

Workers cost an isolate (~MBs) and start-up time (~tens of ms) — use a pool (e.g. Piscina) for many tasks; transfer large buffers instead of copying; keep messages small.

Security Considerations

Cross-origin isolation is required for SharedArrayBuffer because high-resolution shared-memory timers enable side-channel attacks; validate messages from workers like any untrusted input when they run third-party code.

Platform and Runtime Caveats

Node 24 runs .ts files by stripping types (no enums or parameter properties — erasable syntax only); ReadableStream, AbortSignal.any, fetch are globals in modern Node.

Browsers forbid Atomics.wait on the main thread.

Real-World Applications

Node servers offloading image processing to worker pools, browser apps doing parsing/crypto in Web Workers, Electron apps, build tools (esbuild/webpack workers), stream processing in Node.

Best Practices

Keep the main thread/event loop for coordination and I/O.

Use worker pools for CPU work; transfer buffers.

Limit concurrency of async fan-out.

Thread AbortSignal through every API.

Respect stream backpressure.

Exercises

E1 · Beginner. Why can't two .then callbacks in the same page modify a variable at the same time?

Answer: Each callback runs to completion on the agent's single thread; they run one after another, never simultaneously.

E2 · Intermediate. Why does mapLimit keep results in input order even though tasks finish out of order?

Answer: Each lane stores its result at results[index] — the index taken when the task started — rather than appending in completion order.

E3 · Advanced challenge. Implement a worker pool in Node.js that reuses N workers for M tasks.

Answer: Create N Workers, keep an idle list and a task queue; on submit, if a worker is idle post the task to it, else queue; on a worker's message, resolve that task's promise and give the worker the next queued task (or mark it idle). Handle 'error' by rejecting the task and replacing the worker.

Quiz

? How do you get CPU parallelism in Node.js?
+ worker_threads or child processes :: Separate agents/processes run in parallel.
- async/await :: It overlaps waiting, not computation.
- Promise.all :: It waits for promises; it does not create threads.
- process.nextTick :: It schedules a callback on the same thread.

? After transferring an ArrayBuffer to a worker, what happens to it in the sender?
+ It is detached (byteLength 0) and cannot be used. :: Ownership moved.
- It is a shared view of the same memory. :: That is SharedArrayBuffer.
- It is copied, so both have a copy. :: Transfer avoids the copy.
- It is garbage collected immediately. :: It still exists, detached.

? Which are true? (select all that apply)
+ Code between two awaits is not interleaved with other JS in the same agent. :: Run-to-completion.
+ SharedArrayBuffer in browsers requires cross-origin isolation. :: COOP/COEP headers.
- Web Workers can manipulate the DOM. :: They cannot.
+ AbortSignal.timeout(ms) can cancel a fetch. :: It aborts after ms.

Summary

JavaScript agents run code to completion on one thread; the event loop provides concurrency, workers and processes provide parallelism, messages (copy or transfer) and SharedArrayBuffer+Atomics provide communication, streams provide backpressure, and AbortSignal provides cancellation. Browsers and Node.js differ in host APIs.

Further Reading

Node.js docs: "Worker threads", "Child process", "Web Streams API".

MDN: "Using Web Workers", "AbortController", "Streams API".

ECMAScript spec: "Agents" and "Memory Model".

Surma, "When should you be using Web Workers?" (2019).