Have you ever debugged JavaScript code where a function says “Done!”, but the operation it depends on is still running?
Or perhaps an API request fails, but the error never reaches the catch() block you expected to handle it.
You inspect the code. You see .then(), another .then(), maybe a new Promise() inside another Promise and suddenly the execution flow becomes difficult to follow.
Sound familiar?
As JavaScript developers, we use Promises for API calls, database operations, file processing, authentication, background tasks and countless other asynchronous operations. Yet some of the bugs we encounter aren't caused by Promises themselves. They're caused by how we connect them.
Consider this example:
fetchUser()
.then((user) => {
saveUser(user);
})
.then(() => {
console.log("User saved successfully!");
});
Looks reasonable, right?
But what if saveUser() returns a Promise?
The success message can appear before the save operation finishes. Worse, if saving fails, the error might not reach the error handler you intended to use.
The problem isn't that Promises are outdated or that nested Promises are always bad. The problem is understanding which asynchronous operation the current chain is actually waiting for.
In this article, we'll work through these issues step by step:
- What really happens when a Promise is created inside another Promise?
- How does JavaScript execute synchronous code, Promise callbacks and timers?
- Why does forgetting
returnbreak a Promise chain? - How do nested Promises differ from normal Promise chaining?
- Does the same bug happen with
async/await? - When should you use Promises,
async/await,Promise.all(), or a background task? - How can you debug these problems in a real application?
Let's start with a bug we can reproduce.
1. First, let's reproduce the bug
Imagine you're building a user registration API.
After a user registers, your application needs to:
- Save the user's information.
- Send a welcome email.
- Confirm that registration has completed.
For simplicity, we'll simulate the database and email operations using Promises.
The buggy implementation
function saveUser() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("1. User saved to database");
resolve({ id: 101, name: "Fazal" });
}, 1000);
});
}
function sendWelcomeEmail(user) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`2. Welcome email sent to ${user.name}`);
resolve();
}, 2000);
});
}
function registerUser() {
return saveUser()
.then((user) => {
sendWelcomeEmail(user);
})
.then(() => {
console.log("3. Registration completed successfully");
});
}
registerUser();
What do you expect to happen?
A reasonable expectation is:
1. User saved to database
2. Welcome email sent to Fazal
3. Registration completed successfully
But the actual output is:
1. User saved to database
3. Registration completed successfully
2. Welcome email sent to Fazal
Wait. Why?
We called sendWelcomeEmail(user) before logging the success message. Shouldn't JavaScript wait for it?
No. Calling an asynchronous function that returns a Promise does not automatically make the surrounding code wait for that Promise.
Let's understand exactly where the execution flow goes wrong.
What JavaScript actually does
Look at this callback:
.then((user) => {
sendWelcomeEmail(user);
})
The function calls sendWelcomeEmail(user), which starts the email operation and returns a Promise.
But the callback doesn't return that Promise.
From the Promise chain's perspective, this callback returns undefined.
That means the next .then() doesn't need to wait for the email operation.
saveUser()
|
| Promise fulfills
v
First .then()
|
| Starts email operation
| Does NOT return its Promise
v
First .then() fulfills with undefined
|
v
Second .then()
|
| Runs immediately after the previous
| Promise reaction completes
v
"Registration completed successfully"
|
| Email operation is still running
v
"Welcome email sent to Fazal"
The exact wording matters here: the next .then() doesn't run synchronously inside the previous callback. It runs as a Promise reaction after the preceding chain Promise fulfills. But because the preceding callback returned undefined, that fulfillment doesn't depend on the email Promise.
This is the central bug.
The fix: return the Promise
function registerUser() {
return saveUser()
.then((user) => {
return sendWelcomeEmail(user);
})
.then(() => {
console.log("3. Registration completed successfully");
});
}
Now the output is:
1. User saved to database
2. Welcome email sent to Fazal
3. Registration completed successfully
We changed only one thing: we returned the inner Promise.
JavaScript can now connect the two asynchronous operations into one chain.
A shorter version works too:
function registerUser() {
return saveUser()
.then((user) => sendWelcomeEmail(user))
.then(() => {
console.log("3. Registration completed successfully");
});
}
When an arrow function has an expression body, that expression is returned implicitly. Here, the expression is the Promise returned by sendWelcomeEmail().
Keep this rule in mind throughout the article:
If the next step must wait for an asynchronous operation, make sure that operation's Promise is returned or awaited.
But why does returning a Promise work? To understand that, we need to look at how Promises actually behave.
2. What is a Promise, really?
A Promise represents the eventual outcome of an asynchronous operation.
It can be in one of three states:
- Pending: The operation hasn't settled yet.
- Fulfilled: The operation completed successfully.
- Rejected: The operation failed.
A Promise that has fulfilled or rejected is called settled.
For example:
const result = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("Data loaded");
}, 1000);
});
result.then((value) => {
console.log(value);
});
Output after approximately one second:
Data loaded
The Promise starts pending. After the timer callback runs, it fulfills with "Data loaded".
The .then() callback runs as a Promise reaction, rather than being invoked immediately by the call to resolve().
One important distinction: a Promise doesn't make an operation asynchronous by itself. The operation may involve a timer, network request, or other asynchronous API. A Promise provides a way to represent and compose its outcome.
Also, the Promise executor—the function passed to new Promise()—runs synchronously.
Let's verify that.
console.log("1. Before");
const promise = new Promise((resolve) => {
console.log("2. Inside executor");
resolve("Done");
});
promise.then((value) => {
console.log("4.", value);
});
console.log("3. After");
Output:
1. Before
2. Inside executor
3. After
4. Done
Why?
- JavaScript logs
"Before". - The Promise constructor immediately invokes its executor, so
"Inside executor"appears next. -
resolve("Done")fulfills the Promise, but its.then()reaction doesn't run inline. - JavaScript continues with the remaining synchronous code.
- The Promise reaction runs as a microtask.
This distinction becomes important when you see a Promise inside another Promise.
3. What happens when a Promise is inside another Promise?
There are two patterns that developers often confuse.
Pattern A: Returning a Promise from another Promise
const outerPromise = new Promise((resolve) => {
resolve(
new Promise((innerResolve) => {
setTimeout(() => {
innerResolve("Inner operation completed");
}, 1000);
})
);
});
outerPromise.then((result) => {
console.log(result);
});
Output after approximately one second:
Inner operation completed
Notice something important: outerPromise doesn't fulfill with the inner Promise object as its final value.
When a Promise is resolved with another Promise, it adopts the inner Promise's eventual state and outcome.
In this example:
- The outer Promise's executor starts synchronously.
- The inner Promise is created and its executor also starts synchronously.
-
resolve(innerPromise)resolves the outer Promise to follow the inner Promise. - The timer eventually fulfills the inner Promise.
- The outer Promise then fulfills with
"Inner operation completed". - The outer Promise's
.then()reaction receives that string.
Conceptually:
Outer Promise
|
| resolved with inner Promise
v
Inner Promise
|
| pending for 1 second
v
Fulfilled with a string
|
v
Outer Promise fulfills with the same outcome
|
v
.then() receives the string
This behavior is part of Promise resolution. It's why Promise chains can be composed without manually inspecting whether each returned value is itself a Promise.
Pattern B: Creating a Promise but not returning it
Now compare that with:
const outerPromise = new Promise((resolve) => {
new Promise((innerResolve) => {
setTimeout(() => {
innerResolve("Inner operation completed");
}, 1000);
});
resolve("Outer operation completed");
});
outerPromise.then((result) => {
console.log(result);
});
Output:
Outer operation completed
The inner Promise still runs. After approximately one second, it fulfills with "Inner operation completed", but nothing consumes that result.
The outer Promise doesn't wait for it because the inner Promise was neither returned from a relevant callback nor used to resolve the outer Promise.
This is a subtle but important distinction:
- Creating a Promise starts the executor and establishes an asynchronous result.
-
Returning a Promise from a
.then()callback connects its outcome to the chain. - Resolving a Promise with another Promise makes the first Promise adopt the second Promise's outcome.
These are related concepts, but they aren't interchangeable.
A complete execution-order example
Let's combine synchronous execution, nested Promise creation, a timer and .then().
console.log("0. Start");
function getUser() {
return new Promise((resolve) => {
console.log("1. Outer executor");
resolve(
new Promise((innerResolve) => {
console.log("2. Inner executor");
setTimeout(() => {
console.log("4. Inner timer");
innerResolve({ id: 1, name: "Fazal" });
}, 1000);
})
);
});
}
getUser().then((user) => {
console.log("5. User:", user.name);
});
console.log("3. End");
Output:
0. Start
1. Outer executor
2. Inner executor
3. End
4. Inner timer
5. User: Fazal
The two executor messages appear immediately because both executors run synchronously. The "End" message follows because JavaScript continues running synchronous code. The timer callback executes later and the .then() callback runs after the Promise has fulfilled.
You don't need to memorize this particular output. You need to understand which operations are synchronous, which callbacks are scheduled and which Promise outcomes are connected.
4. Nested .then() callbacks vs. Promise chaining
Let's return to the registration example.
A nested version might look like this:
function registerUser() {
return saveUser().then((user) => {
return sendWelcomeEmail(user).then(() => {
console.log("Email step completed");
return user;
});
});
}
registerUser().then((user) => {
console.log("Registration flow completed for", user.name);
});
This is valid JavaScript. The inner .then() returns a Promise and the outer callback returns that Promise. The whole chain waits for the email operation.
But nesting adds indentation and makes longer flows harder to follow.
We can express the same dependency using a flat chain:
function registerUser() {
let registeredUser;
return saveUser()
.then((user) => {
registeredUser = user;
return sendWelcomeEmail(user);
})
.then(() => {
console.log("Email step completed");
return registeredUser;
});
}
registerUser().then((user) => {
console.log("Registration flow completed for", user.name);
});
Both versions wait for the email operation.
The flat chain makes the sequence easier to scan, although it uses a variable to preserve the user value. In production, you should consider whether that extra state is necessary and whether it makes the flow clearer.
You can also preserve the user value without an outer variable:
function registerUser() {
return saveUser().then((user) => {
return sendWelcomeEmail(user).then(() => user);
});
}
Or, using async/await:
async function registerUser() {
const user = await saveUser();
await sendWelcomeEmail(user);
return user;
}
There isn't a universal rule that every nested .then() is wrong. Nesting is sometimes natural when a local operation has its own result handling. The problem is accidental nesting that hides dependencies, loses return values, or makes error handling confusing.
5. The missing return: a small mistake with several consequences
Let's examine the most common version of this bug more closely.
Case 1: The next step starts too early
function processOrder(order) {
return chargePayment(order)
.then((payment) => {
updateOrderStatus(order.id, payment);
})
.then(() => {
console.log("Order processing completed");
});
}
If updateOrderStatus() returns a Promise, the final callback doesn't wait for it.
Correct version:
function processOrder(order) {
return chargePayment(order)
.then((payment) => {
return updateOrderStatus(order.id, payment);
})
.then(() => {
console.log("Order processing completed");
});
}
Now the next step waits for the status update.
Case 2: Errors don't propagate through the expected chain
Suppose an inner operation rejects.
function processOrder(order) {
return chargePayment(order)
.then((payment) => {
updateOrderStatus(order.id, payment);
})
.catch((error) => {
console.error("Order processing failed:", error);
});
}
If updateOrderStatus() returns a Promise that rejects, the .catch() above won't catch that rejection through this chain because its Promise was never returned.
Correct version:
function processOrder(order) {
return chargePayment(order)
.then((payment) => {
return updateOrderStatus(order.id, payment);
})
.catch((error) => {
console.error("Order processing failed:", error);
throw error;
});
}
Notice the throw error.
Without it, the .catch() callback would fulfill the chain with undefined if it simply logged the error and returned nothing. Downstream code could then mistake the failure for success.
You should decide whether to rethrow, recover with a fallback, or handle the error and intentionally return a successful result. The right choice depends on the application.
Case 3: The caller receives a result too early
Imagine a function that should return the saved record:
function updateProfile(profile) {
return saveProfile(profile).then((savedProfile) => {
refreshSearchIndex(savedProfile);
});
}
The caller receives a Promise that fulfills with undefined after the callback finishes—not with the search-index operation's result.
If the caller expects to wait for indexing too, return its Promise:
function updateProfile(profile) {
return saveProfile(profile).then((savedProfile) => {
return refreshSearchIndex(savedProfile);
});
}
If the caller needs the saved profile as its result after indexing finishes:
function updateProfile(profile) {
return saveProfile(profile).then((savedProfile) => {
return refreshSearchIndex(savedProfile).then(() => savedProfile);
});
}
This makes the intended result explicit.
Important: Don't return a Promise just to make every operation sequential. Return it when the caller needs to wait for that operation or depend on its outcome. Independent work may be better handled concurrently or in the background.
6. Is this bug specific to Promises, or does it also happen with async/await?
This is an excellent question because async/await often makes asynchronous code easier to read, but it doesn't eliminate the underlying problem.
The bug is generic to asynchronous control flow. It happens whenever the program starts work but fails to connect that work to the sequence that depends on it.
Let's translate the registration example.
Incorrect async/await implementation
async function registerUser() {
const user = await saveUser();
sendWelcomeEmail(user);
console.log("Registration completed successfully");
}
registerUser();
The output can still be:
1. User saved to database
3. Registration completed successfully
2. Welcome email sent to Fazal
Why?
Because sendWelcomeEmail(user) starts the operation, but the function doesn't await its Promise.
The async keyword does not automatically wait for every Promise created inside the function.
Correct implementation
async function registerUser() {
const user = await saveUser();
await sendWelcomeEmail(user);
console.log("Registration completed successfully");
}
registerUser();
Now the function waits for the email operation before logging success.
The difference is not that async/await has magical asynchronous behavior. It provides syntax for expressing the dependency clearly.
Compare the two patterns:
// Promise chaining
return saveUser()
.then((user) => sendWelcomeEmail(user))
.then(() => {
console.log("Registration completed");
});
// async/await
async function registerUser() {
const user = await saveUser();
await sendWelcomeEmail(user);
console.log("Registration completed");
}
Both can represent the same sequence of operations.
A useful rule
If a later step must wait for an asynchronous operation:
- With Promise chaining, return the Promise from the callback.
- With
async/await, await the Promise.
The underlying principle is the same: explicitly connect the operation to the control flow that depends on it.
7. Error handling: .catch() vs. try/catch
Correct sequencing is only half the story. We also need to understand how failures propagate.
Promise-based error handling
function registerUser() {
return saveUser()
.then((user) => {
return sendWelcomeEmail(user);
})
.then(() => {
console.log("Registration completed");
})
.catch((error) => {
console.error("Registration failed:", error);
throw error;
});
}
A rejection from saveUser() or sendWelcomeEmail() can propagate to the final .catch() because each operation is connected to the chain.
If the email operation rejects, the success callback is skipped and the error handler runs.
The equivalent async/await approach
async function registerUser() {
try {
const user = await saveUser();
await sendWelcomeEmail(user);
console.log("Registration completed");
} catch (error) {
console.error("Registration failed:", error);
throw error;
}
}
The try/catch handles rejections from the awaited operations.
However, consider this:
async function registerUser() {
try {
const user = await saveUser();
sendWelcomeEmail(user);
console.log("Registration completed");
} catch (error) {
console.error("Registration failed:", error);
throw error;
}
}
If sendWelcomeEmail() returns a Promise that rejects, this catch won't catch that rejection through the try block because the Promise wasn't awaited. It may instead become an unhandled rejection, depending on what else handles it.
Correct:
async function registerUser() {
try {
const user = await saveUser();
await sendWelcomeEmail(user);
console.log("Registration completed");
} catch (error) {
console.error("Registration failed:", error);
throw error;
}
}
There is one additional nuance: try/catch catches synchronous exceptions thrown while executing the try block, as well as rejections from awaited Promises. It doesn't automatically catch failures from unrelated asynchronous operations that you start and leave unawaited.
That is why await matters for error handling as well as sequencing.
Should you always rethrow an error?
No.
Rethrow when the caller needs to know the operation failed. Recover when you have a meaningful fallback. Handle the error without rethrowing only when the function's contract says the failure has been dealt with and downstream code should continue.
For example, an optional analytics event might fail without preventing an order from being completed. A payment failure should not be silently swallowed.
Error handling should reflect the business requirement, not just the syntax.
8. Promise vs. async/await: which should you use?
This isn't a competition in which one syntax is always correct. async/await is built on Promises and is often easier to read for sequential workflows, while Promise methods remain valuable for composition and concurrency.
Here's a practical comparison.
Scenario A: Sequential operations
Suppose you need to fetch a user, then fetch that user's orders.
Promise chaining:
function getUserOrders(userId) {
return fetchUser(userId)
.then((user) => fetchOrders(user.id));
}
Using async/await:
async function getUserOrders(userId) {
const user = await fetchUser(userId);
const orders = await fetchOrders(user.id);
return orders;
}
Both are valid. The second version can be easier to follow when the workflow contains many steps, conditions, or intermediate variables.
Scenario B: Several independent operations
Suppose a dashboard needs user information, notifications and recommendations. None depends on the others.
This version unnecessarily runs the operations sequentially:
async function loadDashboard() {
const user = await fetchUser();
const notifications = await fetchNotifications();
const recommendations = await fetchRecommendations();
return { user, notifications, recommendations };
}
If each operation takes time, the total duration can approach the sum of their durations.
Instead, start the independent operations together:
async function loadDashboard() {
const [user, notifications, recommendations] =
await Promise.all([
fetchUser(),
fetchNotifications(),
fetchRecommendations()
]);
return { user, notifications, recommendations };
}
Promise.all() fulfills when all supplied Promises fulfill. If one rejects, the returned Promise rejects with that failure, though the other operations aren't automatically cancelled and may continue running.
This is a common example of using async/await and Promise composition together. You don't need to choose one or the other.
Scenario C: Independent operations where partial results matter
Suppose you're loading data from five optional integrations. One failing shouldn't prevent you from displaying the successful results.
Use Promise.allSettled():
async function loadIntegrations() {
const results = await Promise.allSettled([
fetchCalendar(),
fetchTasks(),
fetchNotifications()
]);
for (const result of results) {
if (result.status === "fulfilled") {
console.log("Loaded:", result.value);
} else {
console.error("Integration failed:", result.reason);
}
}
return results;
}
This waits for every supplied Promise to settle and gives you the outcome of each one.
Scenario D: You want a Promise chain without an async function
Sometimes a small function can be expressed naturally as a chain:
function getDisplayName(userId) {
return fetchUser(userId)
.then((user) => user.name)
.catch((error) => {
console.error("Unable to load user:", error);
throw error;
});
}
You don't have to introduce async/await just because it exists. Promise chaining is still useful, particularly when transforming results or composing functions.
A practical decision guide
| Situation | A useful approach |
|---|---|
| Several dependent steps |
async/await or a returned Promise chain |
| Several independent operations |
Promise.all() with async/await
|
| Need the outcome of every operation | Promise.allSettled() |
| Simple result transformation | Promise chaining |
| Complex branching and intermediate values | Often async/await
|
| Background work that shouldn't block the caller | Explicit background-task handling |
| Need to handle each operation's failure separately | Local try/catch or per-Promise .catch()
|
These are guidelines, not strict rules. Readability, error-handling requirements, cancellation needs and the behavior expected by the caller all matter.
9. One real-world decision: should the email block registration?
Our registration example assumes that registration isn't complete until the email has been sent.
But is that always the correct design?
Imagine the database save succeeds and the email provider takes five seconds to respond. Should the user wait five seconds before receiving a successful registration response?
Not necessarily.
This is where we must distinguish a JavaScript bug from a product or architecture decision.
Option A: Email is part of the required workflow
If the operation must finish before the caller can proceed, return or await the email Promise.
async function registerUser(input) {
const user = await saveUser(input);
await sendWelcomeEmail(user);
return user;
}
The caller receives the user only after the awaited email operation fulfills. If it rejects, the function rejects unless the error is handled.
Option B: Registration should succeed independently of email delivery
If the user should be registered even when the email provider is temporarily unavailable, don't pretend that the email is part of the same success condition.
A simple illustrative approach is:
async function registerUser(input) {
const user = await saveUser(input);
try {
await sendWelcomeEmail(user);
} catch (error) {
console.error("Welcome email failed:", error);
// Record or schedule a retry if the application supports it.
}
return user;
}
This waits for the email attempt but doesn't let its failure reject registration. It is not truly non-blocking, though, because the function still waits for the attempt to settle.
If email delivery should happen independently, a background job or durable queue is usually more appropriate:
Registration request
|
v
Save user + record email job
|
v
Return registration result
|
v
Background worker
|
v
Send email and retry if needed
In a production system, you would need to consider how the job is persisted, retried and monitored. A simple fire-and-forget call such as sendWelcomeEmail(user) is not a reliable substitute for a durable background-work mechanism.
This illustrates an important lesson: waiting for an operation, handling its failure and deciding whether it belongs in the critical path are three separate decisions.
10. How to debug confusing Promise execution flow
When asynchronous code behaves unexpectedly, don't start by rewriting everything with async/await. First establish which Promise represents which operation.
Here is a practical checklist.
Step 1: Add logs at the boundaries
function updateProfile(profile) {
console.log("Starting update");
return saveProfile(profile).then((savedProfile) => {
console.log("Profile saved");
return refreshSearchIndex(savedProfile);
}).then(() => {
console.log("Index refreshed");
});
}
Log before and after each important operation. This helps you see whether the unexpected order is caused by missing Promise composition or by the operation itself.
Step 2: Inspect every asynchronous callback
For every .then() callback, ask:
- Does this callback start another asynchronous operation?
- Does the next step need to wait for it?
- If yes, am I returning its Promise?
- Does this callback need to preserve a value for a later step?
For every async function, ask:
- Is the relevant operation awaited?
- Does the function return the value the caller expects?
- Is the rejection handled at the correct level?
Step 3: Inspect the returned value
A callback with braces doesn't implicitly return its contents:
.then((result) => {
transformResult(result);
})
This callback returns undefined unless it explicitly returns something.
Compare:
.then((result) => {
return transformResult(result);
})
And:
.then((result) => transformResult(result))
The latter two return the result of transformResult(), including its Promise if it returns one.
Step 4: Check rejection paths, not just successful execution
A chain may appear correct when every operation succeeds but fail when one rejects.
Test:
- The first operation rejects.
- An inner asynchronous operation rejects.
- A callback throws synchronously.
- A fallback handles an error.
- A caller expects a result but receives
undefined.
A successful manual test alone isn't enough to establish correct error propagation.
Step 5: Write down the intended dependency
Before changing code, express the desired workflow:
Save user
↓ must finish before
Send email
↓ must finish before
Return success
If the actual requirement is different, draw that flow instead. This helps distinguish a missing return or await from an incorrect assumption about which operations must be sequential.
11. Common misconceptions worth remembering
Misconception 1: Every Promise runs asynchronously.
The executor passed to new Promise() runs synchronously. Promise reactions, such as .then() callbacks, run asynchronously.
Misconception 2: Creating a Promise means the current function waits for it.
It doesn't. You must connect its outcome to the relevant Promise chain or await it.
Misconception 3: async automatically waits for all Promises inside a function.
It doesn't. async makes the function return a Promise. Individual asynchronous operations still need to be awaited or explicitly composed when their completion matters.
Misconception 4: Nested Promises are always bad code.
Not true. Resolving a Promise with another Promise and returning Promises from callbacks are normal parts of Promise composition. Deeply nested callbacks may hurt readability, but nesting itself isn't proof of a bug.
Misconception 5: Promise.all() cancels the other operations when one rejects.
It doesn't automatically cancel them. Its returned Promise rejects when one of its inputs rejects, but the remaining operations may continue.
Misconception 6: Converting everything to async/await fixes asynchronous bugs.
It can improve readability, but forgetting await creates the same fundamental problem as forgetting to return a Promise from a chain.
12. A final debugging challenge
Before finishing, predict the output of this example:
console.log("A");
Promise.resolve()
.then(() => {
console.log("B");
Promise.resolve().then(() => {
console.log("C");
});
})
.then(() => {
console.log("D");
});
console.log("E");
What order do you expect?
The correct output is:
A
E
B
C
D
Here's why:
-
Aruns synchronously. - The first
.then()callback is scheduled as a Promise reaction. -
Eruns before Promise reactions are processed. - The first callback runs and logs
B. It creates another Promise reaction but doesn't return that inner Promise. - The inner reaction logs
C. - The next callback in the outer chain logs
Dafter the first callback's returned value—undefined—fulfills the preceding chain Promise.
This example shows why you can't simply assume that all nested Promise callbacks execute before the next step in the outer chain. Their scheduling and Promise dependencies both matter.
Try modifying the example to return the inner Promise:
console.log("A");
Promise.resolve()
.then(() => {
console.log("B");
return Promise.resolve().then(() => {
console.log("C");
});
})
.then(() => {
console.log("D");
});
console.log("E");
The output is still:
A
E
B
C
D
The visible output happens to be the same in this example, but the Promise dependency is different. With the return, the outer chain explicitly waits for the inner Promise. Without it, the two branches are not connected, even though this particular output order looks identical.
That's an important debugging lesson: a log sequence alone doesn't always prove that asynchronous operations are correctly connected. Test the behavior and rejection paths, not just the happy-path output.
Conclusion
Promises aren't inherently complicated, but their behavior becomes much easier to understand once you distinguish creating an operation from connecting its outcome to the rest of your code.
If you take only a few lessons from this article, remember these:
-
Return a Promise from a
.then()callback when the chain must wait for that operation. -
Await a Promise inside an
asyncfunction when subsequent code depends on its outcome. - Propagate or handle errors intentionally instead of accidentally converting failures into successful results.
- Use
Promise.all()for independent operations that can run concurrently and choosePromise.allSettled()when you need every operation's outcome. - Don't assume every asynchronous task should block the caller. Decide whether it belongs in the critical path or should be handled by a background-work mechanism.
- Debug the dependency between operations, not just the order of console messages.
And remember: the same control-flow bug can appear with Promise chains and async/await. Changing syntax can make code easier to read, but it doesn't replace understanding the behavior you need.
The next time your application logs “Done” before the work is actually done, look for the missing return, the missing await, or an operation that was never connected to the flow in the first place.
That's often where the real bug begins.
đź’¬ Your turn: Have you ever encountered a missing return or await that caused a race condition or made an error disappear? Share the scenario in the comments. Real debugging experiences are often the best way for all of us to learn.
Top comments (0)