Processes, Threads & Tasks

JavaScript: One Event Loop per Agent, Not One Thread per Runtime

JavaScript *execution* is one event loop per agent — but the runtime around it is thoroughly multi-threaded: Node does file and crypto work on a thread pool, network I/O through the kernel's readiness API, and both Node and browsers offer real parallelism through workers with message passing.

▶ Run the lab

The question this answers

The question

If JavaScript runs one piece of code at a time, what exactly is doing the other work — and where does real parallelism come from?

The work

A Node service handling 2000 concurrent connections: reading request bodies from sockets, reading templates from disk, hashing passwords with scrypt, and rendering a 900 KB report.

What is shared

Within one agent: every module-level object, every closure and every cache, shared across all tasks on that event loop and interleaved at every await. Across agents: nothing, unless it is a SharedArrayBuffer.

The invariant — what must stay true under every interleaving

A session's permission set contains every role that any successfully-returned grant call added, and no request observes a partially-updated session — regardless of how many tasks interleave on the loop.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

What is actually single about it

runtime-specific· Node 18+. Browsers replace libuv with the host's own I/O implementation and worker_threads with Web Workers; the agent model is the same.

The precise statement is: each JavaScript agent has exactly one event loop, and only one piece of JavaScript executes in that agent at a time. An agent is the main thread of a page, a Web Worker, a Node process's main thread, or a worker_threads worker. Each has its own loop, its own heap and its own copy of everything.

What is emphatically not single-threaded is the runtime hosting that agent. Node's libuv maintains a worker thread pool — four by default, settable via UV_THREADPOOL_SIZE — that performs filesystem operations, DNS resolution via getaddrinfo, and CPU-heavy crypto and zlib work off the loop. Network sockets do not use that pool at all; they go through epoll, kqueue or IOCP, so ten thousand sockets cost no threads. Confusing those two paths is why "raise UV_THREADPOOL_SIZE" is such a common non-fix for a network-bound service.

And real parallelism is available: worker_threads in Node and Web Workers in browsers are separate agents that run genuinely simultaneously on separate cores. They communicate by message passing with structured cloning, so nothing is shared unless you deliberately allocate a SharedArrayBuffer — at which point you have ordinary shared-memory concurrency, with Atomics as your only tool and a memory model to respect. In browsers a SharedArrayBuffer additionally requires cross-origin isolation via COOP and COEP headers.

One Node process: one JS agent, several thread pools, one kernel readiness path
exactly one at a timenon-blocking; readiness callbacksblocking work offloaded4 threads by default — a real queuecompletion → task queued on the loopcompletion → task queued on the loopparallelism starts heredata cloned, not sharedopt-in: shared memory, needs Atomicsbrowsers also need COOP/COEPMain agent — one event loop, one heapYour JS: one task at a time, interleaved at every awaitpostMessage — structured clone or transferSockets via epoll / kqueue / IOCP — no threadslibuv thread pool (default 4, UV_THREADPOOL_SIZE)worker_threads #1 — separate agent, own loop + heapworker_threads #2 — separate agentfs.*, dns.lookup, zlib, crypto (pbkdf2/scrypt)SharedArrayBuffer + Atomics — genuinely shared memory
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Moving CPU work off the loop, in four languages

runtime-specific· Node 18+ for the JS/TS entries; CPython 3.12; C++17.

The comparison that makes JavaScript's model legible is: how do you run a CPU-bound job without freezing the caller? Three of these languages start a thread that shares memory. JavaScript starts a separate agent and sends it a copy — which is much closer to multiprocessing in Python than to threading, and has the same consequence: the boundary costs serialisation, and nothing is shared by accident.

The mistake this design invites is specific and constant: reaching for Promise.all to make CPU work parallel. Promise.all waits for several promises; it creates no execution units. Four CPU-bound functions inside Promise.all run sequentially on the one loop, take exactly as long as they would in a for loop, and additionally freeze every other task in the agent for the whole duration. See [[async-is-not-parallel]] and [[promise-all-and-gather]].

