Asynchronous JavaScript in Depth
A precise event-loop mental model — the call stack, microtasks, and macrotasks — plus cancellation, retries, race conditions, and async iteration.
By the end of this lesson, you can
- Predict execution order across synchronous code, microtasks, and macrotasks
- Cancel an in-flight fetch with AbortController, and explain why cancellation matters for correctness, not just performance
- Recognize a race condition caused by out-of-order async responses, and fix it
- Iterate an async iterable with for await...of
Why it matters
Asynchronous Basics and Data Fetching covered
async/await well enough to write resilient fetch code. Real applications also need to cancel
requests that are no longer needed, avoid races between out-of-order responses, and reason precisely
about when a piece of async code actually runs relative to everything else — this lesson builds
that precision.
The event loop, precisely
JavaScript runs on one thread with a call stack. When the stack is empty, the event loop pulls the next unit of work from one of two queues — and it always fully drains the microtask queue before running even one task from the macrotask queue:
| Queue | Examples | Drained when |
|---|---|---|
| Microtasks | A resolved Promise's .then/await continuation, queueMicrotask | Immediately after the current synchronous code finishes — every microtask, before anything else |
| Macrotasks (tasks) | setTimeout, setInterval, I/O callbacks, UI events | One at a time, only after the microtask queue is completely empty |
console.log('1: sync');
setTimeout(() => console.log('2: macrotask'), 0);
Promise.resolve().then(() => console.log('3: microtask'));
console.log('4: sync');
// Output order: 1: sync, 4: sync, 3: microtask, 2: macrotask
Cancellation with AbortController
An in-flight request that’s no longer needed — the user typed a new search query, navigated away — should be cancelled, both to save bandwidth and, more importantly, to prevent its eventual response from arriving late and overwriting more recent state:
const controller = new AbortController();
fetch('/api/search?q=term', { signal: controller.signal })
.then((response) => response.json())
.catch((error) => {
if (error.name === 'AbortError') {
console.log('Request was cancelled — this is expected, not a failure.');
} else {
throw error;
}
});
controller.abort(); // cancels the fetch abovecontroller.signal can be passed to any API that supports cancellation (fetch, some timer
wrappers); controller.abort() rejects the associated promise with an AbortError, which should be
handled as an expected outcome, not a genuine failure.
Race conditions from out-of-order responses
A race condition happens when the order two async operations finish in — not the order they started in — affects the result, and that order isn’t guaranteed:
// BUGGY: an earlier, slower request can resolve after a later, faster one —
// overwriting the correct, more recent result with stale data.
let latestResults;
async function search(query) {
const response = await fetch(`/api/search?q=${query}`);
latestResults = await response.json(); // last write wins, regardless of which query it's for
}
// FIXED: track which request is actually the most recent, and ignore stale ones.
let currentRequestId = 0;
async function searchFixed(query) {
const requestId = ++currentRequestId;
const response = await fetch(`/api/search?q=${query}`);
const data = await response.json();
if (requestId === currentRequestId) {
latestResults = data; // only apply it if nothing newer has started since
}
}AbortController (cancelling the stale request outright) and this request-id pattern (letting the
stale request finish but ignoring its result) are the two standard fixes — cancellation is generally
preferable when supported, since it also stops wasted work.
Retries and idempotency
A failed request is sometimes worth retrying — but only if the operation is idempotent: calling
it multiple times has the same effect as calling it once. Retrying a GET is normally safe;
retrying a POST that charges a credit card is not, unless the server explicitly supports safe
retry (an idempotency key, for instance).
async function fetchWithRetry(url, retries = 3, delayMs = 300) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await fetch(url);
if (response.ok) return response;
} catch {
// network error — fall through to retry
}
if (attempt < retries) {
await new Promise((resolve) => setTimeout(resolve, delayMs * 2 ** attempt)); // exponential backoff
}
}
throw new Error(`Failed after ${retries + 1} attempts.`);
}
Exponential backoff (doubling the delay each attempt) avoids hammering a struggling server with immediate, repeated retries.
Async iteration
for await...of iterates an async iterable — one whose Symbol.asyncIterator produces
promises rather than plain values — the async counterpart to Symbol.iterator from Functional
Techniques and Collection Design:
async function* fetchPages(baseUrl) {
let page = 1;
while (true) {
const response = await fetch(`${baseUrl}?page=${page}`);
const data = await response.json();
if (data.items.length === 0) return;
yield data.items;
page++;
}
}
for await (const items of fetchPages('/api/results')) {
console.log(`Got ${items.length} items`);
}
This is the foundation streaming APIs (a ReadableStream’s reader, for instance) build on —
processing chunks of data as they arrive, without waiting for the entire response.
Check your understanding
Or reveal the answer without checking
Answer:a d c b
Synchronous code (a, d) runs first, in order. The microtask queue (c) is then fully drained before the event loop touches the macrotask queue (b) — regardless of the 0ms delay.
Or reveal the answer without checking
Answer:POST requests are not idempotent by default — retrying could place the order twice
Unlike a GET, a POST that creates or charges something typically isn't idempotent — calling it twice can have twice the effect. Safe retry needs server-side support, such as an idempotency key that recognizes a duplicate request.
Practice: warm-up
Trace the execution order, then check:
async function asyncFn() {
console.log('1');
await null;
console.log('2');
}
console.log('start');
asyncFn();
console.log('end');
Stuck? Reveal one hint at a time.
Hint 1
Everything in asyncFn before its first await runs synchronously, immediately, when asyncFn() is called.
Hint 2
The code after await (here, console.log("2")) is scheduled as a microtask, resuming only after the current synchronous code finishes.
Reveal the order
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
start
1
end
2Practice: apply it
Write debounce(fn, delayMs) returning a function that only calls fn after delayMs have passed
since the last call to the debounced function — if it’s called again before that, the previous
pending call is cancelled.
Stuck? Reveal one hint at a time.
Hint 1
A closure-held timer id (from setTimeout) lets you clear the previous pending call each time the debounced function is invoked again.
Hint 2
clearTimeout(existingId) before scheduling a new setTimeout is the core of the pattern.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
function debounce(fn, delayMs) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delayMs);
};
}
const debouncedSearch = debounce((query) => console.log('searching for', query), 300);
debouncedSearch('a');
debouncedSearch('ab'); // cancels the pending call for "a"
debouncedSearch('abc'); // cancels the pending call for "ab" — only this one actually runsSummary
- The event loop always fully drains the microtask queue (promise continuations) before running even
one macrotask (
setTimeout, I/O) —setTimeout(fn, 0)never means “immediately.” AbortControllercancels an in-flight request, both to save work and to prevent a stale response from overwriting more recent state — a correctness concern, not just performance.- A race condition from out-of-order responses is fixed either by cancelling the stale request or by tracking and ignoring results from anything but the most recent one.
- Retrying is only safe for an idempotent operation — repeating it has the same effect as doing it once; exponential backoff avoids hammering a struggling server on retry.
for await...ofiterates an async iterable, the foundation streaming and paginated-fetch patterns build on.