Promises
The Problem Promises Solve
Recall the callback pattern from the previous lesson:
fetchUser(id, (err, user) => {
if (err) return handleError(err);
fetchPosts(user.id, (err, posts) => {
if (err) return handleError(err);
// ... more nesting
});
});The deeper problem isn't just nesting. It's inversion of control. When you pass a callback to fetchUser, you're handing your continuation—what happens next—to code you don't control. Will it call your callback once? Twice? Never? At what time? You've given up control.
Promises invert the inversion. Instead of giving someone your callback and hoping they call it correctly, you receive a receipt—the Promise—that you control. You decide when and how to attach handlers. The Promise guarantees:
- Handlers will be called at most once
- Handlers will always run asynchronously (even if the Promise is already settled)
- The settled value is immutable—once resolved, forever resolved
This is the fundamental insight: Promises give you back control over your own continuations.
The Promise Mental Model
Think of a Promise as a container for a future value. Like a shipping tracking number—you have the receipt immediately, even though the package hasn't arrived.
// You get the Promise immediately
const promise = fetchUser(1);
// The value arrives later
// But you have the receipt NOW and can decide what to do with itA Promise is always in one of three states:
| State | Meaning | Transitions To |
|---|---|---|
| Pending | Waiting for result | Fulfilled OR Rejected |
| Fulfilled | Has a value | Nothing (terminal) |
| Rejected | Has an error | Nothing (terminal) |
Once a Promise settles (fulfilled or rejected), it stays that way forever. This immutability is a feature—you can attach handlers at any time and get the same value.
const p = Promise.resolve(42);
// Even 5 seconds later, you get the same value
setTimeout(() => {
p.then(x => console.log(x)); // 42
}, 5000);Creating Promises
The Promise constructor takes an executor function that receives resolve and reject:
const promise = new Promise((resolve, reject) => {
// This code runs IMMEDIATELY (Promises are eager)
setTimeout(() => {
const success = Math.random() > 0.5;
if (success) {
resolve("It worked!"); // Fulfill with a value
} else {
reject(new Error("It failed")); // Reject with an error
}
}, 1000);
});Key insight: The executor runs immediately. Promises are eager, not lazy. The moment you write new Promise(...), the executor is already running.
// This prints "Running!" immediately, not when you call .then()
const p = new Promise(resolve => {
console.log("Running!");
resolve(42);
});
// Output: Running!Consuming Promises: then, catch, finally
The .then() Method
.then() attaches a handler that runs when the Promise fulfills:
const promise = fetchData();
promise.then((result) => {
console.log("Success:", result);
});Critical rule: .then() handlers always run asynchronously, even for already-settled Promises:
console.log("1");
Promise.resolve("2").then(x => console.log(x));
console.log("3");
// Output: 1, 3, 2 (not 1, 2, 3!)Why? Consistency. If .then() sometimes ran synchronously (for settled Promises) and sometimes asynchronously (for pending ones), your code would have unpredictable behavior. The language guarantees asynchronous execution always.
The .catch() Method
.catch() handles rejections:
fetchData()
.then((data) => {
console.log("Success:", data);
})
.catch((error) => {
console.log("Error:", error.message);
});.catch(fn) is just shorthand for .then(undefined, fn).
The .finally() Method
.finally() runs regardless of outcome—perfect for cleanup:
showLoadingSpinner();
fetchData()
.then((data) => displayData(data))
.catch((error) => showError(error))
.finally(() => hideLoadingSpinner()); // Always runsPromise Chaining: The Transformative Idea
Here's the key insight that makes Promises powerful: .then() returns a new Promise.
This enables chaining—each step transforms the value for the next:
fetchUser(userId)
.then((user) => {
console.log("Got user:", user.name);
return fetchPosts(user.id); // Return a Promise
})
.then((posts) => {
console.log("Got posts:", posts.length);
return fetchComments(posts[0].id);
})
.then((comments) => {
console.log("Got comments:", comments.length);
})
.catch((error) => {
// Catches error from ANY step above
console.log("Error:", error.message);
});The Flatten Rule
What happens if you return a Promise from .then()? It automatically unwraps:
// These two are equivalent:
Promise.resolve(1)
.then(x => Promise.resolve(x + 1))
.then(y => console.log(y)); // 2
Promise.resolve(1)
.then(x => x + 1)
.then(y => console.log(y)); // 2Whether you return a plain value or a Promise, the next .then() receives the unwrapped value, not a Promise. This is why chaining works so cleanly—you never have to deal with "a Promise of a Promise."
Returning Values vs. Returning Promises
// Return a plain value: immediately available
.then(user => user.name) // Next .then gets the string
// Return a Promise: chain waits for it
.then(user => fetchPosts(user.id)) // Next .then gets posts array
// Return nothing: next .then gets undefined
.then(user => { console.log(user); }) // Next .then gets undefinedError Propagation
Errors propagate through Promise chains until caught:
fetchUser(1)
.then(user => {
throw new Error("Something went wrong"); // Error thrown here
})
.then(result => {
console.log("This never runs"); // Skipped!
})
.then(result => {
console.log("This never runs either"); // Skipped!
})
.catch(error => {
console.log("Caught:", error.message); // Catches the error
});Errors "fall through" .then() handlers until they hit a .catch(). This is similar to how exceptions bubble up through function calls.
Recovering from Errors
You can recover mid-chain by returning a value from .catch():
fetchUser(1)
.then(user => {
throw new Error("Oops");
})
.catch(error => {
console.log("Recovering...");
return { name: "Default User" }; // Provide a fallback
})
.then(user => {
console.log(user.name); // "Default User" - chain continues!
});Re-throwing Errors
To handle an error but still fail the chain, re-throw:
fetchData()
.catch(error => {
logError(error); // Log it
throw error; // Re-throw to fail the chain
})
.then(data => {
// This won't run if there was an error
});Promisifying Callbacks
Wrap callback-based code in Promises to use them in chains:
import { readFile } from "fs";
function readFilePromise(path) {
return new Promise((resolve, reject) => {
readFile(path, "utf-8", (error, content) => {
if (error) {
reject(error);
} else {
resolve(content);
}
});
});
}
// Now it chains beautifully
readFilePromise("./config.json")
.then(content => JSON.parse(content))
.then(config => console.log(config.version))
.catch(error => console.log("Error:", error.message));Most modern APIs return Promises natively. Node.js even provides fs/promises:
import { readFile } from "fs/promises";
// Already returns a Promise
readFile("./data.txt", "utf-8")
.then(content => console.log(content));Promise Combinators
When you have multiple Promises, these static methods coordinate them.
Promise.all: Wait for All (Fail Fast)
const promise1 = fetchUser(1);
const promise2 = fetchUser(2);
const promise3 = fetchUser(3);
Promise.all([promise1, promise2, promise3])
.then((users) => {
// users is [user1, user2, user3] - same order as input!
console.log("All users:", users);
})
.catch((error) => {
// Rejects as soon as ANY promise rejects
console.log("One failed:", error.message);
});Key behavior: If any Promise rejects, Promise.all rejects immediately—even if others are still pending. The other Promises keep running (they're not cancelled), but their results are ignored.
Use Promise.all when you need all results and any failure is fatal.
Promise.allSettled: Wait for All (No Fail Fast)
const promises = [
Promise.resolve("success"),
Promise.reject(new Error("failed")),
Promise.resolve("another success")
];
Promise.allSettled(promises).then((results) => {
// results is an array of outcome objects
results.forEach((result, index) => {
if (result.status === "fulfilled") {
console.log(`${index}: ${result.value}`);
} else {
console.log(`${index}: ${result.reason.message}`);
}
});
});
// 0: success
// 1: failed
// 2: another successUse Promise.allSettled when you want to know what happened to each Promise, regardless of success or failure.
Promise.race: First to Settle
const slow = new Promise(resolve => setTimeout(() => resolve("slow"), 2000));
const fast = new Promise(resolve => setTimeout(() => resolve("fast"), 100));
Promise.race([slow, fast]).then((result) => {
console.log(result); // "fast"
});Practical use—implementing timeouts:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), ms)
);
return Promise.race([promise, timeout]);
}
// Fetch with 5-second timeout
withTimeout(fetchData(), 5000)
.then(data => console.log(data))
.catch(err => console.log(err.message)); // "Timeout" or actual errorPromise.any: First to Fulfill
const promises = [
Promise.reject(new Error("fail 1")),
Promise.resolve("success!"),
Promise.reject(new Error("fail 2"))
];
Promise.any(promises).then((result) => {
console.log(result); // "success!"
});Promise.any ignores rejections unless all Promises reject (then it throws AggregateError).
The Event Loop: How Promises Execute
Understanding when Promise handlers run is crucial for debugging async code.
JavaScript has a single thread and an event loop. Work is queued in two priority levels:
- Microtasks (high priority): Promise handlers,
queueMicrotask() - Macrotasks (lower priority):
setTimeout,setInterval, I/O
After each piece of synchronous code finishes, the event loop:
- Drains all microtasks (including new ones added during draining)
- Runs one macrotask
- Repeats
console.log("1");
setTimeout(() => console.log("2"), 0); // Macrotask
Promise.resolve().then(() => console.log("3")); // Microtask
console.log("4");
// Output: 1, 4, 3, 2Why does "3" print before "2" even though both have 0 delay? Promise handlers (microtasks) always run before timers (macrotasks).
This matters when you're debugging timing issues or wondering why code runs in a particular order.
Common Patterns
Pattern 1: Parallel vs Sequential
// PARALLEL: All requests start at once
async function getParallel() {
const [a, b, c] = await Promise.all([
fetchA(),
fetchB(),
fetchC()
]);
return { a, b, c };
}
// SEQUENTIAL: Each waits for the previous
async function getSequential() {
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
return { a, b, c };
}Use parallel when requests are independent. Use sequential when each depends on the previous.
Pattern 2: Retry with Backoff
function retry(fn, maxAttempts = 3, delay = 1000) {
return fn().catch(error => {
if (maxAttempts <= 1) throw error;
return new Promise(resolve =>
setTimeout(resolve, delay)
).then(() => retry(fn, maxAttempts - 1, delay * 2));
});
}
// Usage
retry(() => fetchData())
.then(data => console.log(data))
.catch(err => console.log("All retries failed"));Pattern 3: Cache Promises, Not Values
const cache = new Map();
function fetchUserCached(id) {
if (!cache.has(id)) {
// Cache the Promise itself, not the resolved value
cache.set(id, fetchUser(id));
}
return cache.get(id);
}
// Multiple calls return the SAME Promise
fetchUserCached(1).then(user => console.log(user));
fetchUserCached(1).then(user => console.log(user)); // No second network requestCaching the Promise (not the value) means concurrent requests share one network call.
Best Practices
Always Handle Rejections
// BAD: unhandled rejection
fetchData().then(process);
// GOOD: always catch
fetchData().then(process).catch(handleError);Unhandled rejections are like uncaught exceptions—they indicate bugs. Modern runtimes warn or crash on unhandled rejections.
Keep Chains Flat
// BAD: nested chains (you're back to callback hell)
fetchUser()
.then(user => {
return fetchPosts(user.id)
.then(posts => {
return fetchComments(posts[0].id)
.then(comments => {
// deeply nested
});
});
});
// GOOD: flat chain
fetchUser()
.then(user => fetchPosts(user.id))
.then(posts => fetchComments(posts[0].id))
.then(comments => { /* flat */ });Don't Wrap Promises in Promises
// BAD: unnecessary wrapping
function getUser() {
return new Promise((resolve, reject) => {
fetchUser().then(resolve).catch(reject);
});
}
// GOOD: just return the Promise
function getUser() {
return fetchUser();
}Don't Use Promises for Synchronous Code
// BAD: Promise for sync operation
function double(x) {
return new Promise(resolve => resolve(x * 2));
}
// GOOD: just return the value
function double(x) {
return x * 2;
}Use Promise.resolve for Consistency
// When you might return sync or async
function getData(useCache) {
if (useCache && cache.has("data")) {
return Promise.resolve(cache.get("data")); // Wrap sync value
}
return fetchData(); // Already a Promise
}
// Caller always gets a Promise
getData(true).then(data => console.log(data));Check Your Understanding
What are the three states of a Promise?
What happens if any Promise in Promise.all rejects?
What does this code print?
Why do Promise handlers (.then/.catch) always run asynchronously, even for already-settled Promises?
Try It Yourself
Practice Promises:
Key Takeaways
The mental model matters more than the syntax. Here's what to internalize:
-
Promises restore control. Unlike callbacks where you hand off your continuation, Promises give you a receipt. You decide when and how to react.
-
Settled means forever. Once a Promise resolves or rejects, that result is immutable. This makes them safe to pass around and attach multiple handlers to.
-
Handlers are always async. Even
Promise.resolve(42).then(fn)runsfnasynchronously. This consistency prevents timing bugs. -
Chains flatten automatically. Returning a Promise from
.then()unwraps it. You never deal with "a Promise of a Promise." -
Errors propagate until caught. Like exceptions, rejections flow through the chain until a
.catch()handles them. -
Microtasks before macrotasks. Promise handlers run before
setTimeoutcallbacks. This is the event loop priority.
What's Next
Promises are powerful but chains can get verbose. The next lesson introduces async/await—syntactic sugar that makes async code read like synchronous code while keeping all the Promise semantics you just learned.
async/await is just Promises underneath. Everything in this lesson—states, chaining, error propagation, combinators—still applies. You're not learning something new; you're learning a cleaner syntax for what you already know.