079: Asynchronous JavaScript: Call Stack, Event Loop, Timers, and Callbacks
Learning outcomes
By the end of this lesson, you should be able to:
- 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 that JavaScript itself creates a thread for every operation.
Retrieval warm-up
Answer these before reading on. The point is to surface your current model, not to guess terminology perfectly.
- When one function calls another, which function must finish first?
- What happens to a page while JavaScript runs an infinite
whileloop? - Is a function value the same thing as calling that function?
Self-check: draw global -> greet -> formatName for nested calls. Get the call flow clear before you add specification vocabulary to it.
Vocabulary
- Synchronous: Operations completing in order before subsequent statements run. — Source: MDN: Execution model
- Asynchronous: Work started now whose completion is observed later through callbacks or promises. — Source: MDN: Execution model
- Call stack: A last-in-first-out record of active function calls that determines execution order. — Source: MDN: Execution model — Call stack
- Stack frame / execution context: The data tracked for one running function call, including bindings, position, and
this. — Source: MDN: Execution model — Stack frames - Blocking: Occupying the single thread so that no other work can proceed there in the meantime. — Source: MDN: Execution model
- Host environment: The runtime that embeds the JavaScript engine, such as a browser or Node, and supplies facilities such as timers,
fetch, and the DOM. — Source: WHATWG HTML: Webappapis - Task: A queued unit of host work, such as a timer callback or an event dispatch. — Source: WHATWG HTML: Task queues
- Microtask: High-priority queued work, including Promise reactions, drained before the next task. — Source: MDN: Microtask guide
- Event loop: The host processing model that pulls queued work when the call stack is clear. — 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
For a first model, imagine one cook performing one recipe step at a time. The cook's stack records the current recipe and any sub-recipe called from it. A slow chopping loop keeps that cook occupied, so nothing else at that station can move forward.
Some work can be handed to services outside the station. A timer service watches the clock, and networking machinery waits for bytes. Starting either operation does not put its eventual JavaScript callback directly onto the current stack. Once the operation reaches the relevant point, the host makes follow-up work eligible. The event loop can run that work only after the current JavaScript has finished.
The analogy is intentionally limited. Browsers use multiple processes and threads internally, and workers create separate JavaScript agents. The useful beginner-level 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 automatically mean parallel CPU execution.
Self-check: for each example, answer three separate questions: "What runs now?", "What does the host arrange for later?", and "What becomes runnable when the stack clears?" Do that before reaching for task and microtask terminology. The event loop does not continuously scan your source, and callbacks do not interrupt an active function.
Stack first
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; and then console.log prints the result. Each active call sits above the call that invoked it. Nothing is deferred here, so the sequence is synchronous and predictable.
Blocking is about occupying execution
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");
While the loop is running, clicks, painting, and timer callbacks cannot run on that page's main event loop. Do not try a longer version. Asynchrony is useful when the program is waiting for host operations, but merely marking a function async does not move an expensive calculation off the main thread. Large CPU-bound work may require smaller chunks, a better algorithm, or a Web Worker.
Beginner self-study example: the cafe pickup board
Run this in a browser console. Predict the output before you test it.
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
- The first log runs synchronously.
setTimeoutasks the host to schedule its callback after at least zero milliseconds. It returns immediately. Zero does not override code that is already running or microtasks that are already queued.Promise.resolve()creates an already-fulfilled Promise..then(...)schedules its reaction as a microtask; the reaction still does not run inline.- The final synchronous log runs.
- The current JavaScript finishes and the stack clears. At the microtask checkpoint, the runtime runs the Promise reaction.
- On a later event-loop turn, the timer task can run.
Expected output
1. Take order
2. Serve next customer
3. Microtask: receipt recorded
4. Timer: order is ready
Do not reduce this to the inaccurate rule "Promises always run before timers." The precise explanation 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 depends on when work becomes eligible and on the host. In particular, real network completion timing is not predictable.
Why network requests are asynchronous
A response might arrive in milliseconds, take seconds, fail, redirect, or never arrive before a timeout policy expires. Blocking the main thread for that entire interval would freeze interaction and rendering. fetch therefore returns a Promise and allows the host to carry out the fetch process while the page handles other runnable work. Later lessons will use that result.
Intermediate example: observable non-blocking work
This deterministic example simulates delivery without depending on a public API.
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");
The beginning is predictable. The final two lines depend on the delays:
Open shop
Started tea
Started cake
Keep serving
Delivered cake
Delivered tea
The calls to deliver are synchronous: they start timers and return. "Cake" is delivered first because its timer becomes eligible earlier, not because JavaScript made a random choice. The delays are still minimum thresholds. A busy main thread can cause either callback to run late.
Try adding this immediately after console.log("Keep serving"):
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 exact timing.
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();
The expected final output is 1250025000. Splitting the work into tasks gives the browser opportunities to process other tasks between chunks. It does not make the additions faster and may increase total elapsed time. For genuinely heavy computation, investigate workers instead of relying on timer-based chunking.
Deep Dive: Tasks, Microtasks, Timers, and Callback Hell
The event loop coordinates JavaScript execution with the APIs supplied by the host.
console.log("A");
setTimeout(() => {
console.log("timer");
}, 0);
Promise.resolve().then(() => {
console.log("microtask");
});
console.log("B");
Typical output:
A
B
microtask
timer
Promise reactions run as microtasks. The runtime processes those microtasks before it moves to the next task, such as the timer callback.
setInterval
const id = setInterval(() => {
console.log("poll");
}, 1000);
setTimeout(() => {
clearInterval(id);
}, 5000);
Intervals can conceptually overlap with slow work. For network polling, a recursive timeout scheduled after the previous operation completes can provide better control.
Callback hell
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 issue is not that callbacks are inherently bad. The issue is deeply nested control flow, repeated error paths, and poor composability. Promises and async/await usually give this sequence a clearer structure.
Common mistakes and debugging
- Calling instead of passing:
setTimeout(show(), 1000)callsshownow. UsesetTimeout(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 callback you are waiting for.
- 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 only timestamps. Use performance.now() to measure elapsed time, 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 instead. Keep main-thread tasks short so input and rendering stay responsive. Cancel timers that are no longer needed with clearTimeout(id). Avoid high-frequency intervals when one self-scheduled timeout would prevent overlapping work. Do not put secrets in logs used for timing diagnostics.
Exercises
Level 1: predict
Write the exact output order:
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
A
C
B
The script runs to completion before the timer task gets its turn.
Level 2: repair
Make announce print after roughly 300 ms rather than immediately.
function announce() {
console.log("Ready");
}
setTimeout(announce(), 300);
setTimeout(announce, 300);
Pass the function value, rather than calling it while setting up the timer. The callback may run later than 300 ms.
Level 3: explain mixed queues
Predict and explain:
setTimeout(() => console.log("task"), 0);
Promise.resolve().then(() => console.log("microtask 1"));
Promise.resolve().then(() => console.log("microtask 2"));
console.log("sync");
sync
microtask 1
microtask 2
task
The synchronous code finishes first. The Promise reactions were queued in registration order, so they run at the microtask checkpoint in that order. 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 the program waits. 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 guarantee wall-clock timing.
Official references
- MDN: JavaScript execution model
- MDN: In-depth microtask guide
- WHATWG HTML: Event loops
- WHATWG HTML: Timers
- WHATWG HTML: Microtask queuing
Iterators and generators
An iterable supplies a [Symbol.iterator]() method. Its iterator supplies next(), and each call returns an object shaped like { value, done }. for...of, spread, and array destructuring consume that protocol; none of them requires the source to be an array.
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 values 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.
There are a few edge cases worth keeping in mind. 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. A generator is a good fit when values can be produced lazily or when the consumer may stop early.
Async iterators
An async iterable supplies [Symbol.asyncIterator]() and its next() returns a Promise for { value, done }. for await...of awaits each result and also accepts ordinary synchronous iterables, which makes it useful as a uniform consumption boundary.
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 the right behavior for a paginated API when the next-page cursor comes from the previous page, but it is not a reason to fetch independent pages serially. Decide what return() and throw() should do if a consumer breaks early, and close network or file resources in finally inside the generator.
Interview questions and tests
- What makes an object iterable? It exposes a callable
[Symbol.iterator]()that returns an iterator withnext(). - Does a generator run when it is called? No. Calling it creates a suspended iterator; its body starts at the first
next(). - What is the difference between
yieldandreturn?yieldpauses and can resume;returncompletes the iterator. - Does
for await...ofmake independent work parallel? No. Each iteration waits for the prior iteration; batch independent work explicitly.
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 it moves to another task or reaches a rendering opportunity.
Before running code, predict the order of synchronous logs, Promise callbacks, queueMicrotask callbacks, and timers. Be able to 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 are different:
| Environment | Common task sources | Microtask behavior | Rendering |
|---|---|---|---|
| Browser | timers, DOM events, network callbacks | Promise reactions and queueMicrotask() drain at microtask checkpoints | the browser may render between tasks, after microtasks |
| Node.js | timers, filesystem/network callbacks, setImmediate() | Promise reactions and queueMicrotask() are drained between callbacks; process.nextTick() has even higher priority | no browser paint loop |
Run this in a browser and then in Node. For Node, save it as order.mjs:
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. In Node, the relative order of a zero-delay timer and setImmediate() depends on where they were scheduled, so do not claim that one always wins. setImmediate is Node-specific. process.nextTick() is Node-specific as well and can starve I/O when recursively scheduled; prefer ordinary Promise microtasks unless you have a documented Node-specific reason to use it.
Runnable queue test
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 useful because it checks queue ordering. An assertion about exact network or timer wall-clock timing would not be. In Node, add process.nextTick(() => record("nextTick")) only when demonstrating its host-specific priority.
Interview questions
- Does
asynccreate a thread? No. It returns a Promise and lets the function yield atawait; CPU-heavy JavaScript still runs on its agent's thread. - Why can a timer be late? Its delay is a minimum eligibility time. The current task, microtasks, OS scheduling, or host throttling can delay execution.
- 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. - 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 observations about host scheduling.
