The question this answers
If JavaScript runs one piece of code at a time, what exactly is doing the other work — and where does real parallelism come from?
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.
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.
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.
What is actually single about it
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.
Moving CPU work off the loop, in four languages
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.
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 acrossNode 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.
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.
1# I/O-bound: a thread is fine, the GIL is released around it2h = 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.
1auto fut = std::async(std::launch::async, [&] {2 return scrypt(pw, salt, 64); // runs on another core NOW3});4// ... other work continues on this thread ...5auto h = fut.get(); // shared memory throughout:6 // pw and salt are captured by7 // reference and must outlive the taskA 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.
- 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.allandawaitare 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
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.
| # | Handler 1 — POST /grant billing | Handler 2 — POST /grant admin | Event loop | State |
|---|---|---|---|---|
| 1 | · | · | run H1 to its first await | cache.s7=read running=H1 |
| 2 | const roles = [...cache["s7"].roles] // ["read"] | · | · | cache.s7=read H1.snapshot=read |
| 3 | await db.assertQuota("s7") — yields the loop | · | · | cache.s7=read running=none |
| 4 | · | · | run H2 — same thread, same loop | cache.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 |
| 7 | resume; 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. |
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_threadsin Node, Web Workers in browsers, communicating by structured-cloned messages. SharedArrayBufferis the one path to genuinely shared memory, and it brings back data races and requiresAtomics; in browsers it also requires COOP/COEP isolation.Promise.allcreates no execution units. Four CPU-bound functions inside it run sequentially and freeze the loop.- Every
awaitis an interleaving point. Single-threadedness prevents data races and not race conditions. - Choose the async variant of built-ins deliberately:
scryptSyncandgzipSyncblock 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.
- • 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 Workercreates an agent with its own loop, heap and module graph;postMessagestructured-clones the payload unless the buffer is transferred or shared.
- • 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.readFilecalls 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
SharedArrayBufferindex withoutAtomics: 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.
- • 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 —
SharedArrayBufferis 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.
- • 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.
- •
SharedArrayBuffercontention is ordinary cache-line contention between cores, withAtomicsand its memory ordering as your only tools.
- • 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
SharedArrayBufferaccessed withoutAtomics— the one place JavaScript has the C++ problem.
- • 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.
- • 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.
- • 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.
- • 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
awaitpoints is mandatory for correctness on shared in-memory state, and nothing in the language marks the regions that were implicitly atomic. - •
SharedArrayBufferplusAtomicsis a full shared-memory concurrency model with a memory ordering to respect — everything this domain teaches about memory models applies there.
- • 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
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)
Sequential awaits vs Promise.all
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
Two increments, twenty schedules: find the one that loses an update
| # | Task A — counter++ | Task B — counter++ | State |
|---|---|---|---|
| 1 | rA ← counter | · | counter=0 rA=0 rB=0 |
| 2 | rA ← rA + 1 | · | counter=0 rA=1 rB=0 |
| 3 | counter ← rA | · | counter=1 rA=1 rB=0 |
| 4 | · | rB ← counter | counter=1 rA=1 rB=1 |
| 5 | · | rB ← rB + 1 | counter=1 rA=1 rB=2 |
| 6 | · | counter ← rB | counter=2 rA=1 rB=2 |
One of five fails — what happens to the siblings?
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 raisesWhat people believe, and what is true
JavaScript is single-threaded.
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.
Single-threaded means no race conditions.
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.
Promise.all runs things in parallel.
It waits for several promises concurrently. It creates no execution units, so CPU-bound functions inside it run sequentially and block the loop.
Async I/O means Node uses a thread per connection under the hood.
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.
Raising UV_THREADPOOL_SIZE will speed up my API.
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.