~/blog

Returning a promise from try skips your catch

published

#javascript#async#node

TL;DR

In an async function, return somePromise() inside a try block returns the promise without waiting for it. If it rejects, the rejection goes straight to the caller: your catch never runs, and your finally (or an await using disposal) runs before the work has finished. Write return await somePromise() inside try, inside a catch that has a finally, and inside a using scope. It costs nothing: MDN says return await is at least as fast as a bare return, and ESLint deprecated its no-return-await rule in v8.46.0 for that reason.

The problem

A fallback that never falls back:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function fetchUser(id) {
  await sleep(10);
  throw new Error(`user ${id} not found`);
}

async function getUserOrGuest(id) {
  try {
    return fetchUser(id);
  } catch (err) {
    console.log('catch ran:', err.message);
    return { id, name: 'guest' };
  }
}

try {
  console.log('result:', await getUserOrGuest(42));
} catch (err) {
  console.log('caller got:', err.message);
}
// caller got: user 42 not found

catch ran never prints, and the caller gets the raw error instead of the guest object. The try wraps the call to fetchUser, which succeeds and hands back a pending promise. It does not wrap the rejection, which happens 10 ms later.

The finally version is worse, because nothing fails loudly. Here a lock is released while the work it protects is still running:

const log = [];

async function slowQuery() {
  await sleep(20);
  log.push('query finished');
  return 'rows';
}

async function withLock(fn) {
  log.push('lock taken');
  try {
    return fn();
  } finally {
    log.push('lock released');
  }
}

await withLock(slowQuery);
console.log(log.join(' -> '));
// lock taken -> lock released -> query finished

Swap the lock for a database transaction, a pooled connection or a temp directory and you get intermittent “connection already released” errors, or cleanup that deletes files a request is still reading.

Every snippet in this post is an ES module (save it as .mjs) and builds on the ones before it; the outputs are copied from a run on Node 26.2.0.

Why it happens

A catch clause only sees an exception that is thrown while control is inside its try block. A rejected promise is not an exception until something awaits it.

return fn() evaluates fn(), gets a pending promise, and completes the return immediately. Control leaves the try, runs finally, and the async function is finished. Its own result promise then adopts the state of the promise you returned, so when that promise rejects later, the rejection lands in the caller’s hands. Nothing was ever thrown inside the function body.

return await fn() suspends the function inside the try. If the promise rejects, await throws at that line, where catch can handle it, and finally runs only after the promise has settled:

async function getUserOrGuestFixed(id) {
  try {
    return await fetchUser(id);
  } catch (err) {
    console.log('catch ran:', err.message);
    return { id, name: 'guest' };
  }
}

console.log('fixed:', await getUserOrGuestFixed(42));
// catch ran: user 42 not found
// fixed: { id: 42, name: 'guest' }

The same thing happens with explicit resource management. An await using declaration disposes its resource when the scope ends, and a bare return ends the scope before the returned promise settles:

function openConnection() {
  log.push('open');
  return {
    async query(sql) {
      await sleep(20);
      log.push(`query done: ${sql}`);
      return [];
    },
    async [Symbol.asyncDispose]() {
      log.push('closed');
    },
  };
}

async function listUsers() {
  await using conn = openConnection();
  return conn.query('select * from users');
}

log.length = 0;
await listUsers();
console.log(log.join(' -> '));
// open -> closed -> query done: select * from users

The connection is closed before the query it is running has finished. With return await conn.query(...) the order is open -> query done -> closed.

Where the missing await changes behaviour, and where it does not:

Where the return sitsreturn preturn await p
Inside try that has a catchRejection skips catch and goes to the callercatch handles it
Inside try that has a finallyfinally runs before p settlesfinally runs after p settles
Inside catch, when there is a finallyfinally runs before p settlesfinally runs after p settles
Inside a using or await using scopeResource is disposed before p settlesDisposed after p settles
Anywhere elseCaller gets the same resultCaller gets the same result

There is one more difference even in the last row: the stack trace. V8’s async stack traces are rebuilt from the chain of awaits, so a function that returns the promise without awaiting it drops out of the trace:

async function inner() {
  await sleep(1);
  throw new Error('boom');
}

async function outerNoAwait() {
  return inner();
}

async function outerAwait() {
  return await inner();
}

for (const fn of [outerNoAwait, outerAwait]) {
  try {
    await fn();
  } catch (err) {
    const frames = err.stack
      .split('\n')
      .slice(1)
      .map((line) => line.trim().replace(/ \(.*\)$/, ''))
      .filter((line) => !line.includes('file://'));
    console.log(fn.name, '->', frames.join(' | '));
  }
}
// outerNoAwait -> at inner
// outerAwait -> at inner | at async outerAwait

When the error comes from three helpers deep in a request handler, the version with await is the one that tells you which helper it was.

What to do

Write return await in every error-handling context: inside try, inside a catch that has a finally, and between a using declaration and the end of its block. If you would rather not think about where those are, write return await in every async function. MDN’s own conclusion is that “except for stylistic reasons, return await is almost always preferable.”

Turn off no-return-await if it is still in your config. It was deprecated in ESLint v8.46.0 with no replacement, and its own docs now say it is not recommended because return await “will not result in an extra microtask” and “yields a better stack trace”. The rule only ever allowed return await inside try blocks, so an old config that still enables it can push people to delete the await from a catch with a finally, which is exactly the case in the table above.

In TypeScript, let the linter find them. @typescript-eslint/return-await flags a missing await in error-handling contexts. Its default option, in-try-catch, requires await there and forbids it elsewhere; the strict configs use error-handling-correctness-only, which only enforces the error-handling side. It needs type information, because it has to know the returned value is a promise:

{
  "rules": {
    "@typescript-eslint/return-await": ["error", "in-try-catch"]
  }
}

A function that is not async cannot await, but it has the same problem: return fetchUser(id) inside a plain function’s try also escapes the catch. Either make the function async and await, or attach the handler to the promise itself with return fetchUser(id).catch(...).

Caveats

References