Asynchronous pitfalls: the confusing behaviors of Promise and async/await
Promises and async/await are core tools for modern JavaScript asynchronous programming, but some of their behaviors can often be confusing. From the priority of microtask queues to hidden pitfalls in error handling, these features conceal many counterintuitive details.
The "Immediate Execution" Illusion of Promise.resolve
console.log('Script start');
Promise.resolve().then(() => console.log('Promise 1'));
setTimeout(() => console.log('setTimeout'), 0);
console.log('Script end');
// Output order:
// Script start
// Script end
// Promise 1
// setTimeout
Promise callbacks are placed in the microtask queue, while setTimeout callbacks enter the macrotask queue. The event loop always empties the microtask queue before processing macrotasks, even if setTimeout's delay is set to 0. This priority difference can lead to unexpected execution orders.
More subtly, even if a Promise is already in a resolved state, its then
callback will still be executed asynchronously:
const p = Promise.resolve(42);
p.then(v => console.log(v)); // Still executes asynchronously
console.log('Synchronous code');
The Return Value Trap of Async Functions
Async functions always return Promises, but there are several special scenarios for return value handling:
async function foo() {
return 123; // Equivalent to return Promise.resolve(123)
}
async function bar() {
return Promise.resolve(456); // Note: This creates a double Promise
}
const result = bar();
result.then(v => console.log(v)); // Outputs 456, but undergoes two unwrappings
When returning a thenable object, the behavior becomes more complex:
async function baz() {
return {
then(resolve) {
resolve('Manual thenable');
}
};
}
baz().then(console.log); // Outputs "Manual thenable"
The "Black Hole" Phenomenon of Error Handling
Uncaught Promise rejections can lead to silent failures:
function riskyOperation() {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Failure')), 1000);
});
}
// No .catch handler
riskyOperation(); // The error will be silently swallowed
In Node.js environments, this may trigger the unhandledRejection
event. Browser consoles typically display warnings but won't interrupt program execution.
Error handling with async/await also has its own quirks:
async function fetchData() {
try {
const res = await fetch('invalid-url');
return await res.json();
} catch (e) {
console.log('Caught error:', e);
throw e; // If not rethrown, the caller won't know an error occurred
}
}
// The caller still needs to handle the error
fetchData().catch(e => console.log('External catch:', e));
Value Penetration in Promise Chains
Returning non-Promise values in then
callbacks leads to value penetration:
Promise.resolve(1)
.then(x => x + 1) // Returns 2
.then(x => { /* No return, equivalent to returning undefined */ })
.then(x => console.log(x)); // Outputs undefined
But if a Promise is returned in then
, it will wait for that Promise to resolve:
Promise.resolve(1)
.then(x => new Promise(r => setTimeout(() => r(x + 1), 1000)))
.then(console.log); // 2 (after 1 second)
Async/Await and Parallel Execution
A common misuse is over-serializing asynchronous operations:
// Inefficient approach
async function slowFetch() {
const a = await fetch('/api/a');
const b = await fetch('/api/b'); // Waits for a to complete before starting
return [a, b];
}
// Correct parallel approach
async function fastFetch() {
const aPromise = fetch('/api/a');
const bPromise = fetch('/api/b');
return Promise.all([aPromise, bPromise]);
}
But error handling in parallel execution requires special attention:
async function parallelWithError() {
try {
const [a, b] = await Promise.all([
fetch('/api/a'),
fetch('/api/b').then(() => Promise.reject('Intentional error'))
]);
} catch (e) {
console.log('Caught error:', e); // Triggered if any Promise rejects
}
}
Immediate Execution of Promise Constructors
The Promise constructor immediately executes the executor function:
console.log('Start');
new Promise(resolve => {
console.log('Inside executor');
resolve();
});
console.log('End');
// Output order:
// Start
// Inside executor
// End
This feature is often used to wrap callback APIs:
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
delay(1000).then(() => console.log('After 1 second'));
Multiple Then Callbacks Issue
A Promise can have multiple then
callbacks attached:
const p = Promise.resolve('Data');
p.then(v => console.log('Callback 1:', v));
p.then(v => console.log('Callback 2:', v)); // Also executes
But if a then
callback throws an error, it won't affect other callbacks:
const p = Promise.resolve('Safe');
p.then(() => { throw new Error('Boom!') });
p.then(() => console.log('Still executes')); // This line runs normally
Implicit Returns in Async Functions
An async function without a return
implicitly returns a Promise resolved to undefined
:
async function noReturn() {
// No return statement
}
noReturn().then(v => console.log(v)); // Outputs undefined
Even if the function throws an error, it returns a rejected Promise rather than throwing directly:
async function throwsError() {
throw new Error('Internal error');
}
// Needs to be caught at the call site
throwsError().catch(e => console.log('Caught:', e));
Early Termination Trap in Promise.race
Promise.race returns as soon as the first Promise settles, but other Promises continue executing:
function createTask(id, delay) {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Task ${id} completed`);
resolve(id);
}, delay);
});
}
Promise.race([
createTask(1, 1000),
createTask(2, 2000)
]).then(winner => console.log(`Winner: ${winner}`));
// Output:
// Task 1 completed
// Winner: 1
// Task 2 completed (Still executes)
Recursive Microtask Queue Explosion
Recursively adding microtasks can block the event loop:
function recursiveMicrotask(count = 0) {
if (count >= 100000) return;
Promise.resolve().then(() => {
console.log(`Microtask ${count}`);
recursiveMicrotask(count + 1);
});
}
recursiveMicrotask(); // May freeze the browser
In contrast, recursion with setTimeout is "friendly":
function recursiveMacrotask(count = 0) {
if (count >= 100) return;
setTimeout(() => {
console.log(`Macrotask ${count}`);
recursiveMacrotask(count + 1);
}, 0);
}
recursiveMacrotask(); // Executes in chunks
Awaiting Non-Promises
await
can wait for any value; non-Promise values are automatically wrapped:
async function awaitNonPromise() {
const v = await 42; // Equivalent to await Promise.resolve(42)
console.log(v); // 42
}
But awaiting thenable objects has special behavior:
async function awaitThenable() {
const v = await {
then(resolve) {
setTimeout(() => resolve('Custom then'), 100);
}
};
console.log(v); // 'Custom then' (after 100ms)
}
Special Behavior of Promise.allSettled
Unlike Promise.all, allSettled never rejects:
Promise.allSettled([
Promise.resolve('Success'),
Promise.reject('Failure')
]).then(results => {
console.log(results);
// [
// { status: "fulfilled", value: "Success" },
// { status: "rejected", reason: "Failure" }
// ]
});
Interaction with Async Generator Functions
Async generator functions combine async and generator features:
async function* asyncGen() {
yield await Promise.resolve(1);
yield await Promise.resolve(2);
}
(async () => {
for await (const num of asyncGen()) {
console.log(num); // 1, then 2
}
})();
This pattern is particularly useful for streaming data, but note that error propagation differs from regular async functions.
本站部分内容来自互联网,一切版权均归源网站或源作者所有。
如果侵犯了你的权益请来信告知我们删除。邮箱:cc@cccx.cn