JavaScript28 min readIntermediate

Promises & async/await

Callbacks, promises, async functions, the event loop, and microtasks — what's actually happening.

Why async at all?

JavaScript runs on a single thread. If you call a slow function (a network request, a database query), and JS were synchronous, your entire UI would freeze until it returned. So JS is asynchronous: long-running operations don't block — they run in the background and notify your code when they finish.

The event loop

JS doesn't actually do the work itself for I/O — it asks the runtime (the browser, Node) to handle it, and the runtime calls your code back when ready. The mechanism is called the event loop. There's a call stack and a queue of pending callbacks; whenever the stack is empty, the loop pulls the next callback off the queue and runs it.

This is also why heavy synchronous code freezes your page — it never lets the loop pick up the next task.

JavaScript event loop — microtasks always beat macrotasks
Watch what happens when you mix console.log, setTimeout, and Promise.resolve.then in one function.
Call stack
console.log("1")
Console
1
Microtask queue (high priority)
empty
Macrotask queue (lower priority)
empty
console.log("1") — runs synchronously
Sequential vs concurrent awaits

Same three fetches. The only difference is whether you `await` them one by one or fire all three first and `await Promise.all`.

fetch A (300ms)
300ms
fetch B (200ms)
200ms
fetch C (400ms)
400ms
Total
900ms (sum of durations)

Three generations of async code

1. Callbacks (the old way)

javascript
setTimeout(() => {
  console.log("after 1 second");
}, 1000);

// Nested callbacks → \"callback hell\":
fetchUser(id, (err, user) => {
  if (err) return cb(err);
  fetchOrders(user.id, (err, orders) => {
    if (err) return cb(err);
    fetchProducts(orders, (err, products) => {
      // ... pyramid of doom
    });
  });
});

2. Promises

A Promise is an object representing a value that may be available now, later, or never. It is in one of three states: pending, fulfilled (with a value), or rejected (with an error).

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

delay(1000)
  .then(() => fetch("/api/user"))
  .then(r => r.json())
  .then(user => console.log(user))
  .catch(err => console.error(err));

3. async / await — the modern way

`async` and `await` are syntactic sugar over promises. They let you write asynchronous code that READS like synchronous code — top to bottom — but doesn't block.

javascript
async function loadUser() {
  try {
    await delay(1000);
    const r = await fetch("/api/user");
    const user = await r.json();
    console.log(user);
  } catch (err) {
    console.error(err);
  }
}
loadUser();

`await` only works inside an `async` function (or at the top level of a module). It pauses execution of THAT function until the promise resolves; the surrounding event loop keeps running. When the promise settles, the function continues from where it paused.

Running things concurrently

If three operations are independent, don't `await` them sequentially — start them all and `await` together with `Promise.all`.

javascript
// Sequential — total time = sum
async function slow() {
  const a = await fetch("/api/a");
  const b = await fetch("/api/b");
  const c = await fetch("/api/c");
  return [a, b, c];
}

// Concurrent — total time = max
async function fast() {
  const [a, b, c] = await Promise.all([
    fetch("/api/a"),
    fetch("/api/b"),
    fetch("/api/c"),
  ]);
  return [a, b, c];
}
⚠ Watch out
If ANY promise in Promise.all rejects, the whole thing rejects. Use Promise.allSettled if you want all results, including failures.