Single-threaded execution, a non-blocking concurrency model, and the event loop that ties them together. The real question isn't "sync vs async" — it's what yields the call stack and when it resumes.
JS runs on one thread with one call stack. "Async" doesn't add threads — it means work is handed to the host (browser/Node C++ APIs, libuv), and a callback is queued to run later, only when the stack is empty. The event loop drains one macrotask, then all microtasks (Promises/await), then renders, then repeats. Blocking the stack (heavy sync loop) starves everything — that's the real cost. await is syntactic sugar over a Promise continuation; it yields the stack, it does not parallelize.
The JS engine (V8) only gives you a stack + heap. Concurrency comes from the host environment around it.
await continuations, queueMicrotask, MutationObserver. High priority.setTimeout, I/O callbacks, UI events, setImmediate (Node). One per loop turn.An async API call returns immediately; the engine keeps executing. The host does the slow work on another thread and, when done, enqueues your callback. The engine only picks it up once the stack unwinds.
This ordering is the whole ballgame. Per iteration ("tick"):
requestAnimationFrame callbacks, then style/layout/paint if needed.A Promise chain resolves before the next setTimeout(…,0) — microtasks always beat the next macrotask. And a runaway microtask loop can freeze the UI even without a single long function.
console.log('1 sync'); setTimeout(() => console.log('4 macrotask'), 0); Promise.resolve().then(() => console.log('3 microtask')); console.log('2 sync'); // → 1 sync · 2 sync · 3 microtask · 4 macrotask
Sync code runs first (it's on the stack). When the stack empties, all microtasks flush, then one macrotask. Order is deterministic and layer-driven, not timing-driven — the 0ms is a lie; it's "next task queue turn, at soonest."
| Microtask | Macrotask | |
|---|---|---|
| Sources | Promise .then, await, queueMicrotask | setTimeout, I/O, events, setImmediate |
| Drained | All, each tick | One per tick |
| Priority | Higher | Lower |
An async function always returns a Promise. Each await splits the function: everything after it becomes a microtask continuation that runs when the awaited value settles. It suspends the function and pops the stack — it does not block the thread.
async function load() { const a = await fetch('/a'); // suspend; stack unwinds; loop free const b = await fetch('/b'); // resumes as microtask when /a settles return [a, b]; // SERIAL — b waits for a }
Sequential awaits that have no dependency = accidental serialization. Kick off first, await together:
const [a, b] = await Promise.all([fetch('/a'), fetch('/b')]); // concurrent
"Concurrent" here = both requests in flight at the host level while JS waits. The JS thread still processes their callbacks one at a time. Real parallelism needs Web Workers / worker_threads.
forEach with an async callback doesn't await — use for…of (serial) or Promise.all(map(...)) (concurrent)..catch / try-await. Add a global handler + always attach one.await in a loop when items are independent → slow. Batch with Promise.all / allSettled.promisify.await new Promise(r => setTimeout(r)) or scheduler.yield().Promise.race for timeouts; AbortController to actually cancel the in-flight work.| Need | Reach for |
|---|---|
| Fan out, need all | Promise.all (fail-fast) / allSettled (collect all) |
| First to finish / timeout | Promise.race, Promise.any |
| Cancel in-flight | AbortController + signal |
| True CPU parallelism | Web Workers / worker_threads |
| Stream/async iteration | for await…of, async generators |
| Cooperative yielding | scheduler.yield() / setTimeout chunking |
await) always beat the next setTimeout.await suspends the function and pops the stack; it never blocks the thread.awaits in sequence = accidental serialization → Promise.all.