March 26, 20265 min read

Async JavaScript Actually Explained

How callbacks, promises, and async/await work under the hood. The event loop demystified, plus the mistakes that trip up even experienced developers.

javascript async promises event-loop
Ad 336x280

JavaScript runs on a single thread. One call stack, one thing at a time. Yet somehow it handles network requests, timers, file reads, and user interactions without freezing. If that sounds contradictory, you're thinking about it correctly -- and the answer is the event loop.

The Single Thread Problem

Imagine you need to fetch user data from an API. That request takes 200ms. In a language like Python (without async), your entire program just... waits. Nothing else happens for those 200ms.

JavaScript can't afford that. It runs in a browser where blocking means the entire UI freezes. So instead of waiting, JS delegates the slow work to the browser (or Node.js runtime), keeps executing the next lines of code, and picks up the result later when it's ready.

That's non-blocking I/O. The single thread never stops -- it just schedules work to be handled elsewhere and moves on.

The Event Loop in 30 Seconds

Here's the mental model that actually works:

  1. Your synchronous code runs first, top to bottom, on the call stack.
  2. When you hit something async (fetch, setTimeout, file read), the runtime handles it in the background.
  3. When the async operation finishes, its callback gets placed in a queue.
  4. The event loop checks: "Is the call stack empty?" If yes, it grabs the next callback from the queue and runs it.
That's it. The event loop is just a loop that moves callbacks from queues onto the call stack when the stack is free.

There are actually two queues -- the microtask queue (promises) and the macrotask queue (setTimeout, setInterval) -- and microtasks always run first. This matters more than you'd think.

Callbacks: The Original Pattern

function getUser(id, callback) {
  setTimeout(() => {
    callback({ id, name: "Ada Lovelace" });
  }, 100);
}

getUser(1, (user) => {
console.log(user.name);
});

Callbacks work fine for simple cases. They fall apart when you need to chain operations:

getUser(1, (user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0].id, (details) => {
      // three levels deep and counting
    });
  });
});

This is callback hell, and it's not just ugly -- it makes error handling a nightmare because each level needs its own error check.

Promises: Chainable Futures

Promises flatten the nesting:

getUser(1)
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => console.log(details))
  .catch(err => console.error("Something broke:", err));

A promise is an object representing a value that doesn't exist yet. It's in one of three states: pending, fulfilled, or rejected. Once it settles, it stays that way forever.

The .catch() at the end handles errors from any step in the chain. That's a massive improvement over callbacks.

Promise.all vs Sequential Execution

If you need three independent API calls, don't do this:

// Bad: sequential, takes 300ms total
const users = await fetchUsers();
const products = await fetchProducts();
const orders = await fetchOrders();

Do this instead:

// Good: parallel, takes ~100ms (slowest request)
const [users, products, orders] = await Promise.all([
  fetchUsers(),
  fetchProducts(),
  fetchOrders()
]);
Promise.all fires all three requests simultaneously and waits for all of them. If any one rejects, the whole thing rejects -- use Promise.allSettled if you want results from the ones that succeeded regardless.

Async/Await: Promises in Disguise

Async/await is syntactic sugar over promises. Nothing more. An async function always returns a promise, and await pauses execution inside that function until the promise resolves.

async function loadDashboard(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    return { user, orders };
  } catch (err) {
    console.error("Failed to load dashboard:", err);
    throw err;
  }
}

It reads like synchronous code, which is the whole point. But there are traps.

The Gotchas That Get Everyone

Forgetting await. This is the most common one:
// Bug: response is a Promise object, not the actual data
const response = fetch("/api/users");
console.log(response); // Promise { <pending> }

Without await, you get the promise itself, not its resolved value. Your code won't crash -- it'll just silently do the wrong thing.

Await in loops. This runs requests one at a time:
// Slow: each request waits for the previous one
for (const id of userIds) {
  const user = await getUser(id); // sequential!
  results.push(user);
}

If the requests are independent, map them to promises and use Promise.all:

const results = await Promise.all(
  userIds.map(id => getUser(id))
);
Swallowing errors. If you don't await a promise and don't attach .catch(), rejected promises vanish silently (or trigger an unhandled rejection warning in Node). Always handle your errors explicitly. Async functions in .forEach(). This doesn't work the way you'd expect:
// Bug: forEach doesn't await anything
items.forEach(async (item) => {
  await processItem(item); // fires and forgets
});
console.log("Done!"); // runs immediately, before any item is processed

Use a for...of loop or Promise.all with .map() instead.

When to Use What

  • Callbacks: Only when an API forces you to (some older Node.js APIs, event listeners).
  • Promises with .then(): When you're composing promise-based utilities or working with Promise.all/Promise.race.
  • Async/await: Almost everywhere else. It's the default choice for modern JavaScript.
The best way to really internalize these patterns is to write them yourself. CodeUp has interactive JavaScript challenges where you can practice async patterns, debug promise chains, and build things that actually use the event loop -- all in the browser with instant feedback.

Understanding async JavaScript isn't about memorizing syntax. It's about having a clear picture of the event loop, knowing that promises are just objects, and recognizing the common traps before you fall into them.

Ad 728x90