The other thing worth knowing is which built-ins already leave the loop. crypto.scrypt (callback form) runs on the libuv pool; crypto.scryptSync does not and blocks everything. zlib.gzip uses the pool; zlib.gzipSync does not. The asynchronous variant is not merely stylistic — it is the difference between using a thread and monopolising the only one that matters.

Run a CPU-bound job without blocking the caller. Note what is shared in each. — Hash a password with scrypt without blocking the rest of the program
JavaScriptNODE.JS
1// WRONG: blocks the event loop for ~100 ms.
2const h = crypto.scryptSync(pw, salt, 64)
3
4// RIGHT: runs on the libuv thread pool; the loop stays free.
5const h = await promisify(crypto.scrypt)(pw, salt, 64)
6
7// For your OWN CPU code there is no pool -- start an agent:
8const w = new Worker('./hash.js', { workerData: { pw, salt } })
9const h = await once(w, 'message') // data is CLONED across

Node ships a thread pool and uses it for specific built-ins, but your own JavaScript never runs on it. Anything you wrote that computes for a long time must go to a worker_threads agent, and the arguments are structured-cloned rather than shared.

TypeScriptNODE.JS
1const pool = new WorkerPool(os.availableParallelism())
2const hashes = await Promise.all(
3 users.map(u => pool.run<Buffer>({ pw: u.pw, salt: u.salt }))
4)
5// Promise.all does NOT create parallelism. The pool does.
6// Without the pool these run one after another on the loop.

Identical semantics to JavaScript; the types add nothing concurrency-wise. Included because it is where the Promise.all misconception is most often written down: awaiting many promises is concurrency, and only a worker agent is parallelism.

PythonCPYTHON
1# I/O-bound: a thread is fine, the GIL is released around it
2h = await asyncio.to_thread(hashlib.scrypt, pw, salt=salt, n=16384, r=8, p=1)
3# hashlib releases the GIL, so this genuinely uses another core.
4# Pure-Python CPU work would need a process instead:
5with ProcessPoolExecutor() as ex:
6 h = await loop.run_in_executor(ex, pure_python_hash, pw)

Python has both doors. to_thread works here only because hashlib releases the GIL inside its C implementation; for pure-Python computation the equivalent of a JavaScript worker is a process.

C++
1auto fut = std::async(std::launch::async, [&] {
2 return scrypt(pw, salt, 64); // runs on another core NOW
3});
4// ... other work continues on this thread ...
5auto h = fut.get(); // shared memory throughout:
6 // pw and salt are captured by
7 // reference and must outlive the task

A thread on another core with full shared memory and no serialisation cost. The flip side: capturing by reference across a thread boundary is a lifetime and data-race hazard that JavaScript's clone-on-send design makes impossible.

What actually differs
  • JavaScript parallelism is agent-based and message-passing: each worker has its own loop and heap, and data is structured-cloned unless it is a SharedArrayBuffer or an explicitly transferred buffer.
  • C++ threads share the address space directly — zero copy, and every data race is undefined behaviour.
  • CPython sits between the two: threads share memory but serialise bytecode, so parallelism needs either a GIL-releasing C extension or separate processes.
  • Node's libuv pool serves specific built-ins (fs, dns.lookup, zlib, some crypto) and never runs user JavaScript; network I/O bypasses it entirely through the kernel readiness API.
  • Promise.all and await are concurrency constructs in every JS runtime. Neither creates an execution unit, and neither makes CPU-bound code parallel.

One thread, no data races, and a lost update anyway

runtime-specific· Node 18+. The same schedule occurs identically in a browser with two overlapping fetch handlers on the main thread.

Because only one piece of JavaScript runs at a time in an agent, JavaScript has no data races in the memory-model sense — with the single exception of SharedArrayBuffer, where they are real and Atomics is how you avoid them. A great many engineers conclude from this that JavaScript cannot have concurrency bugs. That conclusion is wrong, and the reason is [[overlapping-progress]]: every await is a point where another task on the same loop runs.

The schedule below is two HTTP handlers modifying a session's permission set through an in-memory cache. Both read the set, both await a database round trip, both write back a modified copy. One grant vanishes. There is one thread, no parallelism, no shared-memory access of any kind, and the bug is a textbook lost update.

