What is .then()?
The .then() method is the foundation of asynchronous programming in JavaScript, enabling developers to handle promise-based operations with clean, chainable code. Understanding how .then() works is essential for writing modern JavaScript applications, from simple API calls to complex async workflows in frameworks like Next.js.
When you call .then() on a Promise, you are essentially subscribing to the eventual outcome of that asynchronous operation. The method schedules your callback functions to run when the promise settles--whether it fulfills successfully or gets rejected. This approach eliminates the need for deeply nested callback functions, a pattern historically known as "callback hell" that made code difficult to read and maintain.
The fundamental innovation of .then() is its return value: a new Promise that resolves based on what your callback returns. This creates the foundation for promise chaining, where multiple asynchronous operations can be composed into sequential workflows. Each link in the chain passes its result to the next, enabling clean, linear code that reads like synchronous operations.
For developers working with JavaScript fundamentals, mastering .then() is a critical skill that unlocks the full potential of asynchronous programming patterns.
Syntax and Parameters
The .then() method accepts two optional parameters that define how to handle the promise's outcome. Understanding both parameters is crucial for writing robust asynchronous code.
onFulfilled Callback
The first parameter is a function that executes when the promise becomes fulfilled. This callback receives the fulfillment value as its argument and determines what the returned promise will resolve to. If the callback returns a value, the returned promise gets fulfilled with that value. If the callback doesn't return anything, the returned promise fulfills with undefined. If the callback throws an error, the returned promise rejects with that error.
The onFulfilled callback receives a single argument: the value that the promise was fulfilled with. This is typically the result of your asynchronous operation--whether it's data from an API call, a file's contents, or any value your async function produces.
onRejected Callback
The second parameter handles rejection cases. This callback receives the rejection reason (typically an Error object) and, like the fulfillment handler, its return value determines the next promise's state. If this parameter is omitted, rejection reasons propagate down the chain until caught by a .catch() handler.
Return Value Behavior
The .then() method always returns a new Promise, even if no callbacks are provided. This returned promise is immediately in a pending state and will settle based on your callback's execution. This behavior is what enables chaining--each .then() creates a new promise that depends on the previous one's outcome. Understanding the bind method can also help when working with callback contexts in promise chains.
1fetch('/api/data')2 .then(3 response => response.json(), // onFulfilled4 error => { // onRejected5 console.error('Fetch failed:', error);6 throw error;7 }8 )9 .then(data => console.log(data));Promise Chaining
One of the most powerful features of .then() is its ability to chain multiple asynchronous operations. Each .then() call returns a new promise, allowing you to attach another .then() that will execute when the previous operation completes. This eliminates callback hell and produces linear, readable code.
Consider the difference between nested callbacks and promise chaining. With nested callbacks, each operation is indented further than the last, making the code structure difficult to follow. With promise chaining, each operation appears on a new line at the same indentation level, clearly showing the sequence of operations.
The key to successful chaining is returning promises from your callbacks. When you return a promise from a .then() callback, the next .then() in the chain waits for that promise to settle before executing. This implicit waiting is what enables sequential async operations without nested callbacks. Mastery of promise chaining is essential for building robust async workflows in modern web applications.
1// Bad: Nested callbacks create callback hell2fetchUser(userId)3 .then(user => {4 fetchPosts(user.id)5 .then(posts => {6 fetchComments(posts[0].id)7 .then(comments => console.log(comments));8 });9 });10 11// Good: Chaining produces clean, readable code12fetchUser(userId)13 .then(user => fetchPosts(user.id))14 .then(posts => fetchComments(posts[0].id))15 .then(comments => console.log(comments))16 .catch(error => console.error(error));Error Handling
Proper error handling with promises follows the reject path through your chain until caught by a .catch() handler. Understanding this propagation mechanism is essential for writing reliable async code.
The Catch Method
The .catch() method is syntactic sugar for .then(null, handler), providing a cleaner way to handle rejections anywhere in your chain. Any error or rejection in a preceding .then() will bubble down the chain until caught by a .catch(). This means you can place a single .catch() at the end of your chain to handle errors from any preceding operation.
Error Propagation in Chains
Errors propagate through promise chains until caught. If a .then() callback throws an error, the next .catch() in the chain receives it. Similarly, if a promise in the chain rejects, subsequent .then() calls are skipped until a .catch() is found.
Best Practice: Always Handle Rejections
One of the most common mistakes in promise-based code is forgetting to handle rejections. An unhandled rejection can cause silent failures that are difficult to debug. Modern JavaScript environments will log warnings for unhandled rejections, but it's best practice to explicitly handle all promise rejections. Always attach .catch() handlers to your promise chains, especially at the top level of your application.
1// Bad: Unhandled rejection2fetchData().then(data => console.log(data));3 4// Good: Proper error handling5fetchData()6 .then(data => console.log(data))7 .catch(error => console.error('Fetch failed:', error));8 9// Centralized error handling in chains10fetchData()11 .then(processResult)12 .then(saveToDatabase)13 .then(updateUI)14 .catch(error => {15 console.error('Operation failed:', error);16 showErrorMessage(error);17 });Follow these patterns to write clean, maintainable promise-based code
Return Promises, Don't Create Them
If a function already returns a promise, simply return it rather than creating a wrapper with new Promise(). This avoids unnecessary complexity and potential anti-patterns.
Always Return in Chain Callbacks
Forgetting to return a promise from a .then() callback breaks the chain. The next .then() executes immediately with undefined rather than waiting for the async operation.
Use Promise.all() for Parallel Operations
When multiple async operations can run simultaneously, use Promise.all() to execute them in parallel rather than sequentially. This significantly improves performance for independent operations.
Use Promise.allSettled() for All Results
When you need results from all promises regardless of failures, use Promise.allSettled(). Unlike Promise.all(), it doesn't reject if any promise rejects.
Common Mistakes
Understanding common pitfalls helps you avoid them in your own code. These mistakes are frequently encountered even by experienced JavaScript developers.
1. Forgetting to Return in Chain Callbacks
The most common mistake in promise chains is forgetting to return promises from callbacks. Without the return statement, the next .then() executes immediately with undefined, breaking the expected sequential behavior. This error is particularly insidious because the code appears to work--it runs without throwing errors--but produces incorrect results.
2. Mixing Async and Sync Code
Blocking synchronous code inside async operations defeats the purpose of promises. Ensure that all async operations use their promise-returning counterparts rather than synchronous methods. For example, use fs.promises.readFile() instead of fs.readFileSync() in Node.js applications.
3. Not Handling Rejections
Unhandled promise rejections are silent failures that can leave your application in an inconsistent state. Always attach .catch() handlers to your promise chains, especially at the top level of your application. Modern Node.js and browsers will emit warnings for unhandled rejections, but don't rely on these warnings--explicit error handling is the professional approach.
4. Creating Promises When Not Needed
Wrapping non-promise APIs in the Promise constructor when a simpler approach exists adds unnecessary complexity. Modern APIs like fetch already return promises, and utility libraries like Node's util.promisify() can convert callback-based functions without manual wrapping.
1// Bad: Missing return2promise1()3 .then(result => {4 promise2(result); // Missing return!5 })6 .then(result => console.log(result)); // undefined7 8// Good: Return the promise9promise1()10 .then(result => return promise2(result))11 .then(result => console.log(result));12 13// Bad: Blocking synchronous operation14async function badExample() {15 const data = fs.readFileSync('file.txt'); // Blocks!16 return data;17}18 19// Good: Non-blocking async operation20async function goodExample() {21 const data = await fs.promises.readFile('file.txt');22 return data;23}Performance Considerations
Promise chains have specific performance characteristics that matter for high-throughput applications. Understanding these helps you optimize your async code for better performance in your web applications.
Microtask Queue Execution
When a promise settles, its .then() callbacks are added to the microtask queue, which executes before the next event loop iteration. This behavior ensures that promise callbacks run asynchronously even if the promise is already settled. This means .then() callbacks are guaranteed to run after the current synchronous code completes but before other event loop tasks like timers or rendering.
Memory Considerations
Each .then() call creates a new promise and stores callbacks internally. While this is generally fine for typical applications, extremely long chains (thousands of operations) can consume significant memory. For very long chains, consider breaking them into separate functions or using async/await for better readability.
Parallel vs Sequential Tradeoffs
Choosing between parallel and sequential execution depends on your use case. Sequential execution takes longer but ensures order and allows later operations to use results from earlier ones. Parallel execution with Promise.all is faster but requires operations to be independent.
For operations with dependencies, sequential execution is necessary. For independent operations like batch API calls, parallel execution with Promise.all provides significant performance benefits--reducing total execution time from 6 seconds to 2 seconds for three 2-second operations running in parallel.
1// Sequential (slower - 6 seconds total)2async function sequential() {3 const result1 = await operation1(); // 2 seconds4 const result2 = await operation2(); // 2 seconds5 const result3 = await operation3(); // 2 seconds6 return [result1, result2, result3];7}8 9// Parallel (faster - 2 seconds total)10async function parallel() {11 const [result1, result2, result3] = await Promise.all([12 operation1(), // All run simultaneously13 operation2(),14 operation3()15 ]);16 return [result1, result2, result3];17}Frequently Asked Questions
Sources
- MDN Web Docs - Promise.prototype.then() - Authoritative documentation covering syntax, parameters, return values, and behavioral specifications.
- JavaScript.info - Promise Basics - Comprehensive tutorial explaining promise states, consumers, and practical examples.
- JavaScript Promises in 2025: A Complete Guide - Modern best practices guide covering promise chaining and error handling.