Module: JavaScript
JavaScript·079·11 MIN READ

079: Asynchronous JavaScript: Call Stack, Event Loop, Timers, and Callbacks

TOPICS COVERED: Asynchronous JavaScript: Call Stack, Event Loop, Timers, and Callbacks

Learning outcomes

By the end, you can:

  • trace function calls on a simplified call stack;
  • distinguish blocking synchronous work from non-blocking asynchronous waiting;
  • predict the order of synchronous code, Promise reactions, and timer callbacks;
  • explain why setTimeout(fn, 0) means "not before the delay", not "run immediately";
  • explain why network operations are asynchronous without claiming JavaScript itself creates a thread for each operation.

Retrieval warm-up

Answer before reading further.

  1. When one function calls another, which function must finish first?
  2. What happens to a page while JavaScript runs an infinite while loop?
  3. Is a function value the same thing as calling that function?

Self-check: draw global -> greet -> formatName for nested calls. Focus on the call flow before adding specification vocabulary.

Vocabulary

  • Synchronous: Operations completing in order before subsequent statements run. — Source: MDN: Execution model
  • Asynchronous: Work started now whose completion is observed later via callbacks or promises. — Source: MDN: Execution model
  • Call stack: Last-in-first-out record of active function calls determining execution order. — Source: MDN: Execution model — Call stack
  • Stack frame / execution context: Data tracked for one running function call: bindings, position, this. — Source: MDN: Execution model — Stack frames
  • Blocking: Occupying the single thread so no other work can proceed meanwhile. — Source: MDN: Execution model
  • Host environment: Runtime embedding the engine (browser/Node) supplying timers, fetch, DOM. — Source: WHATWG HTML: Webappapis
  • Task: Queued host work unit such as timer callbacks and event dispatches. — Source: WHATWG HTML: Task queues
  • Microtask: High-priority queued work (promise reactions) drained before the next task. — Source: MDN: Microtask guide
  • Event loop: Host processing model pulling queued work whenever the call stack clears. — Source: WHATWG HTML: Event loops
  • Run to completion: The current task cannot be interrupted midway by another task. — Source: WHATWG HTML: Event loops
  • Job queue (official): "The job queue (event loop queue) holds tasks and microtasks to be processed in order." — Source: MDN: Execution model — Job queue
  • Agent (official): "An agent is an environment that runs JavaScript code, with its own execution context stack and event loop." — Source: ECMAScript: Agents

Mental model: a cook, an order rail, and outside services

Imagine one cook doing one recipe step at a time. The cook's stack records the current recipe and any sub-recipe it called. A slow chopping loop occupies the cook: nothing else at that station progresses.

Some work can be handed to outside services. A timer service watches time; networking machinery waits for bytes. Starting that work does not put its later JavaScript callback directly onto the current stack. When the operation reaches the relevant point, the host makes follow-up work eligible. The event loop eventually runs that work when the current JavaScript has completed.

This model has limits. Browsers use multiple processes and threads internally, and workers create separate JavaScript agents. The useful beginner claim is narrower: ordinary JavaScript for one page event loop runs one job at a time, while host facilities can make progress outside that call stack. A Promise is not a thread, and asynchronous does not necessarily mean parallel CPU execution.

Self-check: for every example, separate three questions: "What runs now?", "What does the host arrange for later?", and "What becomes runnable when the stack clears?" Answer before checking task and microtask terminology. Remember the event loop does not constantly scan source and callbacks do not interrupt active functions.

Stack first

js
function label(name) {
  return name.toUpperCase();
}

function welcome(name) {
  return `Welcome, ${label(name)}!`;
}

console.log(welcome("Mina"));

The script calls welcome; welcome calls label; label returns; welcome returns; console.log prints. Each active call sits above its caller. This is synchronous and predictable.

Blocking is about occupying execution

js
console.log("before");

const end = performance.now() + 2000;
while (performance.now() < end) {
  // Deliberately keep the main thread busy for about two seconds.
}

console.log("after");