The fixes are the same as anywhere else, expressed in async primitives: do not span an await with a read-modify-write; hold a per-key async mutex across the whole sequence; or push the update into the store as an atomic operation with a version check. What does not work is reasoning that one thread implies atomicity. See [[data-races]] for the distinction and [[optimistic-concurrency-control]] for the version-check approach.

Two Express handlers, one event loop, one thread. The `await` between read and write is the bug.ILLUSTRATIVE
Invariant · sessionCache[id].roles contains every role added by any grant call that returned 200.
#Handler 1 — POST /grant billingHandler 2 — POST /grant adminEvent loopState
1··run H1 to its first awaitcache.s7=read running=H1
2const roles = [...cache["s7"].roles] // ["read"]··cache.s7=read H1.snapshot=read
3await db.assertQuota("s7") — yields the loop··cache.s7=read running=none
4··run H2 — same thread, same loopcache.s7=read running=H2
5·const roles = [...cache["s7"].roles] // ["read"]·cache.s7=read H2.snapshot=read
6·await db.assertQuota("s7") — yields·cache.s7=read running=none
7resume; cache["s7"].roles = ["read","billing"]; res.json(200)··cache.s7=read,billing
8·resume; cache["s7"].roles = ["read","admin"]; res.json(200)·cache.s7=read,admin
✕ The "billing" role granted and confirmed by handler 1 is gone. H2 wrote an array derived from a snapshot taken before H1's write.
Two 200 responses, one surviving grant, one thread, zero data races. SharedArrayBuffer is the only place JavaScript can have a data race; it can have race conditions everywhere. The correct fixes: keep the read and the write in the same synchronous block, guard the sequence with a per-session async mutex, or make the update a conditional write in the store with a version check.

Key points

  • Each JavaScript agent has one event loop and runs one piece of JavaScript at a time. A page, a Web Worker and a Node worker thread are each separate agents.
  • The runtime around the agent is multi-threaded: Node's libuv pool (default 4) handles fs, dns.lookup, zlib and some crypto.
  • Network sockets do not use the thread pool — they go through epoll/kqueue/IOCP, which is why thousands of connections cost no threads.
  • Real parallelism comes from separate agents: worker_threads in Node, Web Workers in browsers, communicating by structured-cloned messages.
  • SharedArrayBuffer is the one path to genuinely shared memory, and it brings back data races and requires Atomics; in browsers it also requires COOP/COEP isolation.
  • Promise.all creates no execution units. Four CPU-bound functions inside it run sequentially and freeze the loop.
  • Every await is an interleaving point. Single-threadedness prevents data races and not race conditions.
  • Choose the async variant of built-ins deliberately: scryptSync and gzipSync block the loop; their callback forms use the pool.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • The event loop drains phases in order — timers, pending callbacks, poll, check, close — running each queued task to completion.
  • A task runs until it returns or awaits; the loop cannot reclaim control in the middle, so any long computation holds it.
  • Non-blocking network I/O is registered with the kernel readiness mechanism; a ready socket queues a task for the next poll phase.
  • Blocking operations that have no readiness equivalent — filesystem calls, getaddrinfo, scrypt — are dispatched to libuv's thread pool and their completion queues a task.
  • Microtasks (promise reactions) drain fully between macrotasks, which is why an infinite chain of resolved promises can starve timers and I/O.
  • new Worker creates an agent with its own loop, heap and module graph; postMessage structured-clones the payload unless the buffer is transferred or shared.
Interleavings that matter
  • H1 reads and writes with no await between: atomic with respect to every other task on the loop, and the invariant holds for free.
  • H1 reads, awaits, H2 reads, both write — the lost update above, on one thread.
  • A handler calls crypto.scryptSync: the loop is frozen for around 100 ms and 2000 connections make no progress, including the health check.
  • Six concurrent fs.readFile calls with the default pool of 4: two queue behind the others, adding latency that looks like slow disk and is actually pool saturation.
  • Main agent and a worker both write a SharedArrayBuffer index without Atomics: a genuine data race, in JavaScript, with genuinely undefined interleaving of the two writes.
  • A promise chain that resolves immediately and re-queues itself: microtasks drain forever, timers never fire, and the process is busy with 100% CPU on one core.
