Learn Frontend Part 7: Async JavaScript and the Event Loop - Promises, async/await and AbortController

Learn Frontend Part 7: Async JavaScript and the Event Loop - CodingHumans

JavaScript runs on a single thread, yet a web page can fetch data, run timers and respond to clicks all at once. The mechanism that makes this possible is the event loop. Understanding it explains why setTimeout(fn, 0) does not run immediately, why a promise callback runs before a timer, and why a long loop freezes the whole page.

This is Part 7 of the Learn Frontend series. We cover the call stack, task and microtask queues, promises, async/await, error handling, running requests in parallel, and cancelling work with AbortController.

1. The call stack, Web APIs and the event loop

The JavaScript engine has one call stack: it runs one piece of code at a time, to completion. Slow operations such as timers, network requests and events are handled by the browser (the Web APIs), which queue a callback when they finish.

  1. Run the current script until the call stack is empty.
  2. Run all microtasks: promise callbacks (then, catch, finally), await continuations and queueMicrotask. Microtasks added during this step also run now.
  3. If needed, let the browser render (style, layout, paint), running requestAnimationFrame callbacks just before.
  4. Take one task (macrotask) from the task queue: a timer callback, an event handler, a message. Run it, then go back to step 2.
console.log('1: script start');

setTimeout(() => console.log('5: timeout (task)'), 0);

Promise.resolve()
  .then(() => console.log('3: promise 1 (microtask)'))
  .then(() => console.log('4: promise 2 (microtask)'));

queueMicrotask(() => console.log('3b: queueMicrotask'));

console.log('2: script end');

// Output: 1, 2, 3, 3b, 4, 5
// All microtasks run before the next task (the timeout).

Because rendering waits for the stack to empty, any long synchronous work (a big loop, heavy JSON parsing) freezes scrolling and clicks. That is what the INP metric measures (Part 9). Split long work into chunks, move it to a Web Worker, or yield back to the browser between steps.

2. Callbacks to promises

Early asynchronous code used callbacks, which nest quickly ("callback hell") and make error handling awkward. A Promise represents a value that will be available later and is in one of three states: pending, fulfilled or rejected. Once settled, it never changes.

  • .then() returns a new promise, so steps chain instead of nesting.
  • Returning a value from then passes it on; returning a promise waits for it; throwing rejects the chain.
  • One .catch() at the end handles errors from any earlier step.
  • An unhandled rejection shows a console error and can crash a Node.js process, so always handle errors.

3. async/await and error handling

async functions always return a promise, and await pauses the function until a promise settles without blocking the thread. The code reads top to bottom like synchronous code, and errors are handled with ordinary try/catch.

async function loadDashboard(userId) {
  try {
    // Start both requests at once, then wait for both: total time = slower one
    const [profileRes, ordersRes] = await Promise.all([
      fetch('/api/users/' + userId),
      fetch('/api/users/' + userId + '/orders'),
    ]);
    if (!profileRes.ok || !ordersRes.ok) throw new Error('Request failed');

    const [profile, orders] = await Promise.all([profileRes.json(), ordersRes.json()]);
    return { profile, orders };
  } catch (err) {
    console.error('Could not load dashboard', err);
    throw err;               // let the caller show an error state
  }
}
  • Sequential vs parallel: awaiting two independent requests one after another doubles the wait. Start both, then await Promise.all.
  • fetch rejects only on network failure. A 404 or 500 still resolves, so check res.ok (Part 1).
  • await inside forEach does not wait; use for...of for sequential steps or Promise.all with map for parallel ones.

4. Running promises together

MethodResolves whenRejects whenUse for
Promise.allAll fulfil (array of results)Any one rejectsIndependent requests that all must succeed
Promise.allSettledAll settle (each with status)NeverShowing partial results, batch jobs
Promise.raceThe first one settlesThe first one settles with an errorTimeouts
Promise.anyThe first one fulfilsAll rejectFastest mirror or fallback sources

"Implement Promise.all" is a frequent interview task: create a new promise, track a counter and a results array in input order, resolve when the counter reaches the length, and reject on the first error.

5. Cancellation, timeouts and race conditions

In UIs, requests often become outdated: the user types another search term or leaves the page. Without care, a slow old response can arrive after a newer one and overwrite fresh data, a classic race condition. AbortController cancels fetches cleanly, and React effects should abort in their cleanup function (Part 11).

// Fetch with a timeout using AbortController
async function fetchWithTimeout(url, ms = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    const res = await fetch(url, { signal: controller.signal });
    return await res.json();
  } finally {
    clearTimeout(timer);
  }
}

// Newer browsers also support: fetch(url, { signal: AbortSignal.timeout(5000) })
Interview tip: when asked to build search-as-you-type, mention three things: debounce the input (Part 8), abort the previous request, and ignore responses that do not match the latest query.
Key takeaways
  • JavaScript runs one call stack; the browser handles timers, network and events and queues callbacks.
  • After each task, all microtasks (promise callbacks, await continuations) run before rendering and the next task.
  • Long synchronous work blocks rendering and input; split it up or use a Web Worker.
  • Use async/await with try/catch; run independent work in parallel with Promise.all.
  • Use AbortController to cancel outdated requests and avoid race conditions.

Practice problems

Predict the output of the ordering questions before running them, then implement the utilities from scratch.

  1. Predict the log order of a snippet mixing setTimeout, Promise.then, async/await and console.log.
  2. Rewrite a three-level nested callback function using promises, then async/await.
  3. Implement Promise.all from scratch, preserving input order.
  4. Implement Promise.allSettled and Promise.race from scratch.
  5. Write a retry(fn, attempts) helper with exponential backoff.
  6. Write a sleep(ms) function and use it to run tasks one after another with a delay.
  7. Build a search box that aborts the previous fetch when the user types again.

Interview questions and FAQ

What is the event loop?

It is the mechanism that lets single-threaded JavaScript handle asynchronous work. When the call stack is empty, it runs all queued microtasks, lets the browser render if needed, then takes the next task from the task queue, repeating forever.

What is the difference between microtasks and macrotasks?

Microtasks (promise callbacks, await continuations, queueMicrotask) all run right after the current code finishes. Macrotasks (setTimeout, setInterval, events, messages) run one per loop iteration, after the microtask queue is empty.

Why does setTimeout(fn, 0) not run immediately?

It only queues fn as a task after at least 0 ms. The task waits until the current script and all microtasks finish, and browsers may clamp nested timers to a minimum delay.

What is the difference between Promise.all and Promise.allSettled?

Promise.all rejects as soon as any promise rejects, discarding the other results. Promise.allSettled waits for every promise and returns each outcome with a status, so you can handle successes and failures individually.

Does async/await make code run in parallel?

No. await pauses only the current async function until the promise settles, without blocking the thread. To run independent operations concurrently, start them first and await them together with Promise.all.

Comments

Answered 0/0Correct 0