Why Async Functions Always Return a Promise
An async function always returns a Promise because async changes the function contract, not just the syntax inside the body. A plain return 42 becomes a fulfilled Promise for 42. A thrown error becomes a rejected Promise. That consistency is why callers must use await, .then(), or .catch().
The confusing part is that the inside of the function looks synchronous while the outside still sees a Promise.
The naive expectation
This function returns a string from inside the body:
async function getName() {
return "Ada";
}
So this feels reasonable:
const name = getName();
console.log(name.toUpperCase());
It fails:
TypeError: name.toUpperCase is not a function
name is not "Ada". It is a Promise that will eventually fulfill with "Ada".
const result = getName();
console.log(result instanceof Promise); // true
result.then((name) => {
console.log(name.toUpperCase()); // "ADA"
});
The returned value is wrapped for you.
async wraps returned values
Inside an async function, returning a non-Promise value fulfills the returned Promise with that value.
async function getScore() {
return 100;
}
getScore().then((score) => {
console.log(score); // 100
});
That function behaves like this non-async version:
function getScore() {
return Promise.resolve(100);
}
You do not need to write return Promise.resolve(value) inside an async function. It already does that wrapping. Adding it usually says "I have trust issues with syntax," which is understandable, but not useful.
Returning a Promise does not double-wrap it
If you return an existing Promise from an async function, JavaScript adopts that Promise's eventual result.
function fetchScore() {
return Promise.resolve(100);
}
async function getScore() {
return fetchScore();
}
const score = await getScore();
console.log(score); // 100
You do not get Promise<Promise<number>> as the value you await. The async function's returned Promise settles the same way the returned Promise settles.
This is why both versions are equivalent at the call site:
async function withReturn() {
return fetchScore();
}
async function withAwait() {
return await fetchScore();
}
In simple pass-through code, return fetchScore() is enough. Use return await when you need a local try/catch or cleanup behavior to run inside the async function.
Thrown errors become rejected Promises
Async functions also convert thrown errors into rejected Promises.
async function getUser() {
throw new Error("not authorized");
}
getUser().catch((error) => {
console.log(error.message); // "not authorized"
});
That is why this try/catch does not catch the error:
try {
getUser();
} catch (error) {
console.log("caught");
}
The function call returns a rejected Promise. The throw happened inside the async function's Promise flow, so the caller must handle the Promise:
try {
await getUser();
} catch (error) {
console.log("caught");
}
Or:
getUser().catch((error) => {
console.log("caught");
});
Same failure, different handling shape.
Calling async functions correctly
There are three normal ways to call an async function.
Use await inside another async function:
async function showName() {
const name = await getName();
console.log(name.toUpperCase());
}
Use .then() when you are already in Promise-chain style:
getName().then((name) => {
console.log(name.toUpperCase());
});
Use .catch() or try/catch with await for errors:
async function showUser() {
try {
const user = await getUser();
console.log(user.name);
} catch (error) {
console.log("Could not load user");
}
}
What you should not do is treat the returned Promise like the finished value:
const user = getUser();
console.log(user.name); // undefined
That line reads a name property from the Promise object, not from the user. The result is usually undefined, which then spreads through the code in the least dramatic and most annoying way possible.
await unwraps inside async functions only
await pauses the current async function. It does not pause the whole program.
async function run() {
console.log("before");
await Promise.resolve();
console.log("after");
}
run();
console.log("outside");
Output:
before
outside
after
The call to run() starts immediately and logs "before". At await, run pauses and returns a Promise to its caller. The caller keeps going, so "outside" logs. Then the continuation after await runs as a microtask.
For the queue mechanics behind that final step, see JavaScript Tasks vs Microtasks.
async changes the API surface
Adding async to a function is an API change. This version returns a number:
function calculateTotal() {
return 42;
}
This version returns a Promise for a number:
async function calculateTotal() {
return 42;
}
The inside looks nearly identical. The outside is not.
That matters when refactoring. If callers currently do this:
const total = calculateTotal();
renderTotal(total);
Changing calculateTotal to async means callers must change too:
const total = await calculateTotal();
renderTotal(total);
The keyword is small. The contract change is not.
Key Takeaways
- Every async function returns a Promise, even when it returns a plain value.
- Returning a value fulfills the Promise; throwing an error rejects it.
- Returning an existing Promise from an async function adopts that Promise's result.
- Call async functions with
await,.then(), and.catch(), not like synchronous functions. - Adding
asyncchanges what callers receive, so it is a real API change.