What it guarantees — and does not
  • The runtime guarantees that a JavaScript task runs to completion without another task in the same agent interleaving inside it.
  • It guarantees no data races on ordinary objects, because there is no simultaneous access — SharedArrayBuffer is the documented exception.
  • It guarantees nothing about state across an await. Anything reachable from another task may have changed.
  • It guarantees microtasks drain before the next macrotask, which is a scheduling guarantee and a starvation hazard.
  • Workers guarantee isolation: separate heaps, separate globals, no accidental sharing. They guarantee nothing about the cost of the message boundary, which is proportional to payload size.
  • The libuv pool guarantees the loop is not blocked by the operations it handles. It does not guarantee they run immediately — four threads is a queue.
Where contention appears
  • The event loop is the primary contention point, and it is total: one slow task delays every other task in the agent.
  • The libuv pool is a bounded queue at default size 4; heavy fs or crypto use serialises behind it and presents as unexplained latency.
  • The microtask queue can starve macrotasks entirely, producing a busy process that accepts no new connections.
  • Worker message boundaries contend on serialisation cost — cloning a 40 MB object is CPU work on the sending loop, so offloading can cost more than it saves.
  • SharedArrayBuffer contention is ordinary cache-line contention between cores, with Atomics and its memory ordering as your only tools.
How it fails
  • Event-loop starvation from a synchronous CPU section: every connection in the agent stalls, health checks time out, and the orchestrator restarts a healthy process.
  • Race condition across an await: lost updates on in-memory caches and counters, on one thread.
  • libuv pool saturation: fs or crypto latency that rises with concurrency while CPU and disk both look idle.
  • Unhandled promise rejection from a fire-and-forget task, which in modern Node terminates the process by default.
  • Unbounded task creation: promises are cheap, so 100 000 in-flight operations are easy to create and exhaust the heap.
  • Data race on a SharedArrayBuffer accessed without Atomics — the one place JavaScript has the C++ problem.
When it helps
  • High-concurrency network services: thousands of connections on one thread with no per-connection stack, which is the model at its best.
  • Browser UI work, where the single main thread is a hard constraint and workers exist precisely to keep it responsive. See [[ui-concurrency]].
  • I/O-heavy glue services — gateways, BFFs, proxies — where nearly all the time is spent waiting.
  • CPU work moved to a worker pool, where the model gives you isolation and message passing rather than shared-memory hazards.
When it hurts
  • CPU-bound work on the main agent, which serialises it and freezes everything else at the same time.
  • Large data hand-offs to workers, where structured cloning of the payload can exceed the computation being offloaded.
  • Heavy filesystem or synchronous-crypto workloads, which queue on a four-thread pool that most teams do not know exists.
  • Anywhere the team believes single-threaded means safe, which produces the lost-update schedule above with complete confidence.
How you would know
  • Event-loop lag: the delay between scheduling a zero-millisecond timer and it firing. The single most important metric for a Node service and the most commonly missing one.
  • Task duration distribution on the loop; any task above a few milliseconds is a latency source for everything else in the agent.
  • libuv pool queue depth and wait time, which distinguishes slow disk from a saturated pool.
  • Active handle and active request counts, which reveal leaked timers, sockets and unfinished operations.
  • Worker message sizes and round-trip times, to confirm the offload is actually cheaper than doing it inline.
  • Heap size against in-flight promise count, since unbounded task creation shows up as heap growth before it shows up as latency.
Complexity it introduces
  • Two kinds of concurrency in one runtime — loop-level interleaving and agent-level parallelism — with different sharing rules and different bugs.
  • The worker boundary constrains payloads to structured-cloneable values: no functions, no class identity, no open handles.
  • Async colouring propagates through the call graph, and a single synchronous library in a hot path defeats the model for the whole agent.
  • Reasoning about await points is mandatory for correctness on shared in-memory state, and nothing in the language marks the regions that were implicitly atomic.
  • SharedArrayBuffer plus Atomics is a full shared-memory concurrency model with a memory ordering to respect — everything this domain teaches about memory models applies there.
