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:
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.
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:
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.
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.
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:
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);
});
}
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:
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.
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
Build a two-step Promise chain
Practice creating Promises manually and chaining them, including a failure path.
Requirements:
- Write
fetchProduct(id), returning anew Promisethat resolves after asetTimeoutwith a{'{'} id, name, price {'}'}object — but rejects ifidis less than or equal to 0. - Write
fetchDiscount(productPrice), returning a Promise that resolves with a discounted price (e.g. 10% off) after another shortsetTimeout. - Chain them: call
fetchProduct, then.then()intofetchDiscountusing the resolved product's price, then log the final discounted price. - Add a single
.catch()at the end of the chain, then intentionally callfetchProduct(-1)to confirm the rejection is caught there instead of crashing. - 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.
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?
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?
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?
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?
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.