During the loop, clicks, painting, and timer callbacks cannot run on that page's main event loop. Do not run a longer version. Asynchrony helps while waiting for host operations, but merely writing async does not move expensive calculation off the main thread. Large CPU work may need smaller chunks, a better algorithm, or a Web Worker.

Beginner self-study example: the cafe pickup board

Run this in a browser console. Predict first, then test.

js
console.log("1. Take order");

setTimeout(() => {
  console.log("4. Timer: order is ready");
}, 0);

Promise.resolve().then(() => {
  console.log("3. Microtask: receipt recorded");
});

console.log("2. Serve next customer");

Step-by-step explanation

  1. The first log runs synchronously.
  2. setTimeout asks the host to schedule its callback after at least zero milliseconds. It returns immediately. Zero does not override currently running code or queued microtasks.
  3. Promise.resolve() creates an already-fulfilled Promise. .then(...) schedules its reaction as a microtask; it still does not run inline.
  4. The final synchronous log runs.
  5. Current JavaScript ends and the stack clears. The microtask checkpoint runs the Promise reaction.
  6. On a later event-loop turn, the timer task can run.

Expected output

text
1. Take order
2. Serve next customer
3. Microtask: receipt recorded
4. Timer: order is ready

Do not turn this into the false rule "Promises always run before timers" without context. The accurate rule for this example is that the already-fulfilled Promise reaction is queued as a microtask during the current task, while the timer callback is a later task. Ordering can depend on when work becomes eligible and on the host. Timing from real networks is not predictable.

Why network requests are asynchronous

A response may take milliseconds, seconds, fail, redirect, or never arrive before a timeout policy. Blocking the main thread for that whole period would freeze interaction and rendering. Fetch therefore returns a Promise and lets the host perform the fetch process while the page can handle other runnable work. Later lessons will consume that result.

Intermediate example: observable non-blocking work

This deterministic example simulates delivery without relying on a public API.

js
function deliver(item, delay) {
  console.log(`Started ${item}`);

  setTimeout(() => {
    console.log(`Delivered ${item}`);
  }, delay);
}

console.log("Open shop");
deliver("tea", 500);
deliver("cake", 100);
console.log("Keep serving");

Expected output begins predictably, while the final two lines depend on the delays:

text
Open shop
Started tea
Started cake
Keep serving
Delivered cake
Delivered tea

The calls to deliver are synchronous. They start timers and return. "Cake" completes first because its timer becomes eligible earlier, not because JavaScript skipped randomly. Even so, delays are minimum thresholds. A busy main thread can make either callback run late.

Try adding this immediately after console.log("Keep serving"):

js
const blockedUntil = performance.now() + 1000;
while (performance.now() < blockedUntil) {}

Both callbacks become late because eligible work cannot interrupt the blocking loop.

Optional advanced stable example: yielding between chunks

This example demonstrates responsiveness, not precise timing.

js
const numbers = Array.from({ length: 50_000 }, (_, index) => index + 1);
let total = 0;
let position = 0;

function addChunk() {
  const stop = Math.min(position + 5000, numbers.length);

  while (position < stop) {
    total += numbers[position];
    position += 1;
  }

  if (position < numbers.length) {
    setTimeout(addChunk, 0);
  } else {
    console.log(total);
  }
}

addChunk();

Expected final output is 1250025000. Splitting work into tasks gives the browser opportunities to process other tasks between chunks. It does not make addition faster and can increase total elapsed time. For truly heavy computation, investigate workers rather than timer-based chunking.

Deep Dive: Tasks, Microtasks, Timers, and Callback Hell

The event loop coordinates JavaScript execution with host APIs.

js
console.log("A");

setTimeout(() => {
  console.log("timer");
}, 0);

Promise.resolve().then(() => {
  console.log("microtask");
});

console.log("B");

Typical output:

text
A
B
microtask
timer

Promise reactions run as microtasks, which are processed before the next task such as a timer callback.

setInterval

js
const id = setInterval(() => {
  console.log("poll");
}, 1000);

setTimeout(() => {
  clearInterval(id);
}, 5000);

Intervals can overlap conceptually with slow work. For network polling, a recursive timeout after completion can provide better control.

Callback hell

