Blog

Mastering Asynchronous Programming in JavaScript: A Comprehensive Guide

JavaScript
Asynchronous Programming
Promises
Async
Callbacks

How the event loop schedules promises and timers, and the request patterns that keep async JavaScript predictable.

Mastering Asynchronous Programming in JavaScript: A Comprehensive Guide

One thread, two queues

JavaScript on a page runs on one thread, the same thread that handles input and painting. Asynchronous code does not mean two functions running at the same instant. It means the thread can start some work, go do something else, and come back when the result is ready.

The runtime keeps two relevant queues. A task (timers, I/O callbacks, user events) is picked up one at a time. After that task finishes, the runtime drains the microtask queue completely, including microtasks that were queued by other microtasks, and only then considers rendering or the next task. Promise reactions (.then, .catch, .finally, and the code after an await) are microtasks. setTimeout schedules a task. People often call tasks “macrotasks”. The spec’s word is task.

That single rule predicts this program:

console.log('start')

setTimeout(() => console.log('timeout'), 0)

Promise.resolve()
  .then(() => console.log('promise 1'))
  .then(() => console.log('promise 2'))

console.log('end')

The log is start, end, promise 1, promise 2, timeout.

start and end run in the current task. The timeout is only queued, and a delay of 0 means “a future timer task”, after the current script and any microtasks. The first .then is queued as a microtask. When the script ends, that microtask runs and logs promise 1. Its own .then queues promise 2, and the microtask checkpoint runs that too before the timer task is allowed to start.

async / await is the same mechanism in different syntax. An async function always returns a promise. await pauses the function and resumes it as a microtask.

async function run() {
  console.log('1')
  await null
  console.log('2')
}

run()
console.log('3')
// 1, then 3, then 2

1 is synchronous, up to the first await. 3 is the rest of the current task. 2 is the continuation, queued like a .then. Jake Archibald’s Tasks, microtasks, queues and schedules is still the clearest walk through why this order exists. MDN’s microtask guide covers the same checkpoint from the spec side.

If you need work to happen after the current script but before the next timer or paint, queueMicrotask schedules it directly. Most application code should not. A microtask that queues more microtasks forever will not yield to rendering.

From callbacks to promises

A callback hands the continuation to someone else: “when you are done, call this.” Node’s older APIs used an error-first callback, (error, value) => {}. The shape has two practical problems. Errors and values travel on different conventions, so it is easy to forget the error check. And a sequence of dependent steps nests, because each step has to be written inside the previous callback.

A promise is an object that stands for a value that is not available yet. It is pending, and then it settles: fulfilled with a value, or rejected with a reason. Settling happens once. Reactions attached afterward still run, as microtasks, which is why a promise that is already resolved still delivers .then asynchronously.

function delay(ms, value) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), ms)
  })
}

delay(20, 'done').then((value) => console.log(value))

The executor passed to new Promise runs immediately. resolve fulfills the promise. reject, or a throw inside the executor, rejects it. What you return from .then becomes the fulfillment value of the next promise. What you throw, or return as a rejected promise, sends the chain to the next .catch.

async / await is syntax for that chain. await on a non-promise wraps it, which is why await null still yields. try / catch around an await handles the rejection. MDN’s Using promises is the reference for the settlement rules, including this one: a promise returned from a .then and then forgotten is a floating promise. Its rejection becomes an unhandled rejection. The same thing happens when you call an async function and neither await it nor return it.

async function save(draft) {
  await fetch('/api/drafts', {
    method: 'POST',
    body: JSON.stringify(draft),
  })
}

// The rejection has nowhere to go.
save(draft)

// This one is tracked.
await save(draft)

fetch fails in two different ways

fetch rejects when the network itself fails: the user is offline, the request is aborted, DNS never resolves. It fulfills for an HTTP error. A 404 and a 500 are successful fetches of an error response, and .catch will not see them.

