← Tech Talk

JavaScript Async: The Event Loop, Callbacks, Promises, and Async/Await

JavaScript is single-threaded. One call stack, one thing at a time. If you've ever wondered how it manages to fetch data, respond to clicks, and run timers without freezing the whole page, the answer is the event loop. Understanding it makes async code a lot less mysterious.

This post builds from the ground up: the event loop first, then callbacks, then Promises, then async/await. I'll use REST API calls as the running example so you can see how the same problem looks at each layer.


The Event Loop

JavaScript runs in a single thread, meaning only one piece of code executes at a time. But the browser (and Node.js) gives JavaScript access to APIs that do work outside that thread: fetch, setTimeout, event listeners, and others. The event loop is what coordinates between your JS code and those external APIs.

Here's the mental model:

┌──────────────────────────────────┐
│           Call Stack             │
│  (your code runs here, LIFO)     │
└──────────────────────────────────┘
           |           ^
           | hands off | pushes callback
           v           |
┌──────────────────────────────────┐
│   Web APIs (fetch, setTimeout)   │
│   These run outside your thread  │
└──────────────────────────────────┘
                   |
                   | when done
                   v
┌──────────────────────────────────┐
│            Task Queue            │
│  (callbacks waiting to run)      │
└──────────────────────────────────┘

The event loop has one job: if the call stack is empty, grab the next callback from the queue and push it onto the stack. That's it. Everything else in async JavaScript follows from that rule.

Here's a classic example to make it concrete:

console.log("first");
 
setTimeout(() => {
  console.log("third");
}, 0);
 
console.log("second");
 
// Output:
// first
// second
// third

Even with a 0ms delay, "third" prints last. Why? setTimeout hands its callback off to the browser's timer API and returns immediately. The rest of your synchronous code runs. Only when the call stack is empty does the event loop pick up the callback from the queue and run it.

The Microtask Queue

There are actually two queues. The microtask queue has higher priority than the regular task queue, and resolved Promises push their .then() callbacks there. Microtasks are fully drained before the event loop picks up the next regular task.

console.log("start");
 
setTimeout(() => console.log("setTimeout"), 0);
 
Promise.resolve().then(() => console.log("promise"));
 
console.log("end");
 
// Output:
// start
// end
// promise      <-- microtask runs before setTimeout
// setTimeout

This is why Promise callbacks always run before setTimeout callbacks, even when both are queued at the same time. Good to know when you're debugging weird ordering issues.


Callbacks

Before Promises, callbacks were the only tool for async work. A callback is just a function you pass to another function to be called when the work finishes.

Here's a basic example with setTimeout:

function notifyUser(message, callback) {
  setTimeout(() => {
    console.log(message);
    callback();
  }, 1000);
}
 
notifyUser("Data loaded!", () => {
  console.log("Callback ran.");
});

The real-world version of this was making HTTP requests with XMLHttpRequest:

function getUser(userId, callback) {
  const xhr = new XMLHttpRequest();
  xhr.open("GET", `https://jsonplaceholder.typicode.com/users/${userId}`);
 
  xhr.onload = function () {
    if (xhr.status === 200) {
      callback(null, JSON.parse(xhr.responseText));
    } else {
      callback(new Error(`Request failed: ${xhr.status}`));
    }
  };
 
  xhr.onerror = function () {
    callback(new Error("Network error"));
  };
 
  xhr.send();
}
 
getUser(1, function (err, user) {
  if (err) {
    console.error(err);
    return;
  }
  console.log(user.name);
});

The convention callback(error, result) comes from Node.js and became a de facto standard: first argument is the error (or null if everything went fine), second is the data. You'll still see this pattern in older SDKs and libraries that predate Promises, so it's worth recognizing even if you wouldn't write it from scratch today.

Callback Hell

The problem shows up when you need to chain multiple async operations. Fetch a user, then fetch their posts, then fetch comments on the first post:

getUser(1, function (err, user) {
  if (err) return console.error(err);
 
  getPosts(user.id, function (err, posts) {
    if (err) return console.error(err);
 
    getComments(posts[0].id, function (err, comments) {
      if (err) return console.error(err);
 
      console.log(comments);
      // and it keeps going...
    });
  });
});

Each level adds indentation, each level duplicates the error check, and reading the control flow is a puzzle. This is "callback hell" (or the "pyramid of doom"). A classic real-world trigger for this was multi-step auth flows: get a session token, then use it to fetch user permissions, then load the resource the user actually asked for. Three dependent API calls, three levels deep. Promises were designed to flatten this out.


Promises

A Promise represents a value that isn't available yet. It sits in one of three states:

  • Pending: the async work is in progress
  • Fulfilled: it finished successfully, and the Promise holds the result
  • Rejected: something went wrong, and the Promise holds the error

The fetch API returns a Promise, so the callback-heavy XMLHttpRequest approach is mostly gone. Here's that same user fetch using fetch:

fetch("https://jsonplaceholder.typicode.com/users/1")
  .then((response) => {
    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }
    return response.json();
  })
  .then((user) => {
    console.log(user.name);
  })
  .catch((err) => {
    console.error(err);
  });

.then() receives the fulfilled value and returns a new Promise, which is what makes chaining work. .catch() handles any rejection anywhere up the chain. If you throw inside a .then(), it rejects and jumps straight to .catch().

Chaining

That same three-step sequence (user, posts, comments) now reads flat:

fetch("https://jsonplaceholder.typicode.com/users/1")
  .then((res) => res.json())
  .then((user) =>
    fetch(`https://jsonplaceholder.typicode.com/posts?userId=${user.id}`),
  )
  .then((res) => res.json())
  .then((posts) =>
    fetch(
      `https://jsonplaceholder.typicode.com/comments?postId=${posts[0].id}`,
    ),
  )
  .then((res) => res.json())
  .then((comments) => console.log(comments))
  .catch((err) => console.error(err));

