Difference between `return await promise` and `return promise`
Solution 1:
Most of the time, there is no observable difference between return
and return await
. Both versions of delay1Second
have the exact same observable behavior (but depending on the implementation, the return await
version might use slightly more memory because an intermediate Promise
object might be created).
However, as @PitaJ pointed out, there is one case where there is a difference: if the return
or return await
is nested in a try
-catch
block. Consider this example
async function rejectionWithReturnAwait () {
try {
return await Promise.reject(new Error())
} catch (e) {
return 'Saved!'
}
}
async function rejectionWithReturn () {
try {
return Promise.reject(new Error())
} catch (e) {
return 'Saved!'
}
}
In the first version, the async function awaits the rejected promise before returning its result, which causes the rejection to be turned into an exception and the catch
clause to be reached; the function will thus return a promise resolving to the string "Saved!".
The second version of the function, however, does return the rejected promise directly without awaiting it within the async function, which means that the catch
case is not called and the caller gets the rejection instead.
Solution 2:
As other answers mentioned, there is likely a slight performance benefit when letting the promise bubble up by returning it directly — simply because you don’t have to await the result first and then wrap it with another promise again. However, no one has talked about tail call optimization yet.
Tail call optimization, or “proper tail calls”, is a technique that the interpreter uses to optimize the call stack. Currently, not many runtimes support it yet — even though it’s technically part of the ES6 Standard — but it’s possible support might be added in the future, so you can prepare for that by writing good code in the present.
In a nutshell, TCO (or PTC) optimizes the call stack by not opening a new frame for a function that is directly returned by another function. Instead, it reuses the same frame.
async function delay1Second() {
return delay(1000);
}
Since delay()
is directly returned by delay1Second()
, runtimes supporting PTC will first open a frame for delay1Second()
(the outer function), but then instead of opening another frame for delay()
(the inner function), it will just reuse the same frame that was opened for the outer function. This optimizes the stack because it can prevent a stack overflow (hehe) with very large recursive functions, e.g., fibonacci(5e+25)
. Essentially it becomes a loop, which is much faster.
PTC is only enabled when the inner function is directly returned. It’s not used when the result of the function is altered before it is returned, for example, if you had return (delay(1000) || null)
, or return await delay(1000)
.
But like I said, most runtimes and browsers don’t support PTC yet, so it probably doesn’t make a huge difference now, but it couldn’t hurt to future-proof your code.
Read more in this question: Node.js: Are there optimizations for tail calls in async functions?
Solution 3:
Noticeable difference: Promise rejection gets handled at different places
-
return somePromise
will pass somePromise to the call site, andawait
somePromise to settle at call site (if there is any). Therefore, if somePromise is rejected, it will not be handled by the local catch block, but the call site's catch block.
async function foo () {
try {
return Promise.reject();
} catch (e) {
console.log('IN');
}
}
(async function main () {
try {
let a = await foo();
} catch (e) {
console.log('OUT');
}
})();
// 'OUT'
-
return await somePromise
will first await somePromise to settle locally. Therefore, the value or Exception will first be handled locally. => Local catch block will be executed ifsomePromise
is rejected.
async function foo () {
try {
return await Promise.reject();
} catch (e) {
console.log('IN');
}
}
(async function main () {
try {
let a = await foo();
} catch (e) {
console.log('OUT');
}
})();
// 'IN'
Reason: return await Promise
awaits both locally and outside, return Promise
awaits only outside
Detailed Steps:
return Promise
async function delay1Second() {
return delay(1000);
}
- call
delay1Second()
;
const result = await delay1Second();
- Inside
delay1Second()
, functiondelay(1000)
returns a promise immediately with[[PromiseStatus]]: 'pending
. Let's call itdelayPromise
.
async function delay1Second() {
return delayPromise;
// delayPromise.[[PromiseStatus]]: 'pending'
// delayPromise.[[PromiseValue]]: undefined
}
- Async functions will wrap their return value inside
Promise.resolve()
(Source). Becausedelay1Second
is an async function, we have:
const result = await Promise.resolve(delayPromise);
// delayPromise.[[PromiseStatus]]: 'pending'
// delayPromise.[[PromiseValue]]: undefined
-
Promise.resolve(delayPromise)
returnsdelayPromise
without doing anything because the input is already a promise (see MDN Promise.resolve):
const result = await delayPromise;
// delayPromise.[[PromiseStatus]]: 'pending'
// delayPromise.[[PromiseValue]]: undefined
-
await
waits until thedelayPromise
is settled.
- IF
delayPromise
is fulfilled with PromiseValue=1:
const result = 1;
- ELSE is
delayPromise
is rejected:
// jump to catch block if there is any
return await Promise
async function delay1Second() {
return await delay(1000);
}
- call
delay1Second()
;
const result = await delay1Second();
- Inside
delay1Second()
, functiondelay(1000)
returns a promise immediately with[[PromiseStatus]]: 'pending
. Let's call itdelayPromise
.
async function delay1Second() {
return await delayPromise;
// delayPromise.[[PromiseStatus]]: 'pending'
// delayPromise.[[PromiseValue]]: undefined
}
- Local await will wait until
delayPromise
gets settled.
-
Case 1:
delayPromise
is fulfilled with PromiseValue=1:
async function delay1Second() {
return 1;
}
const result = await Promise.resolve(1); // let's call it "newPromise"
const result = await newPromise;
// newPromise.[[PromiseStatus]]: 'resolved'
// newPromise.[[PromiseValue]]: 1
const result = 1;
-
Case 2:
delayPromise
is rejected:
// jump to catch block inside `delay1Second` if there is any
// let's say a value -1 is returned in the end
const result = await Promise.resolve(-1); // call it newPromise
const result = await newPromise;
// newPromise.[[PromiseStatus]]: 'resolved'
// newPromise.[[PromiseValue]]: -1
const result = -1;
Glossary:
- Settle:
Promise.[[PromiseStatus]]
changes frompending
toresolved
orrejected
Solution 4:
This is a hard question to answer, because it depends in practice on how your transpiler (probably babel
) actually renders async/await
. The things that are clear regardless:
Both implementations should behave the same, though the first implementation may have one less
Promise
in the chain.Especially if you drop the unnecessary
await
, the second version would not require any extra code from the transpiler, while the first one does.
So from a code performance and debugging perspective, the second version is preferable, though only very slightly so, while the first version has a slight legibility benefit, in that it clearly indicates that it returns a promise.