Simpler alternatives
  • Move CPU work out of the process entirely: a job queue and a separate service, which also gets you independent scaling. See [[background-jobs]].
  • Use a native addon that releases to a thread, or a built-in that already uses the libuv pool, rather than building a worker pool by hand.
  • Run more processes — one per core behind a load balancer or the cluster module — which is the standard Node scaling answer and needs no in-process parallelism.
  • Chunk long computations with explicit yields (setImmediate, await null) so the loop is released periodically. Crude, effective, and far simpler than a worker for medium-sized jobs.
  • Push the invariant to the datastore: a conditional update with a version removes the in-memory race entirely. See [[optimistic-concurrency-control]].

Three I/O calls, one thread

Three I/O calls, one thread
Each request costs 2 ms to dispatch, waits on the network, then costs 5 ms to parse. Nothing here is parallel — there is exactly one thread in both runs.
Event loop
idle — nothing else to run
idle — nothing else to run
idle — nothing else to run
GET /profile
awaiting I/O 120 ms
GET /flags
awaiting I/O 40 ms
GET /orders
awaiting I/O 80 ms
↑ done 261 ms
runningreadywaitingblockedidlems
wall clock
261 ms
CPU actually used
21 ms
thread-time spent waiting
0 ms
threads used
1
sequential   const a = await getProfile(); const b = await getFlags(); const c = await getOrders()
concurrent   const [a, b, c] = await Promise.all([getProfile(), getFlags(), getOrders()])

wall clock   261 ms  →  127 ms       (2.06× less waiting)
CPU used     21 ms  →  21 ms       (identical — no extra core was touched)
261 ms of wall clock to do 21 ms of work. 92.0% of the run is the event loop sitting idle with nothing to do, because each `await` suspends the whole chain before the next request has even been issued. The three calls are independent — nothing in `getFlags()` needs the profile. Flip the toggle and the same thread, the same code path and the same CPU budget finish in 127 ms.
SIMULATEDRUNTIME-SPECIFIC

Sequential awaits vs Promise.all

Three independent awaits
None of the three calls needs another's result. Written one `await` per line they still run one at a time.
users-svc
90 ms
cart-svc
not issued yet
60 ms
promo-svc
not issued yet
120 ms
↑ response at 270 ms
runningreadywaitingblockedidlems
response time
270 ms
your throughput
148/s
peak downstream concurrency
40
downstream call rate
444/s
sequential   90 + 60 + 120 = 270 ms      peak downstream concurrency = 40 × 1 = 40
Promise.all  max(90, 60, 120) = 120 ms      peak downstream concurrency = 40 × 3 = 120

throughput   148/s → 333/s     downstream sees 444/s → 1000/s
270 ms of latency for 270 ms of waiting that could have overlapped. `await` means "suspend this chain until the value arrives", not "start this now". Three independent calls written on three lines run strictly in series, and the 2 later services sit idle while the first one is queried. The fix is `Promise.all` / `asyncio.gather` — but read the callout after you flip it, because the wall time you save is charged to the services you call.
SIMULATED

Two increments, twenty schedules: find the one that loses an update

Two increments, twenty schedules
Both tasks run counter++ on the same variable. Drive the schedule yourself: read, add, write are three separate steps, and the scheduler may cut between any two of them.
6/6 steps
counter
2
increments completed
2
rA / rB
1 / 2
invariant
holds
Invariant · after k completed increments, counter === k. No update is lost.
#Task A — counter++Task B — counter++State
1rA ← counter·counter=0 rA=0 rB=0
2rA ← rA + 1·counter=0 rA=1 rB=0
3counter ← rA·counter=1 rA=1 rB=0
4·rB ← countercounter=1 rA=1 rB=1
5·rB ← rB + 1counter=1 rA=1 rB=2
6·counter ← rBcounter=2 rA=1 rB=2
counter = 2, and both callers are right. This schedule happens to be safe because one task finished entirely before the other started. Safe once is not safe: press "Enumerate all" to see how many of the possible schedules do not. Testing samples this space; it does not cover it.
SIMPLIFIEDcounter++ modelled as three indivisible steps. Real compilers and CPUs can split it further, or fuse it into one atomic instruction.

