079: 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.
- 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. 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
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
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.
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 currently running code or queued microtasks.Promise.resolve()creates an already-fulfilled Promise..then(...)schedules its reaction as a microtask; it still does not run inline.- The final synchronous log runs.
- Current JavaScript ends and the stack clears. The microtask checkpoint 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 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.
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:
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"):
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.
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.
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, which are processed before the next task such as a timer callback.
setInterval
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
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)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 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:
console.log("A");
setTimeout(() => console.log("B"), 0);
console.log("C");
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.
function announce() {
console.log("Ready");
}
setTimeout(announce(), 300);
setTimeout(announce, 300);
Pass the function value. The actual 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
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
- 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(), 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.
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.
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
- What makes an object iterable? It exposes a callable
[Symbol.iterator]()returning an iterator withnext(). - Does a generator run when it is called? No. Calling it creates a suspended iterator; the 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; explicitly batch independent work.
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:
| 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 (save as order.mjs for Node):
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
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
- 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 may 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 host scheduling observations.