No nesting. One .catch() covers everything. This pattern fits anywhere you have a chain of dependent REST calls: refresh an expired auth token, then replay the original request with the new one, for example.

Running Requests in Parallel

Promise.all takes an array of Promises and resolves once all of them do. If any one rejects, the whole thing rejects.

const userIds = [1, 2, 3];
 
Promise.all(
  userIds.map((id) =>
    fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((res) =>
      res.json(),
    ),
  ),
)
  .then((users) => {
    users.forEach((user) => console.log(user.name));
  })
  .catch((err) => {
    console.error(err);
  });

All three requests fire at the same time. Much faster than running them one after another. A good use case is loading a dashboard: user profile, recent activity, and notification count are all independent, so there's no reason to wait for one before starting the next.

If you want each result regardless of whether some fail, use Promise.allSettled. It never rejects; instead it gives you an array of result objects with a status field:

Promise.allSettled(
  userIds.map((id) =>
    fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((res) =>
      res.json(),
    ),
  ),
).then((results) => {
  results.forEach((result) => {
    if (result.status === "fulfilled") {
      console.log(result.value.name);
    } else {
      console.error("Failed:", result.reason);
    }
  });
});

This is the right tool for batch write operations — like POSTing a list of records to an API — where you want to report which ones succeeded and which failed rather than bailing out on the first error.

finally

.finally() runs after the chain settles no matter what. Good for cleanup like hiding a loading spinner:

showSpinner();
 
fetch("https://jsonplaceholder.typicode.com/users/1")
  .then((res) => res.json())
  .then((user) => console.log(user.name))
  .catch((err) => console.error(err))
  .finally(() => hideSpinner());

Another common use: disabling a form's submit button while a POST is in flight, then re-enabling it in .finally() so the user can retry if it failed.


Async/Await

Async/await is syntactic sugar on top of Promises. An async function always returns a Promise. await pauses execution inside that function until the awaited Promise settles. The thread is not blocked; other code can still run while you're waiting.

async function getUser(id) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`,
  );
 
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }
 
  return response.json();
}

Calling getUser(1) returns a Promise, same as before. The difference is that the code inside reads like synchronous code. This is the pattern you'd reach for on almost any single API call: loading a product page, submitting a search query, fetching the current user on app startup.

Error Handling

Instead of .catch(), you use a plain try/catch block:

async function getUser(id) {
  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${id}`,
    );
 
    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }
 
    return await response.json();
  } catch (err) {
    console.error("Failed to fetch user:", err);
    return null;
  }
}

A practical place this earns its keep: form submissions. A 422 Unprocessable Entity response from the server means the data failed validation. Inside the catch, you can read the error body and map specific field errors back to the form instead of showing a generic failure message.


### Sequential vs Parallel

One gotcha: awaiting inside a loop runs requests one at a time, each waiting for the previous to finish.

```javascript
// Sequential: slow, each request waits for the last
async function getUsersSequential(ids) {
  const users = [];
  for (const id of ids) {
    const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
    users.push(await res.json());
  }
  return users;
}

To run them in parallel, start all the Promises first and then await them together:

// Parallel: all requests fire at once
async function getUsersParallel(ids) {
  const promises = ids.map((id) =>
    fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((res) =>
      res.json(),
    ),
  );
  return Promise.all(promises);
}

The second version fires all three requests simultaneously. For any list of independent requests, this is what you want. Sequential makes sense when the requests are dependent: an OAuth flow where you POST credentials to get a token, then immediately use that token in the next request. Parallel makes sense for anything that can run independently: loading a user's orders, wishlist, and recommendations all at once on a profile page.


Putting It Together: A Real API Client

Here's what a practical data-fetching module looks like using everything covered above:

const BASE_URL = "https://jsonplaceholder.typicode.com";
 
async function apiFetch(path) {
  const response = await fetch(`${BASE_URL}${path}`);
 
  if (!response.ok) {
    throw new Error(`${response.status} ${response.statusText} at ${path}`);
  }
 
  return response.json();
}
 
async function getDashboardData(userId) {
  // User must resolve first since we need the ID
  const user = await apiFetch(`/users/${userId}`);
 
  // Posts and todos don't depend on each other, so run them in parallel
  const [posts, todos] = await Promise.all([
    apiFetch(`/posts?userId=${userId}`),
    apiFetch(`/todos?userId=${userId}`),
  ]);
 
  return { user, posts, todos };
}
 
getDashboardData(1)
  .then(({ user, posts, todos }) => {
    console.log(
      `${user.name} has ${posts.length} posts and ${todos.length} todos`,
    );
  })
  .catch((err) => console.error("Dashboard load failed:", err));

The user fetch is sequential because everything else needs it. Posts and todos are parallel because they don't depend on each other. One sequential step, then parallel work where possible. That's the general pattern for real API calls.


The Mental Model, Summarized

ConceptWhat it is
Event LoopMoves callbacks from the task queue to the call stack when the stack is empty
CallbackA function passed to async code to run when it finishes
PromiseAn object representing a future value, chainable with .then() and .catch()
Async/AwaitSyntax that lets you write Promise-based code that reads like synchronous code

They all describe the same underlying mechanics. The event loop doesn't change between any of them. Callbacks work, but nest badly. Promises flatten the chain and centralize error handling. Async/await reads cleanly and handles errors with familiar try/catch. Pick the right tool for the situation, and know that underneath it all, the event loop is still just moving callbacks off a queue.

Thanks for learning something with me!

References