Skip to main content
JavaScript is single-threaded — it processes one thing at a time. Asynchronous behavior is handled by the event loop, which coordinates the call stack with callback queues.

How the event loop works

Call Stack      Web APIs        Microtask Queue   Macrotask Queue
-----------     ----------      ---------------   ---------------
main()          setTimeout      Promise.then      setTimeout cb
fetch()         fetch           queueMicrotask    setInterval cb
                setInterval                       I/O callbacks
Order of execution:
  1. Execute all synchronous code (call stack)
  2. Drain the microtask queue completely (Promises, queueMicrotask)
  3. Pick one task from the macrotask queue (setTimeout, setInterval, I/O)
  4. Repeat from step 2
console.log("1 - sync");

setTimeout(() => console.log("2 - macrotask"), 0);

Promise.resolve().then(() => console.log("3 - microtask"));

console.log("4 - sync");

// Output: 1, 4, 3, 2

Promises

// Creating a Promise
const fetchData = () =>
  new Promise((resolve, reject) => {
    setTimeout(() => resolve("data"), 1000);
  });

// Chaining
fetchData()
  .then(data => data.toUpperCase())
  .then(upper => console.log(upper))
  .catch(err => console.error(err))
  .finally(() => console.log("done"));

// Promise combinators
Promise.all([p1, p2, p3]);        // resolves when ALL resolve; rejects on first failure
Promise.allSettled([p1, p2, p3]); // waits for ALL to settle (never rejects)
Promise.race([p1, p2, p3]);       // resolves/rejects with the first to settle
Promise.any([p1, p2, p3]);        // resolves with first success; rejects if ALL fail

async/await

async/await is syntactic sugar over Promises. An async function always returns a Promise.
async function loadUser(id) {
  try {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) throw new Error("Not found");
    const user = await response.json();
    return user;
  } catch (err) {
    console.error(err);
    throw err; // re-throw so callers can handle it
  }
}

// Parallel await (don't do requests sequentially if they're independent)
const [users, posts] = await Promise.all([
  fetch("/api/users").then(r => r.json()),
  fetch("/api/posts").then(r => r.json())
]);

Microtasks vs macrotasks

QueueExamplesPriority
MicrotaskPromise.then, queueMicrotask, MutationObserverHigher — runs after every task
MacrotasksetTimeout, setInterval, I/O, requestAnimationFrameLower — one per loop iteration

Common interview questions

The event loop is a mechanism that allows JavaScript to perform non-blocking operations despite being single-threaded. It continuously checks if the call stack is empty, then moves callbacks from the task queues to the stack for execution. Microtasks (Promises) are processed before macrotasks (setTimeout).
setTimeout(fn, 0) schedules fn as a macrotask. Even with 0ms delay, it won’t run until: (1) the current synchronous code finishes, and (2) all pending microtasks (Promises) are drained. The minimum delay in browsers is clamped to ~4ms.
  • Promise.all — rejects immediately when any promise rejects. Useful when all results are required and one failure should abort the operation.
  • Promise.allSettled — waits for all promises to settle (resolve or reject). Returns an array of {status, value/reason} objects. Useful when you need all results regardless of individual failures.
Wrap await expressions in try/catch. For multiple independent awaits, each may need its own try/catch or you can use .catch() on individual promises. An unhandled rejection in an async function propagates as a rejected Promise from that function.
// One try/catch handles all awaits in the block
async function run() {
  try {
    const a = await stepOne();
    const b = await stepTwo(a);
    return b;
  } catch (err) {
    // handles rejection from either stepOne or stepTwo
    console.error(err);
  }
}