Knowledge Base/concepts/Why Async Functions Always Return a Promise

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 async changes what callers receive, so it is a real API change.
Share this article:
javascriptasyncpromisesfunctions