Promises are core to modern JavaScript async. Master states, chaining, error handling, and Promise combinators for your interview.
Picture a restaurant that gives you a buzzer when you order. The buzzer is a Promise. You don't have your food yet — but you have something concrete to hold, something that represents the food you're waiting for. You can go sit down, talk to friends, do other things. The kitchen is working in the background. When the food is ready, the buzzer goes off (resolved). If something went wrong — the kitchen ran out of ingredients — the buzzer goes off differently (rejected). Either way, you get notified and then decide what to do. The old way (callbacks) was like standing at the counter staring at the kitchen. You blocked yourself until the food came. And if you ordered a side dish after the main — you had to start that order from inside the first order. Then the dessert from inside that. Nested orders, three people deep. The key insight: a Promise is not the result itself — it's a proxy for a result that doesn't exist yet. It has three possible states — pending (buzzer in hand), fulfilled (food arrived), rejected (kitchen error) — and state transitions are permanent. Once a Promise settles, it never changes again. Every .then() and .catch() you attach gets called once, with that settled value, no matter when you attach them.
A Promise is always in exactly one of three states. Transitions only go one direction — a settled Promise never changes back.
// pending → fulfilled
const resolved = new Promise((resolve, reject) => {
resolve('success')
})
// pending → rejected
const rejected = new Promise((resolve, reject) => {
reject(new Error('something broke'))
})
// Still pending — resolves after 1 second
const delayed = new Promise((resolve) => {
setTimeout(() => resolve('done'), 1000)
})
Once settled, the value is locked. Multiple .then() calls on the same Promise all receive the same value:
const p = Promise.resolve(42)
p.then(v => console.log(v)) // 42
p.then(v => console.log(v)) // 42 — same value, called again
// Attaching .then() after it's settled still works — fires asynchronously
// Constructor — wrapping a callback-based API
function readFilePromise(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', (err, data) => {
if (err) reject(err) // error path
else resolve(data) // success path
})
})
}
// Promise.resolve() / Promise.reject() — shorthand for already-settled values
const immediate = Promise.resolve({ id: 1, name: 'Alice' })
const failed = Promise.reject(new Error('Not found'))
// Wrapping a synchronous value — useful in functions that must always return a Promise
function getUser(id) {
if (cache.has(id)) return Promise.resolve(cache.get(id))
return fetch(`/api/users/${id}`).then(r => r.json())
}
fetch('/api/user/1')
.then(response => response.json()) // transform the response
.then(user => {
console.log(user.name) // use the data
return user.id // pass to the next .then()
})
.then(id => fetch(`/api/posts/${id}`)) // chain another async call
.then(r => r.json())
.then(posts => console.log(posts))
.catch(err => {
// Catches ANY rejection from ANY step above
console.error('Something failed:', err)
})
.finally(() => {
// Runs after settled — whether fulfilled or rejected
// Perfect for cleanup: hide spinners, close connections
setLoading(false)
})
Every .then() returns a new Promise. This is not obvious and is the source of most Promise bugs. What the new Promise resolves to depends on what the handler returns:
// If the handler returns a plain value → next .then() gets that value
Promise.resolve(1)
.then(n => n + 1) // returns 2 — not a Promise, just a number
.then(n => n * 10) // 2 → 20
.then(console.log) // 20
// If the handler returns a Promise → the chain waits for it to settle
Promise.resolve(1)
.then(n => fetch(`/api/items/${n}`)) // returns a Promise
.then(r => r.json()) // waits for fetch, then transforms
.then(console.log) // gets the JSON
// If the handler throws → the chain jumps to the nearest .catch()
Promise.resolve(1)
.then(n => { throw new Error('oops') })
.then(n => console.log('never runs'))
.catch(err => console.log('caught:', err.message)) // 'caught: oops'
// All must resolve — resolves with an array of all values in order
const [users, posts, settings] = await Promise.all([
fetch('/api/users').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
fetch('/api/settings').then(r => r.json()),
])
// All three fetches run IN PARALLEL — not sequentially
// Total time = slowest request, not sum of all requests
// If ANY rejects, Promise.all immediately rejects
// The other Promises are not cancelled — they continue running,
// but their results are discarded
Promise.all([
Promise.resolve(1),
Promise.reject(new Error('failed')),
Promise.resolve(3),
]).catch(err => console.log(err.message)) // 'failed'
// Waits for ALL to settle regardless of outcome
// Returns array of result objects, never rejects
const results = await Promise.allSettled([
fetch('/api/users').then(r => r.json()),
fetch('/api/broken-endpoint').then(r => r.json()),
fetch('/api/posts').then(r => r.json()),
])
results.forEach(result => {
if (result.status === 'fulfilled') {
console.log('success:', result.value)
} else {
console.log('failed:', result.reason)
}
})
// Use when you need all results, even partial failures — dashboards, batch operations
// Settles with the first Promise to settle — fulfilled or rejected
const result = await Promise.race([
fetch('/api/fast-server'),
fetch('/api/slow-server'),
])
// Gets the response from whichever server responds first
// Classic use: timeout wrapper
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms)
)
return Promise.race([promise, timeout])
}
await withTimeout(fetch('/api/slow'), 5000) // rejects if fetch takes > 5s
// Like race() but ignores rejections — resolves with first FULFILLMENT
// Only rejects if ALL promises reject (AggregateError)
const fastest = await Promise.any([
fetch('https://cdn1.example.com/resource'),
fetch('https://cdn2.example.com/resource'),
fetch('https://cdn3.example.com/resource'),
])
// Gets data from whichever CDN responds first successfully
// If one CDN is down, the others cover it — unlike race() which would reject
// ❌ Mistake 1: Forgetting to return inside .then()
fetch('/api/user')
.then(r => {
r.json() // forgot return — next .then() gets undefined
})
.then(user => console.log(user)) // undefined
// ✓ Fix: always return
fetch('/api/user')
.then(r => r.json()) // return the Promise
.then(user => console.log(user))
// ❌ Mistake 2: Creating a new Promise unnecessarily (Promise constructor antipattern)
function getUser(id) {
return new Promise((resolve, reject) => {
fetch(`/api/users/${id}`)
.then(r => r.json())
.then(resolve)
.catch(reject)
})
}
// ✓ Fix: fetch already returns a Promise — just return it
function getUser(id) {
return fetch(`/api/users/${id}`).then(r => r.json())
}
// ❌ Mistake 3: Unhandled rejections — Promise rejects silently if no .catch()
fetch('/api/data')
.then(r => r.json())
// No .catch() — rejection is swallowed, causes UnhandledPromiseRejection warning
// ✓ Fix: always terminate chains with .catch()
fetch('/api/data')
.then(r => r.json())
.catch(err => handleError(err))
// ❌ Mistake 4: Sequential when you meant parallel
async function loadData() {
const users = await fetchUsers() // waits for this to complete
const posts = await fetchPosts() // THEN starts this — sequential!
return { users, posts } // Total time = users + posts
}
// ✓ Fix: start both at once, then await both
async function loadData() {
const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()])
return { users, posts } // Total time = max(users, posts)
}Many devs think a Promise executes its callback asynchronously — but actually the executor function (the callback passed to new Promise()) runs synchronously, immediately. Only the resolution callbacks (.then handlers) are scheduled asynchronously as microtasks. This means any synchronous code inside the executor (like calling resolve() directly) runs before the current call stack finishes.
Many devs think .catch() only catches network errors — but actually .catch() catches any rejection in the entire chain above it, including errors thrown synchronously inside .then() handlers. A throw inside a .then() callback rejects the returned Promise and propagates to the next .catch() in the chain.
Many devs think calling resolve() and reject() both in one Promise has undefined behavior — but actually only the first call to either resolve() or reject() has any effect. All subsequent calls are silently ignored. A Promise can only transition state once, so whichever of resolve/reject is called first wins permanently.
Many devs think Promise.all() cancels other Promises when one rejects — but actually JavaScript has no Promise cancellation mechanism. When Promise.all() rejects, the other Promises continue running to completion — their results are just discarded. If you need cancellation, use AbortController with fetch or a manual cancellation token pattern.
Many devs think .finally() receives the settled value as its argument — but actually .finally() receives no argument at all. It runs after settlement purely for cleanup, and it passes through the original value or rejection unchanged to the next handler in the chain. Use .then()/.catch() when you need the value.
Many devs think chaining .then() modifies the original Promise — but actually every .then() call returns a brand-new Promise. The original Promise is never modified. This is why you must use the returned chain and not ignore the return value of .then().
React Query and SWR — the most popular data-fetching libraries in React — are built entirely on Promises. Their caching, background refetching, stale-while-revalidate, and optimistic update patterns are all orchestrated by composing Promise chains with state management, and understanding Promise settlement is prerequisite for debugging their behavior.
The Fetch API returns Promises for both the response and the body parsing — a critically misunderstood detail. fetch() resolves when headers arrive (even for 404/500 errors), not when the body is read. response.json() returns a second Promise for the body. Network failure is the only thing that rejects the first Promise, which is why response.ok must be checked manually.
Service Workers use Promise-based APIs for cache management — caches.open(), cache.put(), cache.match() all return Promises. The install and activate lifecycle events use event.waitUntil(promise) to tell the browser not to advance the service worker state until the Promise resolves, ensuring caches are populated before the worker activates.
IndexedDB's native API is notoriously callback-based and unergonomic, which is why libraries like idb wrap it in Promises. The entire indexed database ecosystem in browsers is accessed through Promise wrappers in modern code, making Promise understanding foundational for any offline-capable web app.
Node.js's fs.promises module provides the Promise-based filesystem API — fs.promises.readFile(), fs.promises.writeFile() — which replaced the callback-based API as the standard approach. The util.promisify() utility converts any Node.js callback-style function to a Promise, and understanding what it does internally (it creates a new Promise and calls resolve/reject in the callback) deepens Promise knowledge.
GraphQL clients like Apollo Client represent every query and mutation as a Promise under the hood. Apollo's useQuery hook manages the Promise lifecycle (loading/error/data states) by attaching .then() handlers internally, and understanding why Apollo shows stale data while refetching requires understanding that the old Promise's value is preserved until the new one resolves.
Promise chaining order
Promise constructor is synchronous
Promise constructor anti-pattern
Explain Promises — states, chaining, and error handling.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.