Learn JavaScript Promises. Create, chain, and combine Promises, handle errors properly, and avoid common async pitfalls.
What if you could represent a value that doesn’t exist yet? What if instead of deeply nested callbacks, you could write asynchronous code that reads almost like synchronous code?
Copy
Ask AI
// Instead of callback hell...getUser(userId, function(user) { getPosts(user.id, function(posts) { getComments(posts[0].id, function(comments) { console.log(comments) }) })})// ...Promises give you this:getUser(userId) .then(user => getPosts(user.id)) .then(posts => getComments(posts[0].id)) .then(comments => console.log(comments))
A Promise is an object representing the eventual completion or failure of an asynchronous operation. Standardized in the ECMAScript 2015 (ES6) specification, it’s a placeholder for a value that will show up later. Think of it like an order ticket at a restaurant that you’ll trade for food when it’s ready.
What you’ll learn in this guide:
What Promises are and why they were invented
The three states of a Promise: pending, fulfilled, rejected
How to create Promises with the Promise constructor
How to consume Promises with .then(), .catch(), and .finally()
How Promise chaining works and why it’s powerful
All the Promise static methods: all, allSettled, race, any, resolve, reject, withResolvers, try
Common patterns and mistakes to avoid
Prerequisite: This guide assumes you understand Callbacks. Promises were invented to solve problems with callbacks, so understanding callbacks will help you appreciate why Promises exist and how they improve async code.
A Promise is a JavaScript object that represents the eventual result of an asynchronous operation. When you create a Promise, you’re saying: “I don’t have the value right now, but I promise to give you a value (or an error) later.”
Copy
Ask AI
// A Promise that resolves after 1 secondconst promise = new Promise((resolve, reject) => { setTimeout(() => { resolve('Hello from the future!') }, 1000)})// Consuming the Promisepromise.then(value => { console.log(value) // "Hello from the future!" (after 1 second)})
Unlike callbacks that you pass into functions, Promises are objects you get back from functions. This small change unlocks useful patterns like chaining, composition, and unified error handling.
Here’s the important part: once your order is fulfilled or rejected, it doesn’t change. You can’t un-eat the pasta or un-reject the apology. Similarly, once a Promise settles, its state is permanent. According to the ECMAScript specification, this immutability guarantee (called “settled” state) is what makes Promises reliable building blocks for complex async workflows.
Before we go further, let’s quickly look at why Promises were invented. If you’ve read the Callbacks guide, you know about “callback hell”: the deeply nested, hard-to-read code that happens when you chain multiple async operations:
Copy
Ask AI
// Callback Hell - The Pyramid of DoomgetUserData(userId, function(error, user) { if (error) { handleError(error) return } getOrderHistory(user.id, function(error, orders) { if (error) { handleError(error) return } getOrderDetails(orders[0].id, function(error, details) { if (error) { handleError(error) return } getShippingStatus(details.shipmentId, function(error, status) { if (error) { handleError(error) return } console.log(status) }) }) })})
The same logic with Promises:
Copy
Ask AI
// Promises - Flat and ReadablegetUserData(userId) .then(user => getOrderHistory(user.id)) .then(orders => getOrderDetails(orders[0].id)) .then(details => getShippingStatus(details.shipmentId)) .then(status => console.log(status)) .catch(error => handleError(error)) // One place for ALL errors!
Why Promises are better:
Flat structure — No more pyramid of doom
Unified error handling — One .catch() handles all errors in the chain
Composition — Promises can be combined with Promise.all(), Promise.race(), etc.
Guaranteed async — .then() callbacks always run asynchronously (on the microtask queue)
Return values — Promises are objects you can store, pass around, and return from functions
There’s a subtle but useful distinction between a Promise’s state and its fate:
State = pending, fulfilled, or rejected
Fate = resolved or unresolved
Think of it like this: when you place your restaurant order, your fate is “sealed” the moment the waiter writes it down, even though you haven’t received your food yet (still pending). You can’t change your order anymore.A Promise is resolved when its fate is sealed, either because it’s already settled, or because it’s “locked in” to follow another Promise:
Copy
Ask AI
const innerPromise = new Promise(resolve => { setTimeout(() => resolve('inner value'), 1000)})const outerPromise = new Promise(resolve => { resolve(innerPromise) // Resolving with another Promise!})// outerPromise is now "resolved" (its fate is locked to innerPromise)// but it's still "pending" (its state hasn't settled yet)outerPromise.then(value => { console.log(value) // "inner value" (after 1 second)})
When you resolve a Promise with another Promise, the outer Promise “adopts” the state of the inner one. This is called Promise unwrapping. The outer Promise automatically follows whatever happens to the inner Promise.
JavaScript doesn’t just work with native Promises — it also supports thenables. A thenable is any object with a .then() method. This allows Promises to interoperate with Promise-like objects from libraries:
Copy
Ask AI
// A thenable is any object with a .then() methodconst thenable = { then(onFulfilled, onRejected) { onFulfilled(42) }}// Promise.resolve() unwraps thenablesPromise.resolve(thenable).then(value => { console.log(value) // 42})// Returning a thenable from .then() also worksPromise.resolve('start') .then(() => thenable) .then(value => console.log(value)) // 42
This is why Promise.resolve() doesn’t always return a new Promise — if you pass it a native Promise, it returns the same Promise:
If an error is thrown inside the executor, the Promise is automatically rejected:
Copy
Ask AI
const promise = new Promise((resolve, reject) => { throw new Error('Something went wrong!') // No need to call reject() — the throw does it automatically})promise.catch(error => { console.log(error.message) // "Something went wrong!"})
This is equivalent to:
Copy
Ask AI
const promise = new Promise((resolve, reject) => { reject(new Error('Something went wrong!'))})
The .then() method is the primary way to handle Promise results. It takes up to two callbacks:
Copy
Ask AI
promise.then(onFulfilled, onRejected)
onFulfilled(value) — Called when the Promise is fulfilled
onRejected(reason) — Called when the Promise is rejected
Copy
Ask AI
const promise = new Promise((resolve, reject) => { const random = Math.random() if (random > 0.5) { resolve(`Success! Random was ${random}`) } else { reject(new Error(`Failed! Random was ${random}`)) }})promise.then( value => console.log('Fulfilled:', value), error => console.log('Rejected:', error.message))
Most commonly, you’ll only pass the first callback and use .catch() for errors:
Sometimes you want to log an error but still let it bubble up:
Copy
Ask AI
fetchData(url) .catch(error => { // Log the error console.error('Error fetching data:', error.message) // Re-throw to continue propagating throw error }) .then(data => { // This won't run if there was an error processData(data) }) .catch(error => { // Handle at a higher level showUserError('Failed to load data') })
Always handle Promise rejections! If a Promise is rejected and there’s no .catch() handler, modern JavaScript environments will warn you about an “unhandled promise rejection”:
Copy
Ask AI
// ❌ BAD - Unhandled rejectionPromise.reject(new Error('Oops!'))// ❌ BAD - Error in .then() with no .catch()Promise.resolve('data') .then(data => { throw new Error('Processing failed!') })// UnhandledPromiseRejection warning!// ✓ GOOD - Always have a .catch()Promise.reject(new Error('Oops!')) .catch(error => console.error('Handled:', error.message))
In Node.js, unhandled rejections can crash your application in future versions. In browsers, they’re logged as errors.
Converting a regular value to a Promise for consistency
Starting a Promise chain
Testing Promise-based code
Copy
Ask AI
// Useful for normalizing values to Promisesfunction fetchData(cached) { if (cached) { return Promise.resolve(cached) // Return cached data as Promise } return fetch('/api/data').then(r => r.json()) // Fetch fresh data}// Both code paths return Promises, so callers can use .then() consistentlyfetchData(cachedData).then(data => render(data))
async function loadDashboard(userId) { // All three requests start simultaneously! const [user, posts, notifications] = await Promise.all([ fetchUser(userId), fetchPosts(userId), fetchNotifications(userId) ]) return { user, posts, notifications }}
The short-circuit behavior:
Copy
Ask AI
Promise.all([ Promise.resolve('A'), Promise.reject(new Error('B failed!')), // This rejects! Promise.resolve('C')]) .then(values => { console.log('Success:', values) // Never runs }) .catch(error => { console.log('Failed:', error.message) // "Failed: B failed!" // We don't get 'A' or 'C' — the whole thing fails })
Use Promise.all() when:
You need ALL results to proceed
Any single failure should abort the whole operation
You want to run Promises in parallel and wait for all
Note:Promise.all([]) with an empty array resolves immediately with []. Also, non-Promise values in the array are automatically wrapped with Promise.resolve().
Edge case:Promise.race([]) with an empty array returns a Promise that never settles (stays pending forever). This is rarely useful and usually indicates a bug.
Promise.withResolvers() (ES2024) returns an object containing a new Promise and the functions to resolve/reject it. This is useful when you need to resolve a Promise from outside its executor:
Copy
Ask AI
const { promise, resolve, reject } = Promise.withResolvers()// Resolve it later from anywheresetTimeout(() => resolve('Done!'), 1000)promise.then(value => console.log(value)) // "Done!" (after 1 second)
Before withResolvers(), you had to do this:
Copy
Ask AI
let resolve, rejectconst promise = new Promise((res, rej) => { resolve = res reject = rej})// Now resolve/reject are available outside
Promise.try() (Baseline 2025) takes a callback of any kind and wraps its result in a Promise. This is useful when you have a function that might be synchronous or asynchronous and you want to handle both cases uniformly:
Copy
Ask AI
// The problem: func() might throw synchronously OR return a Promise// This doesn't catch synchronous errors:Promise.resolve(func()).catch(handleError) // Sync throw escapes!// This works but is verbose:new Promise((resolve) => resolve(func()))// Promise.try() is cleaner:Promise.try(func)
Real example: handling callbacks that might be sync or async
Copy
Ask AI
function processData(callback) { return Promise.try(callback) .then(result => console.log('Result:', result)) .catch(error => console.error('Error:', error)) .finally(() => console.log('Done'))}// Works with sync functionsprocessData(() => 'sync result')// Works with async functionsprocessData(async () => 'async result')// Catches sync throwsprocessData(() => { throw new Error('sync error') })// Catches async rejectionsprocessData(async () => { throw new Error('async error') })
You can also pass arguments to the callback:
Copy
Ask AI
// Instead of creating a closure:Promise.try(() => fetchUser(userId))// You can pass arguments directly:Promise.try(fetchUser, userId)
Promise.try() calls the function synchronously (like the Promise constructor executor), unlike .then() which always runs callbacks asynchronously. If possible, it resolves the promise immediately.
When you need to run things one at a time (not in parallel). Use this when each step depends on the previous result, like database transactions or when processing order matters (uploading files in a specific sequence).
Copy
Ask AI
// Process items one at a timeasync function processSequentially(items) { const results = [] for (const item of items) { const result = await processItem(item) // Wait for each results.push(result) } return results}// Or with reduce (pure Promises, no async/await):function processSequentiallyWithReduce(items) { return items.reduce((chain, item) => { return chain.then(results => { return processItem(item).then(result => { return [...results, result] }) }) }, Promise.resolve([]))}
When operations don’t depend on each other. Great for independent fetches like loading a dashboard where you need user data, notifications, and settings all at once. Much faster than doing them one by one.
Copy
Ask AI
// Process all items in parallelasync function processInParallel(items) { const promises = items.map(item => processItem(item)) return Promise.all(promises)}// Example: Fetch multiple URLs at oncetry { const urls = ['/api/users', '/api/posts', '/api/comments'] const responses = await Promise.all(urls.map(url => fetch(url))) const data = await Promise.all(responses.map(r => r.json()))} catch (error) { console.error('One of the requests failed:', error)}
When you want parallelism but don’t want to hammer a server with 100 requests at once. Essential for API rate limits (e.g., “max 10 requests/second”) or when processing large datasets without exhausting memory or connections.
Copy
Ask AI
async function processInBatches(items, batchSize = 3) { const results = [] for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize) const batchResults = await Promise.all( batch.map(item => processItem(item)) ) results.push(...batchResults) } return results}// Process 10 items, 3 at a timeconst items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]const results = await processInBatches(items, 3)// Batch 1: [1, 2, 3] (parallel)// Batch 2: [4, 5, 6] (parallel, after batch 1)// Batch 3: [7, 8, 9] (parallel, after batch 2)// Batch 4: [10] (after batch 3)
Automatically retry when things fail. Perfect for flaky network connections, unreliable third-party APIs, or temporary server issues. For production, consider adding exponential backoff (doubling the delay each attempt).
Copy
Ask AI
async function retry(fn, maxAttempts = 3, delay = 1000) { let lastError for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn() } catch (error) { lastError = error console.log(`Attempt ${attempt} failed: ${error.message}`) if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, delay)) } } } throw lastError}// Usageconst data = await retry( () => fetch('/api/flaky-endpoint').then(r => r.json()), 3, // max attempts 1000 // delay between attempts)
Converting Callbacks to Promises (Promisification)
A helper to convert old callback-style functions to Promises. Useful when working with older Node.js APIs or third-party libraries that still use callbacks but you want clean async/await syntax.
Copy
Ask AI
function promisify(fn) { return function(...args) { return new Promise((resolve, reject) => { fn(...args, (error, result) => { if (error) { reject(error) } else { resolve(result) } }) }) }}// Usage example (Node.js - fs uses callbacks)const readFile = promisify(fs.readFile)const data = await readFile('file.txt', 'utf8')
Node.js has this built-in: const { promisify } = require('util')
The Promise constructor anti-pattern is when you wrap something that’s already a Promise. You’re just adding complexity for no reason. Only use new Promise() when you’re wrapping callback-based APIs.
// ❌ WRONG - forEach doesn't wait for Promisesasync function processAll(items) { items.forEach(async item => { await processItem(item) // These run in parallel, not sequentially! }) console.log('Done!') // Logs immediately, before processing completes}// ✓ CORRECT - Use for...of for sequentialasync function processAllSequential(items) { for (const item of items) { await processItem(item) } console.log('Done!') // Logs after all items processed}// ✓ CORRECT - Use Promise.all for parallelasync function processAllParallel(items) { await Promise.all(items.map(item => processItem(item))) console.log('Done!') // Logs after all items processed}
Promise callbacks are scheduled as microtasks, which run after the current synchronous code but before the next macrotask. See the Event Loop guide for details.
Question 4: What happens if you resolve a Promise with another Promise?
Answer:The outer Promise “adopts” the state of the inner Promise. This is called Promise unwrapping or assimilation:
Copy
Ask AI
const inner = new Promise(resolve => { setTimeout(() => resolve('inner value'), 1000)})const outer = Promise.resolve(inner)// outer is now "locked in" to follow inner// It won't fulfill until inner fulfillsouter.then(value => console.log(value)) // "inner value" (after 1 second)
This happens automatically. You can’t have a Promise that fulfills with another Promise as its value.
Question 5: What's wrong with this code?
Copy
Ask AI
function getData() { return new Promise((resolve, reject) => { fetch('/api/data') .then(response => response.json()) .then(data => resolve(data)) .catch(error => reject(error)) })}
Answer:This is the Promise constructor anti-pattern. You’re wrapping a Promise (fetch) inside new Promise() unnecessarily. Just return the Promise directly:
Copy
Ask AI
function getData() { return fetch('/api/data') .then(response => response.json())}
The original code:
Adds unnecessary complexity
Could lose stack trace information
Might swallow errors if you forget the .catch()
Only use new Promise() when wrapping callback-based APIs.
A Promise is an object that represents the eventual completion or failure of an asynchronous operation. As defined in the ECMAScript specification, a Promise is in one of three states: pending, fulfilled, or rejected. Once settled (fulfilled or rejected), a Promise’s state and value are immutable — it cannot change again.
What are the three states of a Promise?
Pending means the async operation has not completed yet. Fulfilled means it completed successfully with a result value. Rejected means it failed with a reason (usually an Error). A Promise transitions from pending to either fulfilled or rejected, never both and never more than once. This guarantee makes Promises more predictable than callbacks.
What is the difference between Promise.all and Promise.allSettled?
Promise.all resolves when all Promises fulfill and rejects immediately if any single Promise rejects. Promise.allSettled (added in ES2020) waits for all Promises to settle regardless of outcome and returns an array of result objects with status and value or reason. Use allSettled when you need results from every operation even if some fail.
How do you handle errors in Promise chains?
Attach a .catch() at the end of the chain to handle any rejection from any preceding .then(). Errors propagate down the chain until caught. You can also use .then(onFulfilled, onRejected), but a single .catch() at the end is the recommended pattern. Always handle rejections — unhandled rejections are logged as warnings in modern runtimes.
What is Promise chaining and why is it useful?
Promise chaining means calling .then() on the Promise returned by a previous .then(). Each .then() receives the return value of the previous one, creating a flat, readable sequence of async steps. According to MDN, this was the key innovation that solved callback hell by replacing nested callbacks with a linear chain of operations.