How to Chain Promises in JavaScript
The true power of Promises does not come from handling a single asynchronous operation. It comes from chaining multiple operations together in a flat, readable sequence. Promise chaining lets you express "do this, then do that, then do something else" as a clean pipeline, regardless of whether each step is synchronous or asynchronous.
The key insight that makes chaining work is that every .then() call returns a new Promise. The value you return from a .then() handler becomes the resolved value of that new Promise, which the next .then() in the chain receives. This simple mechanism replaces the deeply nested callback pyramids with linear, readable code.
This guide covers how chaining works step by step, the critical difference between returning plain values and returning Promises from handlers, why nesting defeats the purpose, what thenables are, and a complete real-world example using the Fetch API.
Returning Values from .then(): Chaining
Every call to .then() returns a new Promise. If your handler returns a value, that value becomes the fulfillment value of the new Promise. The next .then() in the chain receives it.
Basic Value Chaining
let promise = new Promise(resolve => {
setTimeout(() => resolve(1), 1000);
});
promise
.then(value => {
console.log(value); // 1
return value * 2; // Return 2 → becomes the next Promise's value
})
.then(value => {
console.log(value); // 2
return value * 3; // Return 6 → becomes the next Promise's value
})
.then(value => {
console.log(value); // 6
return value + 4; // Return 10
})
.then(value => {
console.log(value); // 10
});
Output (after ~1 second):
1
2
6
10
Each .then() receives the value returned by the previous handler. The chain flows from top to bottom, transforming the value at each step.
How It Works Internally
Let's trace through the chain more carefully:
let p1 = Promise.resolve(10);
let p2 = p1.then(value => {
console.log("Step 1:", value); // 10
return value + 5;
});
// p2 is a NEW Promise that resolves to 15
let p3 = p2.then(value => {
console.log("Step 2:", value); // 15
return value * 2;
});
// p3 is a NEW Promise that resolves to 30
let p4 = p3.then(value => {
console.log("Step 3:", value); // 30
});
// p4 is a NEW Promise that resolves to undefined (no return)
Each .then() creates an independent Promise. When written as a chain, they connect together:
// This chain:
Promise.resolve(10)
.then(v => v + 5)
.then(v => v * 2)
.then(v => console.log(v)); // 30
// Is equivalent to these separate statements:
let p1 = Promise.resolve(10);
let p2 = p1.then(v => v + 5); // Promise<15>
let p3 = p2.then(v => v * 2); // Promise<30>
let p4 = p3.then(v => console.log(v)); // logs 30, Promise<undefined>
What Happens When You Do Not Return
If a .then() handler does not explicitly return a value, the returned Promise resolves with undefined:
Promise.resolve("hello")
.then(value => {
console.log(value); // "hello"
// No return statement!
})
.then(value => {
console.log(value); // undefined (previous handler returned nothing)
});
This is a common source of bugs in Promise chains. Always return a value if the next step needs it.
Transforming Data Through a Chain
Promise.resolve(" Hello, World! ")
.then(str => str.trim())
.then(str => str.toLowerCase())
.then(str => str.split(" "))
.then(words => words.map(w => w[0].toUpperCase() + w.slice(1)))
.then(words => words.join(" "))
.then(result => console.log(result)); // "Hello, World!"
Each step takes the output of the previous step and transforms it, creating a clear data pipeline.
Common Mistake: Calling .then() on the Original Promise
A critical distinction: chaining calls .then() on the Promise returned by the previous .then(). Calling .then() multiple times on the same Promise creates independent branches, not a chain:
// ✅ CHAINING: Each .then is called on the previous .then's Promise
Promise.resolve(1)
.then(v => v + 1) // Called on Promise<1>, returns Promise<2>
.then(v => v + 1) // Called on Promise<2>, returns Promise<3>
.then(v => console.log(v)); // 3
// ❌ NOT CHAINING: Multiple .then on the same Promise (branching)
let original = Promise.resolve(1);
original.then(v => v + 1)
.then(v => console.log("Branch A:", v)); // 2
original.then(v => v + 10)
.then(v => console.log("Branch B:", v)); // 11
// Both branches start from the same value (1), they don't feed into each other
Output of the branching example:
Branch A: 2
Branch B: 11
Both branches see 1 as their starting value because they both attach to original, not to each other.
Returning Promises from .then(): Sequential Async Operations
The real power of chaining appears when a .then() handler returns another Promise. When this happens, the chain waits for that Promise to settle before proceeding to the next .then().
Returning a Promise
function fetchUser(id) {
return new Promise(resolve => {
setTimeout(() => resolve({ id, name: "Alice", orderId: 42 }), 500);
});
}
function fetchOrder(orderId) {
return new Promise(resolve => {
setTimeout(() => resolve({ id: orderId, product: "Laptop", amount: 999 }), 500);
});
}
function fetchShipping(orderId) {
return new Promise(resolve => {
setTimeout(() => resolve({ orderId, status: "Shipped", eta: "2 days" }), 500);
});
}
// Sequential chain: each step waits for the previous one
fetchUser(1)
.then(user => {
console.log(`User: ${user.name}`);
return fetchOrder(user.orderId); // Returns a Promise (chain waits)
})
.then(order => {
console.log(`Order: ${order.product}, $${order.amount}`);
return fetchShipping(order.id); // Returns a Promise (chain waits)
})
.then(shipping => {
console.log(`Shipping: ${shipping.status}, ETA: ${shipping.eta}`);
})
.catch(error => {
console.error("Something failed:", error.message);
});
Output (over ~1.5 seconds):
User: Alice
Order: Laptop, $999
Shipping: Shipped, ETA: 2 days
Each asynchronous operation runs after the previous one completes. The chain stays flat regardless of how many steps there are.
How the Chain Waits
When a .then() handler returns a Promise, the chain mechanism automatically unwraps it:
- The
.then()receives the returned Promise - It waits for that Promise to settle
- When the returned Promise fulfills, the next
.then()in the chain receives the fulfilled value - When the returned Promise rejects, the error propagates to the nearest
.catch()
Promise.resolve("start")
.then(value => {
console.log(value); // "start"
// Return a Promise that takes 2 seconds to resolve
return new Promise(resolve => {
setTimeout(() => resolve("after delay"), 2000);
});
})
.then(value => {
// This runs 2 seconds later, with "after delay"
console.log(value); // "after delay"
});
Mixing Synchronous Returns and Promise Returns
You can freely mix synchronous values and Promises in the same chain:
fetchUser(1)
.then(user => {
console.log(`User: ${user.name}`);
return user.orderId; // Synchronous return (plain value)
})
.then(orderId => {
console.log(`Looking up order ${orderId}...`);
return fetchOrder(orderId); // Asynchronous return (Promise)
})
.then(order => {
console.log(`Order total: $${order.amount}`);
return order.amount * 1.1; // Synchronous return (calculation)
})
.then(totalWithTax => {
console.log(`Total with tax: $${totalWithTax.toFixed(2)}`);
});
The chain handles both cases seamlessly. Plain values are wrapped in a resolved Promise automatically, so the next .then() receives them just like it would receive a resolved Promise value.
Common Mistake: Forgetting to Return the Promise
One of the most common Promise chaining bugs is forgetting to return a Promise from inside a .then() handler:
// ❌ WRONG: Missing return (the chain doesn't wait for fetchOrder)
fetchUser(1)
.then(user => {
console.log(`User: ${user.name}`);
fetchOrder(user.orderId); // Started but NOT returned!
})
.then(order => {
// order is UNDEFINED (the previous .then returned nothing)
console.log(order); // undefined
});
Output:
User: Alice
undefined
The fetchOrder call is made, but its result is lost because it was not returned. The chain continues immediately with undefined.
// ✅ CORRECT: Return the Promise so the chain waits
fetchUser(1)
.then(user => {
console.log(`User: ${user.name}`);
return fetchOrder(user.orderId); // Returned!
})
.then(order => {
console.log(`Order: ${order.product}`); // Works correctly
});
If your .then() handler starts an asynchronous operation, you must return the resulting Promise. Without the return, the chain moves to the next .then() immediately with undefined, and the async operation runs in the background disconnected from the chain. This is one of the most frequent Promise bugs.
Chaining vs. Nesting (Keep It Flat!)
One of the main reasons Promises were created was to avoid the deeply nested pyramid structure of callbacks. But it is entirely possible to fall back into nesting with Promises if you are not careful.
The Anti-Pattern: Nested Promises
// ❌ WRONG: Nesting Promises recreates callback hell
fetchUser(1).then(user => {
console.log(`User: ${user.name}`);
fetchOrder(user.orderId).then(order => {
console.log(`Order: ${order.product}`);
fetchShipping(order.id).then(shipping => {
console.log(`Status: ${shipping.status}`);
}).catch(err => {
console.error("Shipping error:", err);
});
}).catch(err => {
console.error("Order error:", err);
});
}).catch(err => {
console.error("User error:", err);
});
This works, but it has the same problems as callback hell: growing indentation, scattered error handling, and difficult-to-follow flow.
The Correct Pattern: Flat Chain
// ✅ CORRECT: Flat Promise chain
fetchUser(1)
.then(user => {
console.log(`User: ${user.name}`);
return fetchOrder(user.orderId);
})
.then(order => {
console.log(`Order: ${order.product}`);
return fetchShipping(order.id);
})
.then(shipping => {
console.log(`Status: ${shipping.status}`);
})
.catch(err => {
console.error("Error:", err.message);
});
The flat chain is cleaner, and the single .catch() at the end handles errors from any step in the chain.
When You Need Data from Multiple Steps
Sometimes a later step needs data from an earlier step. Nesting might seem necessary, but there are better solutions:
// ❌ Nesting to access user in the shipping step
fetchUser(1).then(user => {
return fetchOrder(user.orderId).then(order => {
return fetchShipping(order.id).then(shipping => {
// Now we have access to user, order, AND shipping
console.log(`${user.name}'s order ${order.product}: ${shipping.status}`);
});
});
});
Solution 1: Pass data forward by accumulating results
// ✅ Pass data through the chain
fetchUser(1)
.then(user => {
return fetchOrder(user.orderId)
.then(order => ({ user, order })); // Bundle user with order
})
.then(({ user, order }) => {
return fetchShipping(order.id)
.then(shipping => ({ user, order, shipping })); // Bundle all three
})
.then(({ user, order, shipping }) => {
console.log(`${user.name}'s order ${order.product}: ${shipping.status}`);
})
.catch(err => console.error(err));
Solution 2: Use an outer variable
// ✅ Use outer scope variables
let savedUser;
fetchUser(1)
.then(user => {
savedUser = user;
return fetchOrder(user.orderId);
})
.then(order => {
return fetchShipping(order.id)
.then(shipping => ({ order, shipping }));
})
.then(({ order, shipping }) => {
console.log(`${savedUser.name}'s order ${order.product}: ${shipping.status}`);
})
.catch(err => console.error(err));
Solution 3: Use async/await (the best solution)
// ✅ async/await makes this trivial
async function getFullOrderInfo(userId) {
let user = await fetchUser(userId);
let order = await fetchOrder(user.orderId);
let shipping = await fetchShipping(order.id);
console.log(`${user.name}'s order ${order.product}: ${shipping.status}`);
}
When you find yourself needing data from multiple chain steps, async/await (covered in a later article) is almost always the cleanest solution.
Side-by-Side Comparison
// NESTED (bad) 5 levels of indentation
fetchA().then(a => {
fetchB(a).then(b => {
fetchC(b).then(c => {
fetchD(c).then(d => {
console.log(d);
}).catch(handleError);
}).catch(handleError);
}).catch(handleError);
}).catch(handleError);
// CHAINED (good) flat, 1 level of indentation
fetchA()
.then(a => fetchB(a))
.then(b => fetchC(b))
.then(c => fetchD(c))
.then(d => console.log(d))
.catch(handleError); // One catch for all errors
Thenables (Duck-Typed Promises)
JavaScript's Promise system is designed to be interoperable. It does not require that the objects in a chain are actual Promise instances. Any object with a .then() method is treated as a thenable, and the chain will work with it.
What Is a Thenable?
A thenable is any object that has a then method that follows the Promise convention (accepting onFulfilled and onRejected callbacks):
// A custom thenable: not a real Promise, but works in chains
let thenable = {
then(resolve, reject) {
setTimeout(() => resolve(42), 1000);
}
};
// Promise.resolve() detects the thenable and waits for it
Promise.resolve(thenable)
.then(value => console.log(value)); // 42 (after ~1 second)
Thenables in Chains
When a .then() handler returns a thenable, the chain treats it the same as a returned Promise:
class DelayedValue {
constructor(value, delay) {
this.value = value;
this.delay = delay;
}
then(resolve, reject) {
setTimeout(() => resolve(this.value), this.delay);
}
}
Promise.resolve("start")
.then(value => {
console.log(value); // "start"
return new DelayedValue("from thenable", 1000);
})
.then(value => {
console.log(value); // "from thenable" (after ~1 second)
});
Why Thenables Exist
Thenables enable interoperability between different Promise implementations. Before Promises were standardized in ES2015, libraries like Bluebird, Q, and RSVP had their own Promise implementations. Thenables ensured that Promises from different libraries could be chained together:
// A third-party library returns its own Promise-like object
let thirdPartyResult = thirdPartyLibrary.doWork();
// Even if it's not a native Promise, if it has .then(), it works
Promise.resolve(thirdPartyResult)
.then(result => {
console.log("Works with any thenable!");
});
The Detection Mechanism
JavaScript checks for thenables using duck typing: if the returned value is an object or function, and it has a then property that is a function, it is treated as a thenable:
// These are thenables (have a .then method):
let thenable1 = { then(resolve) { resolve(1); } };
let thenable2 = { then(resolve, reject) { reject(new Error("fail")); } };
// These are NOT thenables:
let notThenable1 = { then: 42 }; // then is not a function
let notThenable2 = { notThen() {} }; // no then property
let notThenable3 = "hello"; // primitive, not an object
In everyday development, you rarely create thenables manually. They exist primarily for library interoperability. You will almost always work with native Promise objects. However, understanding thenables explains why the chain mechanism is so flexible and why returning any "then-able" object from a .then() handler works correctly.
The Fetch API: A Real-World Promise Chain Example
The Fetch API is the most common real-world use of Promise chains. fetch() returns a Promise, and processing the response requires at least one additional step, making it a natural chain.
Basic Fetch Chain
fetch("https://jsonplaceholder.typicode.com/users/1")
.then(response => {
console.log("Status:", response.status); // 200
return response.json(); // Returns a Promise!
})
.then(user => {
console.log("User:", user.name); // "Leanne Graham"
console.log("Email:", user.email);
})
.catch(error => {
console.error("Fetch failed:", error.message);
});
Why Fetch Needs a Chain
fetch() resolves with a Response object, not the data itself. The Response object represents the HTTP response, including headers, status code, and a body stream. Reading the body requires calling a method (.json(), .text(), .blob(), etc.), which itself returns a Promise:
fetch("/api/data")
.then(response => {
// response is a Response object, NOT the JSON data
console.log(response.ok); // true/false
console.log(response.status); // 200
console.log(response.headers); // Headers object
// response.json() reads the body stream and parses JSON
// It returns a Promise because reading the stream is async
return response.json();
})
.then(data => {
// NOW we have the parsed JSON data
console.log(data);
});
Handling HTTP Errors
A common gotcha: fetch() only rejects on network failures (no internet, DNS error, etc.). HTTP error responses (404, 500, etc.) are not rejections. You must check response.ok or response.status manually:
fetch("https://jsonplaceholder.typicode.com/users/999")
.then(response => {
// fetch resolves even for 404!
if (!response.ok) {
throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
}
return response.json();
})
.then(user => {
console.log("User:", user.name);
})
.catch(error => {
// Catches both network errors AND our thrown HTTP errors
console.error("Error:", error.message);
});
Complete Real-World Example: User Dashboard
function loadDashboard(userId) {
console.log("Loading dashboard...");
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then(response => {
if (!response.ok) {
throw new Error(`User not found (${response.status})`);
}
return response.json();
})
.then(user => {
console.log(`Welcome, ${user.name}!`);
console.log(`Email: ${user.email}`);
console.log(`Company: ${user.company.name}`);
// Fetch the user's posts
return fetch(`https://jsonplaceholder.typicode.com/users/${userId}/posts`);
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load posts (${response.status})`);
}
return response.json();
})
.then(posts => {
console.log(`\nRecent posts (${posts.length} total):`);
posts.slice(0, 3).forEach(post => {
console.log(` - ${post.title}`);
});
// Fetch the user's todos
return fetch(`https://jsonplaceholder.typicode.com/users/${userId}/todos`);
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to load todos (${response.status})`);
}
return response.json();
})
.then(todos => {
let completed = todos.filter(t => t.completed).length;
console.log(`\nTodos: ${completed}/${todos.length} completed`);
})
.catch(error => {
console.error(`Dashboard error: ${error.message}`);
})
.finally(() => {
console.log("\nDashboard loading complete.");
});
}
loadDashboard(1);
Output:
Loading dashboard...
Welcome, Leanne Graham!
Email: Sincere@april.biz
Company: Romaguera-Crona
Recent posts (10 total):
- sunt aut facere repellat provident occaecati excepturi optio reprehenderit
- qui est esse
- ea molestias quasi exercitationem repellat qui ipsa sit aut
Todos: 11/20 completed
Dashboard loading complete.
Refactoring: Extracting a Helper
The repeated response-checking pattern can be extracted:
function fetchJSON(url) {
return fetch(url)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
});
}
// Clean chain using the helper
fetchJSON(`/api/users/${userId}`)
.then(user => {
console.log(`User: ${user.name}`);
return fetchJSON(`/api/users/${userId}/posts`);
})
.then(posts => {
console.log(`Posts: ${posts.length}`);
return fetchJSON(`/api/users/${userId}/todos`);
})
.then(todos => {
console.log(`Todos: ${todos.length}`);
})
.catch(error => {
console.error("Error:", error.message);
});
POST Request Chain
function createPost(title, body, userId) {
return fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ title, body, userId })
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to create post: ${response.status}`);
}
return response.json();
});
}
// Chain: create a post, then fetch it
createPost("My New Post", "This is the content.", 1)
.then(created => {
console.log(`Created post #${created.id}: "${created.title}"`);
return fetchJSON(`https://jsonplaceholder.typicode.com/posts/${created.id}`);
})
.then(fetched => {
console.log("Fetched back:", fetched.title);
})
.catch(error => {
console.error("Error:", error.message);
});
Building Long Chains Dynamically
Sometimes you need to build a chain programmatically, for example, processing an array of items sequentially:
Sequential Processing with reduce
let urls = [
"https://jsonplaceholder.typicode.com/users/1",
"https://jsonplaceholder.typicode.com/users/2",
"https://jsonplaceholder.typicode.com/users/3"
];
// Process URLs one at a time, in order
urls.reduce((chain, url) => {
return chain.then(() => {
return fetch(url)
.then(res => res.json())
.then(user => console.log(`Loaded: ${user.name}`));
});
}, Promise.resolve());
// Output (sequential, not parallel):
// Loaded: Leanne Graham
// Loaded: Ervin Howell
// Loaded: Clementine Bauch
How reduce Builds the Chain
The reduce starts with Promise.resolve() and each iteration appends another .then():
// The reduce effectively builds this chain:
Promise.resolve()
.then(() => fetch(urls[0]).then(res => res.json()).then(user => console.log(user.name)))
.then(() => fetch(urls[1]).then(res => res.json()).then(user => console.log(user.name)))
.then(() => fetch(urls[2]).then(res => res.json()).then(user => console.log(user.name)));
Collecting Results from Sequential Operations
let userIds = [1, 2, 3, 4, 5];
let results = userIds.reduce((chain, id) => {
return chain.then(accumulated => {
return fetchJSON(`https://jsonplaceholder.typicode.com/users/${id}`)
.then(user => [...accumulated, user.name]);
});
}, Promise.resolve([]));
results.then(names => {
console.log("All users:", names);
// ["Leanne Graham", "Ervin Howell", "Clementine Bauch", ...]
});
The reduce pattern processes items sequentially (one after another). If the operations are independent and can run simultaneously, use Promise.all() (covered in the Promise API article) for much better performance:
// Sequential: ~5 seconds total (1 second each)
// Parallel with Promise.all: ~1 second total (all at once)
let results = await Promise.all(
userIds.map(id => fetchJSON(`/api/users/${id}`))
);
Error Propagation Through Chains
Errors propagate through the chain until they reach a .catch(). Any .then() handlers between the error and the .catch() are skipped:
fetchJSON("/api/users/1")
.then(user => {
console.log("Step 1: Got user");
throw new Error("Something broke!"); // Error thrown here
})
.then(result => {
console.log("Step 2: This is SKIPPED"); // Never runs
})
.then(result => {
console.log("Step 3: This is SKIPPED too"); // Never runs
})
.catch(error => {
console.log("Caught:", error.message); // "Caught: Something broke!"
})
.then(() => {
console.log("Step 4: Chain continues after catch"); // Runs!
});
Output:
Step 1: Got user
Caught: Something broke!
Step 4: Chain continues after catch
The chain after .catch() continues normally because .catch() itself returns a new Promise (resolved with the value returned from the catch handler). Error handling in chains is covered in more depth in the next article.
Summary
- Every
.then()returns a new Promise. The value you return from a.then()handler becomes the fulfillment value of that new Promise, which the next.then()receives. - Returning a plain value from
.then()wraps it in a resolved Promise and passes it forward immediately. Returning a Promise from.then()causes the chain to wait for that Promise to settle before proceeding. - Forgetting to return a Promise from a
.then()handler is one of the most common bugs. The chain continues immediately withundefined, and the async operation runs disconnected. - Keep chains flat. Nesting
.then()inside.then()recreates the pyramid of doom. Return Promises from handlers and let the chain mechanism connect them linearly. - When a later step needs data from an earlier step, you can accumulate data in objects, use outer variables, or (best) use async/await.
- Thenables are objects with a
.then()method that follow the Promise convention. The chain mechanism treats them the same as native Promises, enabling interoperability between different Promise implementations. - The Fetch API is the most common real-world example of Promise chains.
fetch()returns a Promise resolving to aResponseobject, and reading the body (.json(),.text()) returns another Promise, naturally requiring a chain. fetch()does not reject on HTTP errors (404, 500). Always checkresponse.okand throw manually for error status codes.- Errors propagate through the chain, skipping
.then()handlers until they reach a.catch(). A single.catch()at the end handles errors from any step. - For sequential processing of arrays, use the
reducepattern withPromise.resolve()as the initial value. For parallel processing, usePromise.all().