Mental Model · not the docs

Sync vs Async in JS, Under the Hood

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.

TL;DR

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.

01 What's actually in the runtime

The JS engine (V8) only gives you a stack + heap. Concurrency comes from the host environment around it.

Call stack
One. LIFO frames. While non-empty, nothing else runs — no callbacks, no rendering, no timers.
Heap + Web/Node APIs
Timers, fetch/XHR, fs, sockets — implemented in native code (browser threads / libuv pool). These run off your thread.
Microtask queue
Promise reactions, await continuations, queueMicrotask, MutationObserver. High priority.
Macrotask (task) queue
setTimeout, I/O callbacks, UI events, setImmediate (Node). One per loop turn.
Key

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.

02 The event loop — one turn

This ordering is the whole ballgame. Per iteration ("tick"):

Consequence

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.

03 Ordering: the interview-classic

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."

MicrotaskMacrotask
SourcesPromise .then, await, queueMicrotasksetTimeout, I/O, events, setImmediate
DrainedAll, each tickOne per tick
PriorityHigherLower

04 async/await is a Promise state machine

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
}
The #1 real bug

Sequential awaits that have no dependency = accidental serialization. Kick off first, await together:

const [a, b] = await Promise.all([fetch('/a'), fetch('/b')]); // concurrent
Not threads

"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.

05 Pitfalls that bite in prod

Correctness

  • forEach with an async callback doesn't await — use for…of (serial) or Promise.all(map(...)) (concurrent).
  • Unhandled rejections — a floating async call with no .catch / try-await. Add a global handler + always attach one.
  • await in a loop when items are independent → slow. Batch with Promise.all / allSettled.
  • Mixing callbacks + Promises → double resolution. Wrap once with promisify.

Performance

  • Long sync work (big JSON.parse, crypto, sort) blocks the stack → dropped frames / stalled I/O. Offload to a worker or chunk with yielding.
  • Microtask storm (recursive Promise chains) starves rendering — nothing paints.
  • Yield to the loop between chunks: await new Promise(r => setTimeout(r)) or scheduler.yield().
  • Promise.race for timeouts; AbortController to actually cancel the in-flight work.

06 Concurrency toolkit

NeedReach for
Fan out, need allPromise.all (fail-fast) / allSettled (collect all)
First to finish / timeoutPromise.race, Promise.any
Cancel in-flightAbortController + signal
True CPU parallelismWeb Workers / worker_threads
Stream/async iterationfor await…of, async generators
Cooperative yieldingscheduler.yield() / setTimeout chunking

Remember six things

  • One thread, one stack. Async = host does the work off-thread and queues a callback.
  • Event loop: one macrotask → drain all microtasks → render → repeat.
  • Microtasks (Promises/await) always beat the next setTimeout.
  • await suspends the function and pops the stack; it never blocks the thread.
  • Independent awaits in sequence = accidental serialization → Promise.all.
  • Concurrent ≠ parallel. CPU-bound? Move it to a worker.