Week 7: Callbacks, the Event Loop & Promises

Everything so far has run top to bottom, one line finishing before the next starts. Real programs wait on things — a timer, a network request, a file — without freezing while they wait, and this week is about exactly how JavaScript pulls that off. It starts with the call stack and the event loop, then arrives at Promises: the object that represents "a value that isn't ready yet."

Phase 4 of 8 Week 7 of 14 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain, in order, what the call stack, task queue and event loop each do
  • Create a Promise, and consume one with .then()/.catch()
  • Chain .then() calls correctly, instead of nesting callbacks inside callbacks

1. The Call Stack & Why Callbacks Exist

JavaScript runs on a single thread, tracked by a structure called the call stack — every time a function is called, it's pushed onto the stack; when it returns, it's popped off. Only one thing can run at a time:

the call stack, one function at a time
function third()  { console.log("third");  }
function second() { third();  console.log("second"); }
function first()  { second(); console.log("first"); }

first();
// Call order:   first -> second -> third
// Stack builds: [first] -> [first, second] -> [first, second, third]
// Log order:    "third", "second", "first" -- each pops off before the next log runs

Because there's only one stack, a slow operation — like waiting several seconds for a network response — would freeze the entire page if it blocked the stack the whole time. JavaScript's answer is to hand slow work off to the browser (or Node) itself, and give it a callback: a function to run once that work finishes, instead of waiting for it inline.

a callback handing off slow work
console.log("1: start");

setTimeout(() => {
  console.log("2: this runs later, after the timer");
}, 1000);

console.log("3: this runs immediately");

// Logged order: "1: start", "3: this runs immediately", "2: this runs later..."

setTimeout doesn't block anything — it registers the callback and returns immediately, letting "3: this runs immediately" log before the timer callback ever runs. This is the foundation everything else this week builds on: JavaScript stays responsive by never blocking the stack on slow work, and running the "what happens after" logic later, as a callback.

2. Callback Hell

The trouble starts once one async step needs to happen after another — nesting callbacks inside callbacks quickly becomes hard to read and harder to maintain:

callback hell
getUser(userId, (user) => {
  getOrders(user.id, (orders) => {
    getOrderDetails(orders[0].id, (details) => {
      getShippingStatus(details.id, (status) => {
        console.log(status); // four levels deep, and still growing rightward
      });
    });
  });
});

Each step depends on the result of the one before it, so each new step nests one level deeper — the code drifts rightward across the page ("the pyramid of doom"), and error handling has to be repeated at every single level individually. This specific pain is exactly what Promises were designed to solve, which is why they exist as a language feature at all rather than just a library convention.

3. The Event Loop & Task Queue

When an async operation (like a timer) finishes, its callback doesn't run immediately — it's placed in a task queue, and the event loop is the mechanism that continuously checks one thing: is the call stack empty? If so, take the next callback off the queue and run it.

the event loop's rule, illustrated
console.log("A");

setTimeout(() => console.log("B"), 0); // 0ms delay -- still goes through the queue

console.log("C");

// Logged order: A, C, B -- NOT A, B, C

Even with a 0ms delay, "B" logs last. The timer callback can only run once the call stack is completely empty — and the stack isn't empty until console.log("C") and the rest of the currently-running code have finished. This is the core mental model for the rest of async JavaScript: the main script always finishes running first; queued callbacks only get their turn once the stack clears.

You don't need to memorize the internals to use Promises well

The event loop explains why async code behaves the way it does, but day-to-day work with Promises and async/await (Week 8) rarely requires reasoning about the queue directly. It's worth understanding once, mainly so async ordering never feels random.

4. Promises: Creating & Consuming

A Promise is an object representing a value that isn't available yet, but will be — eventually resolved (success) or rejected (failure). It replaces the callback-passing pattern from Section 1 and 2 with something that can be returned and chained like a normal value:

creating a Promise
function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 0) {
        resolve({ id, name: "Priya" }); // success: call resolve with the value
      } else {
        reject(new Error("Invalid user ID")); // failure: call reject with an error
      }
    }, 500);
  });
}
consuming a Promise
fetchUser(1)
  .then((user) => console.log("Got:", user)) // runs if the Promise resolves
  .catch((err) => console.log("Failed:", err.message)); // runs if it rejects

A Promise starts pending, then settles exactly once — either fulfilled (whatever was passed to resolve) or rejected (whatever was passed to reject), and never changes again after that. .then() registers a callback for the success case; .catch() registers one for the failure case — both are just structured versions of the same "callback for later" idea from Section 1, wrapped in an object that can be passed around and chained.

5. Chaining Promises

.then() itself returns a new Promise, which is what makes chaining possible — and it's the direct fix for the callback-hell pyramid from Section 2:

callback hell, rewritten as a Promise chain
getUser(userId)
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetails(orders[0].id))
  .then((details) => getShippingStatus(details.id))
  .then((status) => console.log(status))
  .catch((err) => console.log("Something failed:", err.message)); // catches ANY step's failure

Each .then() receives the value the previous one resolved with, and whatever it returns becomes the value the next .then() receives — if a step returns another Promise (like getOrders(...) above), the chain automatically waits for that Promise to settle before continuing. The structure is now flat instead of nested, and a single .catch() at the end handles a rejection from any step in the chain, instead of repeating error handling at every level.

Forgetting to return inside a .then() breaks the chain

If a .then() callback does async work but forgets to return the Promise it creates, the next .then() runs immediately with undefined instead of actually waiting. This is one of the most common Promise bugs — always return anything you want the chain to wait on.

6. Hands-on Exercise

Hands-on

Build a two-step Promise chain

Practice creating Promises manually and chaining them, including a failure path.

Requirements:

  1. Write fetchProduct(id), returning a new Promise that resolves after a setTimeout with a {'{'} id, name, price {'}'} object — but rejects if id is less than or equal to 0.
  2. Write fetchDiscount(productPrice), returning a Promise that resolves with a discounted price (e.g. 10% off) after another short setTimeout.
  3. Chain them: call fetchProduct, then .then() into fetchDiscount using the resolved product's price, then log the final discounted price.
  4. Add a single .catch() at the end of the chain, then intentionally call fetchProduct(-1) to confirm the rejection is caught there instead of crashing.
  5. Add a comment above the chain explaining, in your own words, why one .catch() at the end is enough to handle a failure from either step.
Hint

Remember to return fetchDiscount(product.price) inside the first .then() — this is exactly the "forgetting to return" trap from the callout above, and skipping it will make the final .then() receive undefined instead of the discounted price.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does setTimeout(fn, 0) not run fn immediately, even with a 0ms delay?

Even with a 0ms delay, the callback still has to go through the task queue and wait for the event loop to check the call stack. Since JavaScript is single-threaded, the callback can only run once the currently-executing code finishes and the stack is completely empty — so any synchronous code written after the setTimeout call still runs first.

Q2

What specific problem does chaining Promises with .then() solve, compared to nesting callbacks?

Nested callbacks drift one level deeper for every additional async step ("callback hell"), and error handling has to be repeated at every level. A Promise chain keeps each step at the same nesting level, flat and top-to-bottom, and a single .catch() at the end can handle a rejection from any step in the chain.

Q3

In new Promise((resolve, reject) => {'{'}...{'}'}), what do resolve and reject actually do?

They're functions supplied by the Promise itself, used to settle it. Calling resolve(value) transitions the Promise to fulfilled with that value, which is what a chained .then() receives. Calling reject(error) transitions it to rejected, which is what a chained .catch() receives instead. A Promise can only settle once — calling either one again after the first has no effect.

Q4

Inside a .then() callback, you call another function that returns a Promise, but forget to return it. What goes wrong?

The chain doesn't wait for that inner Promise to settle — the next .then() runs immediately, receiving undefined instead of the value the inner Promise would have resolved with. Whatever a .then() callback returns becomes the value passed to the next .then(), so a forgotten return silently breaks the sequencing.