js
loadUser(userId, (userError, user) => {
  if (userError) return handle(userError);

  loadOrders(user.id, (orderError, orders) => {
    if (orderError) return handle(orderError);

    loadPayments(orders, (paymentError, payments) => {
      if (paymentError) return handle(paymentError);
      render(payments);
    });
  });
});

The problem is not "callbacks are bad." The problem is deeply nested control flow, repeated error paths, and difficult composition. Promises and async/await improve this structure.

Common mistakes and debugging

  • Calling instead of passing: setTimeout(show(), 1000) calls show now. Use setTimeout(show, 1000).
  • Expecting an exact deadline: timer delays are minimums; load, throttling, nesting rules, and inactive documents can delay callbacks.
  • Believing async means another JS thread: host work can proceed separately, but the callback still needs its event loop.
  • Using a sleep loop: busy waiting blocks the very callback being awaited.
  • Predicting real completion order: record starts and completions with labels; do not infer order from request order.
  • Overusing microtasks: recursively scheduling microtasks can starve tasks and rendering because the microtask queue is drained before another task.

Debug with DevTools breakpoints and the console. Add sequence numbers, not just timestamps. Use performance.now() for elapsed measurements, but treat measurements as observations rather than scheduling guarantees.

Security and performance

Never pass a string to setTimeout; string handlers are compiled like code and create injection risks. Pass a function. Keep main-thread tasks short so input and rendering remain responsive. Cancel timers no longer needed with clearTimeout(id). Avoid high-frequency intervals when one self-scheduled timeout would prevent overlapping work. Do not place secrets in logs used for timing diagnostics.

Exercises

Level 1: predict

Write the exact output order:

js
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
text
A
C
B

The script runs to completion before the timer task.

Level 2: repair

Make announce print after roughly 300 ms rather than immediately.

js
function announce() {
  console.log("Ready");
}

setTimeout(announce(), 300);
js
setTimeout(announce, 300);

Pass the function value. The actual callback may run later than 300 ms.

Level 3: explain mixed queues

Predict and explain:

js
setTimeout(() => console.log("task"), 0);
Promise.resolve().then(() => console.log("microtask 1"));
Promise.resolve().then(() => console.log("microtask 2"));
console.log("sync");
text
sync
microtask 1
microtask 2
task

Synchronous code finishes first. Promise reactions queued in registration order run at the microtask checkpoint. The timer is a later task.

Recap

JavaScript uses a call stack for active synchronous calls. Long synchronous work blocks that event loop. Browser APIs can start operations and arrange later callbacks without freezing the stack while waiting. Current code runs to completion; Promise reactions use microtasks; timers create tasks after at least their delay. These rules predict ordering, but they do not promise wall-clock timing.

Official references

Iterators and generators

An iterable supplies a [Symbol.iterator]() method. Its iterator supplies next(), whose result is always an object shaped like { value, done }. for...of, spread, and array destructuring consume that protocol; they do not require an array.

js
const pages = {
  values: ["intro", "api", "tests"],
  *[Symbol.iterator]() {
    for (const value of this.values) yield value;
  },
};

const labels = [...pages];
console.assert(labels.join("/") === "intro/api/tests");

const iterator = pages[Symbol.iterator]();
console.assert(iterator.next().value === "intro");
console.assert(iterator.next().done === false);
console.assert(iterator.next().value === "tests");
console.assert(iterator.next().done === true);

yield pauses the generator and returns control to its caller. Calling next(input) resumes at the paused yield, so generators can both produce and receive values. The iterable is stateful: two iterators over the same object do not have to share a cursor, while one iterator cannot be rewound unless the generator creates a new one.

Edge cases: an empty iterable produces { done: true, value: undefined }; strings are iterable by Unicode code points; plain objects are not iterable; and spreading an unbounded generator never finishes. Prefer a generator when values can be produced lazily or the consumer may stop early.

Async iterators

An async iterable supplies [Symbol.asyncIterator]() and next() returns a Promise for { value, done }. for await...of awaits each result and also accepts ordinary synchronous iterables, which makes it useful for a uniform consumption boundary.

