IntermediateAsynchronous JavaScript in depth~45 min

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:

Two queues, and the order the event loop drains them in
QueueExamplesDrained when
MicrotasksA resolved Promise's .then/await continuation, queueMicrotaskImmediately after the current synchronous code finishes — every microtask, before anything else
Macrotasks (tasks)setTimeout, setInterval, I/O callbacks, UI eventsOne 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:

JavaScriptCancelling a fetch with AbortController
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 above

controller.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:

JavaScriptA stale-response race condition, and the fix
// 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

In what order does this log? console.log('a'); setTimeout(() => console.log('b'), 0); Promise.resolve().then(() => console.log('c')); console.log('d');
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.

A user retries a failed 'place order' POST request. Why is this potentially unsafe without extra precaution?
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.

  1. Hint 1

    Everything in asyncFn before its first await runs synchronously, immediately, when asyncFn() is called.

  2. 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
2

Practice: 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.

  1. Hint 1

    A closure-held timer id (from setTimeout) lets you clear the previous pending call each time the debounced function is invoked again.

  2. 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 runs

Summary

  • 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.”
  • AbortController cancels 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...of iterates an async iterable, the foundation streaming and paginated-fetch patterns build on.