async function getJson(url, {signal} = {}) {
  const response = await fetch(url, {signal})

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`)
  }

  return response.json()
}

response.ok is true for statuses 200 through 299. Throwing here turns an HTTP error into a rejection, so the try / catch at the call site handles both kinds of failure. Read the body only after that check. response.json() is itself asynchronous: the headers arrive first, and the body is a stream.

Dependent steps, independent steps

await in sequence means each step waits for the previous one. Use it when the second call needs something the first call returns.

const user = await getJson('/api/me')
const posts = await getJson(`/api/users/${user.id}/posts`)

When the calls do not need each other, waiting in series just adds the latencies together. Promise.all starts them together and fulfills with the results in the order of the input array, which is not the order they finished.

const [user, settings] = await Promise.all([
  getJson('/api/me'),
  getJson('/api/settings'),
])

If any input rejects, Promise.all rejects. The other requests keep running, but their values are discarded. That is the right outcome when the page cannot use a partial result. When it can, Promise.allSettled waits for every input to settle and reports each one:

const results = await Promise.allSettled([
  getJson('/api/profile'),
  getJson('/api/recommendations'),
])

for (const result of results) {
  if (result.status === 'fulfilled') {
    console.log(result.value)
  } else {
    console.error(result.reason)
  }
}

The result array lines up with the input array. A fulfilled entry has value. A rejected entry has reason.

Promise.any fulfills with the first fulfillment and ignores earlier rejections. It rejects with an AggregateError only when every input rejects. That fits a race between two mirrors of the same file. Promise.race settles with whichever input settles first, success or failure.

Promise.all does not cancel the losers when it rejects. If those requests should stop, you have to abort them yourself.

Cancellation

A promise has no standard way to be cancelled. The operation underneath sometimes does. AbortController is that mechanism for fetch.

const controller = new AbortController()

try {
  const data = await getJson('/api/search?q=rx', {signal: controller.signal})
  console.log(data)
} catch (error) {
  if (error.name !== 'AbortError') throw error
}

controller.abort()

Calling abort() rejects the fetch with a DOMException named AbortError. In a search box, the controller from the previous query should be aborted before the next query starts, otherwise a slow response can land last and paint stale results. AbortSignal.timeout(5000) aborts on its own after five seconds, and AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]) aborts when either signal does.

Promise.race against a timer does not do this. The race settles, and the fetch continues in the background, still holding the connection and still able to throw an unhandled rejection if nobody is listening. Abort the request if the goal is to stop the work.

The main thread still has to stay free

Concurrency on the page is the event loop interleaving tasks. Parallelism is two pieces of JavaScript executing at the same instant, and the main thread does not offer it. A tight loop over a large array blocks clicks and painting until it returns, no matter how many promises are pending, because those promises can run only after the loop’s task ends.

A Worker is a separate thread. It receives messages, computes, and posts messages back. Those messages arrive on the main thread as tasks, so the page stays responsive while the heavy work happens elsewhere. Workers do not see the DOM. They are for CPU work, not for awaiting fetch, which was already off the main thread inside the browser.

The practical split is: I/O waits go through promises, long calculations go to a worker or get broken into smaller tasks, and ordinary UI code stays on the main thread and avoids doing either by accident.

Habits that prevent the usual bugs

  • Await the promise, or return it. A call you do not track is a rejection you will not catch.
  • Treat response.ok as part of parsing. fetch will not throw for a 500.
  • Use a sequential await when a step needs the previous result. Use Promise.all when it does not.
  • Use Promise.allSettled when one failed call should not hide the others.
  • Abort the previous request when a newer one makes it irrelevant.
  • Keep the try / catch next to the await that can fail, and rethrow anything that is not the case you actually handle.

Streams of events over time, where each value can cancel the previous request, are the point at which a promise chain gets awkward. That problem is what Reactive programming in RxJS is for. Most application code never needs to go there. It needs the queue order above, and the six habits, applied on purpose.