Asynchronous JavaScript Explained: Callbacks, Promises, and Async/Await

Set a timer for two seconds and log a message. Then fetch a user from an API, wait for the response, and render their order history. Each step depends on the one before it, and none of them finishes instantly. That dependency is what makes asynchronous JavaScript feel slippery at first. The syntax is rarely the hard part. The hard part is keeping track of what has finished, what is still waiting, and what should happen when something fails.

Three tools cover almost every case you will meet: callbacks, promises, and async/await. They are not competing philosophies so much as layers built on each other.

Callbacks: Functions You Hand Over for Later

A callback is a function you pass to another function, trusting it to be called when the work is done. Timers, event listeners, and Node's file system all work this way. You write setTimeout(() => console.log('done'), 1000) and hand a function to setTimeout. You write button.addEventListener('click', handleClick) and hand a function to the browser. The browser decides when to call it.

For a single step, callbacks are clean and hard to improve on. Problems appear when the second step needs the result of the first, and the third needs the second.

Why Nested Callbacks Become a Problem

The famous "callback hell" example is a pyramid of nested functions four levels deep, and the indentation usually takes the blame. Indentation is a symptom, not the disease. The real issues are:

  • Every level needs its own error check, so failure handling is duplicated in some places and forgotten in others.
  • Return values disappear. You cannot simply return the result of an inner callback to the function that started everything.
  • Reading order drifts away from execution order, which makes bugs harder to spot during review.
  • Each nested function captures variables from the scopes above it, so accidental sharing is easy.

Promises were designed to fix those four things specifically.

Promises: A Receipt for a Value You Don't Have Yet

A promise is an object representing a value that may not exist yet. It has three states: pending, fulfilled, rejected. Once it settles, it stays settled. A promise cannot resolve twice or flip from resolved to rejected, and that single guarantee removes a lot of guesswork from your code.

You attach work with .then() and handle failure with .catch():

fetch('/api/orders').then(res => res.json()).then(orders => render(orders)).catch(showError)

The chain reads flat instead of nesting inward, and a single .catch() at the end catches a rejection from any link above it. Throwing inside a .then() rejects the promise it returns, so errors travel down the chain naturally. .finally() runs whether the chain succeeds or fails, which is useful for hiding a loading spinner.

One detail trips up nearly everyone: if you forget to return a value inside a .then(), the next link receives undefined rather than your data. It fails quietly, and the symptom shows up several steps away from the cause.

Async/Await: The Same Machinery, Calmer Reading

async and await are built on promises; they do not replace them. Mark a function async and it always returns a promise. Put await in front of a promise and the function pauses at that line until the promise settles, then continues with the resolved value. The engine is not blocked, and other code keeps running, but your function reads top to bottom like a recipe.

Wrap awaited calls in try/catch to handle rejections:

async function loadOrders(userId) { try { const res = await fetch(`/api/users/${userId}/orders`); if (!res.ok) throw new Error(res.status); return await res.json(); } catch (err) { showError(err); } }

Inside an async function, a thrown error becomes a rejected promise. That means the caller can catch it too, either with await plus try/catch or with a .catch() attached to the returned promise.

Await Is Sequential by Default

An await inside a loop waits for each iteration before starting the next one. Sometimes that is exactly what you want, such as when each request depends on the previous result. Often it is not. If the calls are independent, start them together and wait once: const [user, orders] = await Promise.all([getUser(id), getOrders(id)]). Promise.all rejects as soon as any input rejects. Promise.allSettled waits for every one of them and reports each outcome separately, which suits dashboards and batch jobs where partial success is acceptable.

Pitfalls That Show Up in Real Code

  • Forgetting to await. Calling an async function without await gives you a promise, not the value. Then data.map(...) throws "not a function".
  • Awaiting in a loop. Ten independent requests take ten times as long as they need to. Collect the promises in an array and await Promise.all.
  • Empty catch blocks. A bare catch {} hides real failures. Log the error, or rethrow it with context added.
  • Assuming order. A console.log placed after a fetch call runs before the fetch resolves. That is the whole point of async.
  • Unhandled rejections in event handlers. An async click handler that throws produces a rejected promise nobody catches. Add a try/catch inside the handler.
  • Mixing styles carelessly. Wrapping a callback API in new Promise works, but only call resolve or reject once, and prefer the promise-based version of an API when one exists.

A Practical Way to Decide

For new code, reach for async/await first. It reads like synchronous code, works with try/catch you already understand, and handles the ten-step workflows that made callbacks painful. Drop down to raw promises when you need combinators such as Promise.all or a race between a request and a timeout. Keep plain callbacks for genuinely simple, single-step cases like timers and event listeners, where a promise would add ceremony without adding clarity.

When something behaves strangely, read the function as a list of steps and ask two questions: does every step that returns a promise get awaited or returned, and does every path that can fail end up inside a catch? Answer those two honestly and most async bugs resolve themselves before you reach for the debugger.

Photo: Nemuel Sereti / Pexels

You Might Also Like