One of five fails — what happens to the siblings?

One of five fails — what happens to the other four?
Task C rejects at 40 ms. The interesting question is not what the caller sees; it is what the siblings are doing at 41 ms.
Combinator
A · charge card
charge card
B · reserve stock
reserve stock
still running, result discarded
C · fraud check
fraud check
D · send receipt
send receipt
still running, result discarded
E · update ledger
update ledger
still running, result discarded
↑ caller resumes 40 ms
runningreadywaitingblockedidlems
caller resumes at
40 ms
caller sees
1 rejection
siblings still running after
3
unobserved work
95 ms
t+0all five tasks started
t+30A resolved · charge card
t+40C rejected → Promise.all rejects NOW; the caller's await throws
t+41B, D, E are still executing — nothing cancelled them
t+55B resolved · reserve stock — result dropped on the floor
t+70E resolved · update ledger — stock reserved and ledger updated for an order the caller believes failed
t+90D resolved · receipt sent to the customer
Promise.all           rejects on the FIRST rejection; siblings are NOT cancelled and keep running
Promise.allSettled    never rejects; resolves at 90 ms with {status, value|reason} for all five
asyncio.gather(...)   return_exceptions=False → raises at 40 ms, siblings still NOT cancelled
                      return_exceptions=True  → returns at 90 ms with the exception as a value
asyncio.TaskGroup     the structured alternative: on failure it CANCELS the siblings, then raises
The caller resumed at 40 ms; 3 siblings ran on for another 95 ms of unobserved work. `Promise.all` is a combinator over promises, not a supervisor over tasks — it decides when you stop waiting, and has no power to stop anything. The stock stays reserved, the ledger entry lands and the receipt is emailed for an order your code has already reported as failed. Worse: if one of those late siblings rejects, it rejects with nobody listening, which is an unhandled rejection (a process-level warning or crash in Node, a "Task exception was never retrieved" in asyncio). If you need the siblings to stop, you need cancellation — an `AbortController` threaded into every call, or a structured construct like `asyncio.TaskGroup` or a nursery.
SIMULATEDRUNTIME-SPECIFIC

What people believe, and what is true

Claim

JavaScript is single-threaded.

Reality

JavaScript *execution within one agent* is one task at a time. The runtime uses threads for filesystem, DNS and crypto work, and worker agents give real parallelism on separate cores.

Claim

Single-threaded means no race conditions.

Reality

It means no data races on ordinary objects. Every await is an interleaving point, and a read-modify-write spanning one loses updates exactly as it would on eight threads.

Claim

Promise.all runs things in parallel.

Reality

It waits for several promises concurrently. It creates no execution units, so CPU-bound functions inside it run sequentially and block the loop.

Claim

Async I/O means Node uses a thread per connection under the hood.

Reality

Network I/O uses the kernel readiness API and no threads at all. The pool exists for operations with no non-blocking equivalent — mainly the filesystem.

Claim

Raising UV_THREADPOOL_SIZE will speed up my API.

Reality

Only if the bottleneck is fs, dns.lookup, zlib or pool-backed crypto. For a network-bound service it changes nothing, because those requests never touch the pool.

Go deeper

Overview

One event loop per agent runs your JavaScript one task at a time. The runtime uses threads underneath, and workers give you real parallelism through message passing.

Practical

Keep CPU work off the loop — an async built-in, a worker, or explicit chunking. Graph event-loop lag. And treat every await as a place where other handlers touch your shared state.

Advanced

The agent model deliberately makes shared-memory concurrency opt-in, which is why JavaScript has no data races except in SharedArrayBuffer. The cost is that the boundary is a copy, so offloading is worthwhile only when the computation exceeds the serialisation — a calculation that gets skipped and turns worker pools into pure overhead.

Apply it