Quick Answer

JavaScript is single threaded, so only one piece of code runs at a time on the call stack. Asynchronous work is handed to the browser or Node, which puts a callback in a queue when it finishes. The event loop moves queued callbacks onto the stack whenever the stack is empty. Microtasks such as promise callbacks are drained completely before the next macrotask like setTimeout, which is why a resolved promise always logs before a zero-millisecond timeout.

Single Threaded, But Not Blocking

JavaScript has one call stack, so exactly one thing executes at a time. That sounds like it should make network requests freeze the page — and it would, if requests were handled by JavaScript itself.

They are not. When you call setTimeout or fetch, JavaScript hands the work to the surrounding environment — Web APIs in the browser, libuv in Node — which handles timers, network and file I/O outside your single thread. Your code carries straight on.

When that external work finishes, its callback is placed in a queue. The event loop does one job: whenever the call stack is empty, take the next callback from the queue and push it onto the stack.

Call stack   ←  event loop  ←  callback queue
     ↑                              ↑
  your code                    Web APIs / libuv
                              (timers, network, I/O)

So the language is single threaded, but the runtime around it is not. That is the whole trick.

The Ordering That Confuses Everyone

Start with the classic example.

console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');

// Output: 1, 3, 2

Even with a zero delay, 2 comes last. The timeout callback cannot run until the call stack is empty, and the stack does not empty until the current script finishes. Zero milliseconds means "as soon as possible after the current work", not "now".

Now the version that separates people who have read about the event loop from people who understand it:

console.log('start');

setTimeout(() => console.log('timeout'), 0);

Promise.resolve().then(() => console.log('promise'));

console.log('end');

// Output: start, end, promise, timeout

The promise callback beats the timeout despite being written after it. That is not a race — it is guaranteed, and it comes from there being two queues rather than one.

Microtasks Beat Macrotasks, Always

There are two queues with different priorities.

Macrotasks (the task queue): setTimeout, setInterval, I/O callbacks, UI events.

Microtasks: promise callbacks (.then, .catch, .finally), await continuations, queueMicrotask, MutationObserver.

The rule is strict: after each macrotask, the event loop drains the entire microtask queue before taking another macrotask. Not one microtask — all of them, including any added while draining.

setTimeout(() => console.log('macro 1'), 0);
setTimeout(() => console.log('macro 2'), 0);

Promise.resolve().then(() => {
  console.log('micro 1');
  Promise.resolve().then(() => console.log('micro 2'));  // added during draining
});

// Output: micro 1, micro 2, macro 1, macro 2

Both microtasks run before either timeout, because the queue is drained to empty.

This has a sharp edge: a microtask that keeps scheduling microtasks starves the macrotask queue permanently. Timers never fire, rendering never happens, the page locks up. An infinite loop of promises is just as fatal as a while (true), and considerably less obvious.

Where async and await Fit

await is promise syntax, so it follows the microtask rules. Everything after an await is effectively the body of a .then.

async function run() {
  console.log('A');
  await null;              // yields here, even though nothing is pending
  console.log('B');        // scheduled as a microtask
}

run();
console.log('C');

// Output: A, C, B

The function runs synchronously up to the first await, then returns control. The rest is queued as a microtask. This is why B appears after C even though nothing was actually asynchronous.

The practical consequence is sequential awaits in a loop:

// Sequential — each waits for the previous. Three seconds for three calls.
for (const url of urls) {
  await fetch(url);
}

// Parallel — all start immediately, then wait for all. About one second.
await Promise.all(urls.map(u => fetch(u)));

Both are correct code; they differ enormously in speed. Use the sequential form only when each call genuinely depends on the previous result.

Also worth knowing: forEach ignores async callbacks entirely. It does not await them, so the loop finishes before any of the work does. Use a for...of loop or Promise.all.

What Blocking Actually Means

Because there is one thread, any long synchronous operation stops everything — no clicks, no rendering, no timers.

// Freezes the page completely for the duration
function heavy() {
  let total = 0;
  for (let i = 0; i < 1e9; i++) total += i;
  return total;
}

In a browser this is the "page unresponsive" dialog. In Node it is worse in a quieter way: one blocked request stalls every connected client, because they all share the thread.

Common culprits are large JSON.parse calls, synchronous file operations like fs.readFileSync, heavy loops over big arrays, and expensive regular expressions.

The fixes depend on where you are. In the browser, move genuinely heavy computation to a Web Worker, which runs on a separate thread. In Node, use the asynchronous form of I/O and consider worker_threads for CPU-bound work. Where the work can be split, breaking it into chunks separated by setTimeout lets the loop breathe between pieces.

The mental model worth keeping: asynchronous does not mean parallel. Async work is still executed on the same single thread — it is only waiting that happens elsewhere. Ten seconds of computation is ten seconds of frozen page whether or not it sits inside an async function.

Frequently Asked Questions

Why does setTimeout with 0 delay not run immediately? Because the callback is queued as a macrotask and can only run when the call stack is empty. The current script must finish first, and the microtask queue is drained before it too. Zero means as soon as possible, not now.
Why do promises run before setTimeout? Promise callbacks are microtasks, and the event loop drains the entire microtask queue after each macrotask before taking the next one. So a resolved promise always runs before a pending timeout, regardless of the order they were written.
Is JavaScript really single threaded? The JavaScript engine runs your code on one thread, but the runtime around it does not. Browsers handle timers and network requests with Web APIs, and Node uses libuv with a thread pool. Web Workers and worker_threads add genuinely separate threads when you need them.
What is the difference between async and parallel? Async means work can be started and waited for without blocking, but your JavaScript still executes on one thread. Parallel means code genuinely runs at the same time, which requires Web Workers or worker threads. A long computation blocks the page even inside an async function.
Why does my forEach with async not wait? forEach does not await the callback's returned promise, so it starts each one and moves on immediately, finishing before the work completes. Use a for...of loop with await for sequential behaviour, or Promise.all for parallel.