Concurrency in JavaScript
JavaScript's main thread is single-threaded — it cannot achieve true parallelism for regular JS code. What it achieves is concurrency: interleaving many in-progress async operations on one thread, switching between them at await points and callback boundaries, coordinated by the event loop (see the Event Loop topic).
🎯 What you'll learn
- Concurrency vs Parallelism — the Distinction That Matters Here
- How Single-Threaded Concurrency Actually Works
- Where JavaScript DOES Get Real Parallelism
- Concurrency Enables Throughput, Not Raw Speed-Up of CPU Work
- A Practical Mental Model
1Concurrency vs Parallelism — the Distinction That Matters Here
- Parallelism: multiple things physically executing at the exact same instant (requires multiple CPU cores/threads).
- Concurrency: multiple things in progress over overlapping time periods, but not necessarily executing at the same instant.
JavaScript's main thread is single-threaded — it cannot achieve true parallelism for regular JS code. What it achieves is concurrency: interleaving many in-progress async operations on one thread, switching between them at await points and callback boundaries, coordinated by the event loop (see the Event Loop topic).
(A dedicated, deeper comparison of concurrency vs parallelism as general concepts is covered in the Advanced Engineering stack — this topic focuses specifically on how JS achieves concurrency without true parallel threads.)
2How Single-Threaded Concurrency Actually Works
async function taskA() {
console.log("A: step 1");
await delay(50);
console.log("A: step 2");
}
async function taskB() {
console.log("B: step 1");
await delay(50);
console.log("B: step 2");
}
taskA();
taskB();
// Output: A:1, B:1, (both waiting concurrently), A:2, B:2 (roughly together)Neither task literally runs "at the same time" as the other — the engine runs taskA's synchronous part, then taskB's synchronous part, then both resume (via the microtask/macrotask queues) once their delay finishes. The appearance of simultaneity comes from interleaving, not literal parallel execution.
3Where JavaScript DOES Get Real Parallelism
| Mechanism | Parallel? | Notes |
|---|---|---|
async/await, Promises, callbacks | ❌ concurrency only | still one thread, just interleaved |
| Web Workers (browser) | ✅ true parallel threads | separate memory, communicate via postMessage |
| Worker Threads (Node.js) | ✅ true parallel threads | same idea, server-side (own topic in the Backend stack) |
| I/O itself (disk, network) | ✅ happens outside the JS thread entirely | handled by the OS/runtime in the background; JS just gets notified via a callback when done |
The reason async I/O (file reads, network requests) doesn't block the main thread isn't magic — the actual waiting happens in the OS or runtime layer, outside JavaScript's single thread; JS only re-engages once a result is ready.
4Concurrency Enables Throughput, Not Raw Speed-Up of CPU Work
// This does NOT get faster with concurrency — it's pure CPU work,
// blocking the single thread regardless of how "async" it looks:
async function heavyComputation() {
let sum = 0;
for (let i = 0; i < 1e9; i++) sum += i; // still fully blocks the thread
return sum;
}Concurrency helps massively with I/O-bound work (waiting on network/disk — the thread is free to do other things while waiting) but does nothing for CPU-bound work (the thread is genuinely busy computing, with nothing to interleave). CPU-bound work needs real parallelism — Worker Threads / Web Workers — not async/await.
5A Practical Mental Model
I/O-bound task (network request, file read, timer)
→ mostly WAITING, not computing
→ concurrency (async/await) helps a lot — the thread is free during the wait
CPU-bound task (image processing, large sort, heavy math)
→ mostly COMPUTING, not waiting
→ concurrency does nothing — need real parallelism (Worker Threads)Under the HoodWhat a Worker Actually Is▾
When section 3 says Web Workers / Worker Threads provide "true parallel threads," it's worth being precise about what's actually isolated and what isn't — because it explains both why workers achieve real parallelism and why they can't just share objects the way functions in the same thread do.
Each worker gets its own Isolate — a full, separate engine instance
A Worker isn't just "the same JS running on another thread" — V8 spins up a completely separate Isolate: its own heap, its own garbage collector running independently, its own compiled-code caches, entirely isolated from the main thread's Isolate. This is a much stronger boundary than, say, two threads in a language like Java sharing one heap — two Isolates share no memory by default at all.
Main thread: Isolate A — heap A, GC runs independently, Context chain A
Worker thread: Isolate B — heap B, GC runs independently, Context chain B
(genuinely separate engine instances, not just separate call stacks)Why you can't just pass an object to a worker
const worker = new Worker("worker.js");
worker.postMessage({ name: "Ana" }); // this object is NOT shared by referenceBecause the two Isolates have entirely separate heaps, an object living in heap A has no meaning to code running against heap B — there's no shared address space to point into. postMessage doesn't hand over a reference; by default it performs a structured clone: a deep copy of the data, serialized out of one heap and reconstructed fresh inside the other. This is why functions, DOM nodes, and other non-cloneable values can't be sent via postMessage at all, and why sending large data structures repeatedly has a real copying cost — it's not free just because it "looks like" passing a reference.
The one real exception: SharedArrayBuffer
For cases that genuinely need shared memory between threads, SharedArrayBuffer provides an actual shared region of raw memory both Isolates can read/write directly, without cloning — paired with Atomics for safe coordinated access (avoiding the classic race-condition problems true shared-memory concurrency introduces, a topic covered in the Advanced Engineering stack's Race Conditions topic). This is deliberately opt-in and rarely used compared to postMessage, precisely because safely sharing mutable memory across real parallel threads reintroduces the entire category of concurrency bugs (race conditions, torn reads) that JavaScript's single-threaded model normally avoids by construction.
⚠️ Common Pitfalls
- Believing
asyncmakes code run on another thread — it doesn't;async/awaitis entirely about scheduling on the same single thread, not multi-threading. - Expecting concurrency to speed up CPU-heavy loops — only real parallelism (Workers) helps here; wrapping a heavy loop in an
async functionchanges nothing about its blocking behavior. - Assuming "concurrent" means "simultaneous" — in JS, concurrent tasks are interleaved on one thread, not literally running at the same instant (unless offloaded to Workers).
- Not offloading genuinely heavy computation — a long synchronous loop inside an
async functionstill freezes the event loop, blocking timers, network handling, and (in browsers) rendering, exactly as described in the Call Stack and Event Loop topics.
📋 Quick Reference
JS main thread: single-threaded, concurrency via interleaving (event loop)
True parallelism: Web Workers / Worker Threads only
I/O-bound work → concurrency (async/await) helps a lot
CPU-bound work → concurrency does nothing, need real threads🎤 Interview Q&A
Q1What concurrency problems exist in single-threaded JavaScript?core▾
Race conditions between interleaved async operations: stale responses overwriting newer ones, double submissions, read-modify-write across awaits, and shared state changed between await points.
- Example? Two autocomplete responses arriving out of order.
Q2How do you prevent stale response races?core▾
Abort previous requests (AbortController), track a request ID and ignore outdated responses, or use libraries that handle it (TanStack Query, switchMap in RxJS).
- React pattern? An
ignoreflag in effect cleanup.
Q3How do you implement a mutex in JavaScript?core▾
Chain operations on a promise: each caller waits for the previous lock promise and releases by resolving its own; libraries like async-mutex do this. Use it to serialize access to a shared resource across awaits.
- Across processes? Use database locks or distributed locks.
Q4What does Atomics and SharedArrayBuffer provide?senior▾
Shared memory between workers with atomic operations and wait/notify, enabling lock-free algorithms and real multithreading primitives. Browsers require cross-origin isolation headers to enable it.
- Which headers? COOP and COEP.
Q5How do you avoid double form submissions?core▾
Disable the button while pending, track an in-flight promise and return it on repeat calls, and use idempotency keys on the server.
- Why server-side too? Clients can retry or bypass UI.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.