js
async function* retryableValues(values) {
  for (const value of values) {
    await Promise.resolve(); // stand-in for an I/O boundary
    yield value;
  }
}

const received = [];
for await (const value of retryableValues([2, 4, 6])) received.push(value);
console.assert(received.join(",") === "2,4,6");

The loop is sequential by default. That is often correct for a paginated API because the next page cursor arrives from the previous page, but it is not a license to fetch independent pages serially. Define what return()/throw() should do when a consumer breaks early, and close network or file resources in finally inside the generator.

Interview questions and tests

  1. What makes an object iterable? It exposes a callable [Symbol.iterator]() returning an iterator with next().
  2. Does a generator run when it is called? No. Calling it creates a suspended iterator; the body starts at the first next().
  3. What is the difference between yield and return? yield pauses and can resume; return completes the iterator.
  4. Does for await...of make independent work parallel? No. Each iteration waits for the prior iteration; explicitly batch independent work.
js
const onlyOnce = (function* () { yield "x"; })();
console.assert([...onlyOnce].join("") === "x");
console.assert([...onlyOnce].length === 0); // an iterator is consumed

The event loop and asynchronous execution

JavaScript runs one synchronous job at a time on a call stack. Promise reactions and queueMicrotask callbacks use the microtask queue; timers and many I/O callbacks become tasks. After a task completes, the runtime drains microtasks before another task or rendering opportunity.

Predict the order of synchronous logs, Promise callbacks, queueMicrotask callbacks, and timers before running them. Explain how a long synchronous loop blocks input and rendering, how an unbounded microtask chain can starve the browser, and where AbortController fits into cancellation.

Browser versus Node event loops

The shared core is run-to-completion: JavaScript does not interrupt the current callback to run another callback. The host-specific details differ:

EnvironmentCommon task sourcesMicrotask behaviorRendering
Browsertimers, DOM events, network callbacksPromise reactions and queueMicrotask() drain at microtask checkpointsthe browser may render between tasks, after microtasks
Node.jstimers, filesystem/network callbacks, setImmediate()Promise reactions and queueMicrotask() are drained between callbacks; process.nextTick() has even higher priorityno browser paint loop

Run this in a browser and then in Node (save as order.mjs for Node):

js
console.log("sync");
queueMicrotask(() => console.log("microtask"));
Promise.resolve().then(() => console.log("promise"));
setTimeout(() => console.log("timer"), 0);

if (typeof setImmediate === "function") {
  setImmediate(() => console.log("immediate"));
}

sync, microtask, and promise are stable in this example. The relative ordering of a zero-delay timer and setImmediate() in Node depends on where they were scheduled; do not claim one universally wins. setImmediate is Node-specific. process.nextTick() is also Node-specific and can starve I/O if recursively scheduled, so prefer ordinary Promise microtasks unless you have a documented Node reason.

Runnable queue test

js
const seen = [];
const record = (label) => seen.push(label);

record("sync");
queueMicrotask(() => record("microtask"));
setTimeout(() => {
  record("timer");
  console.assert(seen.slice(0, 2).join(",") === "sync,microtask");
  console.log(seen);
}, 0);
console.assert(seen.join(",") === "sync");

The assertion inside the timer is meaningful; an assertion about exact network or timer wall-clock timing is not. In Node, add process.nextTick(() => record("nextTick")) only to demonstrate its host-specific priority.

Interview questions

  1. Does async create a thread? No. It returns a Promise and lets the function yield at await; CPU-heavy JavaScript still runs on its agent's thread.
  2. Why can a timer be late? Its delay is a minimum eligibility time. The current task, microtasks, OS scheduling, or host throttling may delay execution.
  3. Are browser and Node event loops identical? No. They share run-to-completion and Promise semantics, but task sources, scheduling phases, process.nextTick, setImmediate, and rendering differ.
  4. Can microtasks starve a task? Yes. An endlessly self-queuing microtask chain can prevent timers, input, and browser rendering from getting a turn. Yield with a task or redesign the loop.

Test edge cases: insert a busy loop before the timer, reject a Promise without a handler, and run the same file in a browser and Node. Record which claims are language guarantees and which are host scheduling observations.