Promises deep dive—let me take you into the heart of async JavaScript. After five-plus years shipping real-world platforms from e-commerce to ride-hailing, I've learned that understanding promises isn't optional; it's the foundation that separates engineers who debug race conditions from those who ship with confidence. This article gets you there.
States: Pending, then fulfilled or rejected—once
A promise is a state machine with exactly three states: pending, fulfilled, and rejected. The journey is one-way. Once a promise settles—either fulfilled with a value or rejected with a reason—that state is permanent. No reverts, no state changes after the fact.
When you create a promise, it starts pending. Think of it as a task in flight. Your executor function receives two callbacks: resolve and reject. Call one to move to fulfilled, call the other to move to rejected. That's it.
const examplePromise = new Promise<string>((resolve, reject) => {
// pending state here
if (somethingGood) {
resolve("success value");
// now fulfilled
} else {
reject(new Error("something went wrong"));
// now rejected
}
// calling resolve or reject again does NOTHING
resolve("this is ignored");
});
console.log(examplePromise); // Promise { <pending> }I've seen junior developers try to "reset" a promise or change its value mid-execution. They can't, and that's by design. This immutability is what makes async flow predictable. Once settled, observers get the same value every time they attach handlers.
Here's a real example from a payment processing flow I built: we fetch a transaction, validate it, then charge the customer. If validation fails, the promise rejects immediately. Every retry creates a fresh promise with fresh state—we never try to "un-reject" the first one.
async function processPayment(txnId: string): Promise<void> {
const transaction = await fetchTransaction(txnId);
// if validation throws, the returned promise rejects here
validateTransaction(transaction); // throws if invalid
// execution only reaches here if validation passed
await chargeCustomer(transaction.amount);
}
// On retry, we call processPayment again—new promise, fresh state
try {
await processPayment("txn-123");
} catch (err) {
console.error("Payment failed:", err);
// retry: await processPayment("txn-123"); // NEW promise
}Chaining: then returns a new promise; return values flow down the chain
Here's where promises get elegant. Every call to .then() returns a new promise. That new promise resolves to whatever the handler returns. If the handler throws, the new promise rejects. If the handler returns another promise, the new promise adopts that promise's fate.
This design lets you build composable chains where each step transforms the value and passes it forward.
fetchUser(userId)
.then((user) => {
console.log("User fetched:", user.name); // step 1
return user.id; // returns a value
})
.then((userId) => {
// receives the id from previous step
console.log("Fetching posts for user:", userId); // step 2
return fetchPosts(userId); // returns a promise
})
.then((posts) => {
// the promise from fetchPosts resolved, so we get the posts
console.log("Posts loaded:", posts.length); // step 3
return posts.map((p) => p.title); // returns an array
})
.then((titles) => {
// step 4: receives the array
console.log("Titles:", titles);
});The key insight: .then() always returns a promise, even if your handler doesn't explicitly return one (it wraps the return in a resolved promise). And if your handler returns a promise—like fetchPosts(userId) above—the chain waits for that promise to settle before moving to the next step.
I used this pattern heavily in a travel booking platform. Each step transformed the data: search flights → select flight → fetch seat map → select seats → apply discount → create reservation. One chain, pure transformations, no nesting hell.
searchFlights(route, date)
.then((flights) => selectCheapest(flights))
.then((flight) => fetchSeatMap(flight.id))
.then((seatMap) => selectSeats(seatMap, passengerCount))
.then((seats) => applyDiscount(flight, seats, promoCode))
.then((booking) => createReservation(booking))
.then((reservation) => sendConfirmation(reservation.id));Error propagation: A rejection skips to the nearest catch
When a promise rejects—whether from an explicit reject() call or a thrown error—the rejection flows down the chain until it hits a .catch() handler. Any .then() handlers between the rejection and the catch are skipped.
This is powerful because you can handle errors once at the end of a long chain, rather than wrapping each step in try-catch.
fetchUser(userId)
.then((user) => validateUser(user)) // if throws, skip to catch
.then((validUser) => fetchPermissions(validUser.id))
.then((permissions) => checkAccess(permissions)) // if rejects, skip to catch
.then((accessGranted) => {
console.log("Access granted:", accessGranted);
})
.catch((err) => {
// ALL errors above land here
console.error("Error in chain:", err);
});A rejection skips every handler until it finds a catch (or a then with an error handler as the second argument). It's like a try-catch that spans the entire chain.
In production, I use this to wrap API calls with a single error handler at the bottom:
function fetchAndProcess(apiUrl: string) {
return fetch(apiUrl)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => transform(data))
.then((transformed) => validate(transformed))
.then((validated) => saveToCache(validated))
.catch((err) => {
logger.error("Pipeline failed:", err);
return fallbackValue; // or rethrow
});
}
await fetchAndProcess("https://api.example.com/data");If any step fails—network error, bad JSON, validation error, or cache write error—it jumps to the catch. No value is corrupted mid-pipeline.
Promise.withResolvers: Create a promise and its resolve and reject functions in one call
Promise.withResolvers() is a relatively new utility (ES2024) that eliminates a common anti-pattern: storing resolve and reject outside the constructor.
Before withResolvers, you'd see code like this:
let globalResolve: (value: string) => void;
let globalReject: (reason?: any) => void;
const myPromise = new Promise<string>((resolve, reject) => {
globalResolve = resolve;
globalReject = reject;
});
// later, somewhere else
globalResolve("done"); // messy and error-pronePromise.withResolvers() gives you a cleaner API:
const { promise, resolve, reject } = Promise.withResolvers<string>();
// all three live together
setTimeout(() => resolve("done"), 1000);
await promise; // waits 1 second, then resolves to "done"This is especially useful when you're bridging callbacks to promises, or building cancellation logic:
class Deferred<T> {
promise: Promise<T>;
resolve!: (value: T) => void;
reject!: (reason?: any) => void;
constructor() {
const { promise, resolve, reject } = Promise.withResolvers<T>();
this.promise = promise;
this.resolve = resolve;
this.reject = reject;
}
}
// In an event emitter or handler
class FileUpload {
private deferred = new Deferred<string>();
onUploadComplete(url: string) {
this.deferred.resolve(url);
}
onUploadError(err: Error) {
this.deferred.reject(err);
}
waitForCompletion(): Promise<string> {
return this.deferred.promise;
}
}
const upload = new FileUpload();
upload.start("large-file.zip");
const url = await upload.waitForCompletion(); // waits for eventI've used this pattern in WebSocket handlers and long-running background tasks. Instead of nested callbacks, you get a promise that settles when the async work finishes, wherever that callback lives.
Common mistakes
Forgetting to return in chains: If you omit return in a then handler, the next step receives undefined.
fetchUser(id)
.then((user) => {
fetchPosts(user.id); // BUG: missing return
// implicitly returns undefined
})
.then((posts) => {
// posts is undefined, not an array
console.log(posts.map((p) => p.id)); // crash
});Swallowing errors with bad catch: A catch that doesn't rethrow swallows the error. The promise resolves as if nothing happened.
fetchData()
.catch((err) => {
console.error(err);
// implicitly returns undefined; promise now resolves
})
.then((data) => {
// data is undefined
});Calling resolve/reject multiple times: Only the first one matters. This is subtle with timers.
const buggyPromise = new Promise<string>((resolve, reject) => {
resolve("first");
reject(new Error("second")); // ignored; promise already fulfilled
});
await buggyPromise; // "first"Creating a promise anti-pattern: Wrapping an already-resolved value in new Promise is wasteful.
// Bad
function getData(): Promise<string> {
return new Promise((resolve) => {
resolve("data");
});
}
// Good
function getData(): Promise<string> {
return Promise.resolve("data");
}Key takeaways
- Promises are state machines: pending → fulfilled or rejected, one-way, irreversible.
- Each
.then()returns a new promise; return values flow down the chain for clean composition. - Rejections skip to the nearest
.catch(), letting you handle errors centrally instead of per-step. Promise.withResolvers()gives you a cleaner way to create promises when you need to resolve or reject from outside the constructor.- Always return values in then handlers, always rethrow in catch if you can't handle the error, and never try to change a settled promise's state.






Comments
Loading comments…