All articles
TypeScript

return await in TypeScript: When It Actually Matters

`return await` is not always redundant. Learn when it matters in TypeScript, why it changes try/catch behavior, and how it improves async debugging and stack traces.

Gareth Whitbey·Senior Engineer
·11 min read
Share:

Many developers were taught a simple rule: return await is always redundant. Remove it. Lint against it. Move on.

That advice is incomplete. In real TypeScript codebases, return await can change whether errors are caught locally, whether cleanup logic runs, and whether debugging gives useful stack traces. The difference between return somePromise() and return await somePromise() is not about style. In certain contexts, it is about correctness.

TL;DR

  • Outside certain contexts, return await is often neutral.
  • Inside try/catch, it can be essential — it lets the current function observe the rejection before exiting.
  • It can also improve stack traces and make async failures easier to debug.
  • Modern linting guidance is more nuanced than the old “never use return await” rule.

For the broader picture on how unhandled promises cause production failures, see Unhandled Promises in TypeScript: Why They Cause Real Production Bugs.

The old myth vs the modern reality

Older guidance treated return await as universally unnecessary. The reasoning: if an async function returns a Promise, adding await just unwraps and rewraps it, costing an extra microtick for no benefit.

That reasoning was never complete, and modern engine optimisations have made the performance argument irrelevant for real-world code. The important question is not “is it shorter?” but:

  • Does it change whether errors are caught locally?
  • Does it improve diagnostics when something fails?
  • Does it make the intent of the code clearer?

If the answer to any of those is yes, return await is not redundant. It is necessary.

What return await actually does

The mechanics are straightforward:

  • return promisepasses the Promise outward directly. The current async function exits immediately. Whatever happens to the Promise — fulfillment or rejection — happens after the function is already off the call stack.
  • return await promisewaits for the Promise to settle inside the current function. If it fulfills, the resolved value is returned. If it rejects, the rejection is thrown inside the current function's execution context.

This difference matters most when the current function has logic that needs to observe the rejection locally: a try/catch, cleanup code, logging, or metrics that should run before the error propagates.

The case where it really matters: inside try/catch

This is the most important scenario. If you return somePromise() inside a try block, the function exits before the Promise settles. The catch block will never observe a later rejection from that returned Promise.

Bad

async function loadUser(id: string) {
  try {
    return fetchUser(id);
  } catch (error) {
    logger.error("Failed to load user", { id, error });
    throw error;
  }
}

Why it fails: fetchUser(id) returns a Promise. The return statement passes that pending Promise outward and the function exits. If the Promise later rejects, the rejection happens outside the try/catch scope. The logger.error call never runs. The caller receives an unhandled rejection with no local logging.

Good

async function loadUser(id: string) {
  try {
    return await fetchUser(id);
  } catch (error) {
    logger.error("Failed to load user", { id, error });
    throw error;
  }
}

Why the fix works: adding await forces the Promise to settle inside the try block. If it rejects, the error is thrown into the current function where catch can observe it. Logging runs. Metrics fire. The caller receives a clean rethrow.

This is not a style choice

The difference between these two examples changes whether the catch block executes. That is a control-flow difference, not a formatting preference.

The second case: better stack traces

When a Promise is returned directly and rejects later, the current function may not appear in the async stack trace because it has already exited by the time the rejection occurs.

// Direct return — function may be absent from the trace
async function getOrder(id: string) {
  return fetchOrder(id);
}

// With await — function stays on the stack at rejection time
async function getOrder(id: string) {
  return await fetchOrder(id);
}

When the second version rejects, getOrder is on the call stack at the point of rejection. Debugging tools and error reporting services can show the full async call path, making it easier to trace the failure back to the caller.

The exact improvement depends on the runtime and its async stack trace implementation. V8-based engines (Node.js, Chrome) have invested heavily in async stack traces, and keeping functions on the stack at rejection time gives them more to work with.

When return await is usually unnecessary

Not every async function needs return await. In simple pass-through wrappers with no local error handling, no cleanup, and no diagnostic requirements, direct return is fine:

async function getUser(id: string) {
  return fetchUser(id);
}

There is no try/catch. No cleanup logic. No logging that depends on observing the rejection locally. The function is just forwarding a Promise. Adding await here does not change correctness.

The key test: does the current function need to see the rejection? If not, direct return is acceptable. If yes — even for logging, metrics, or cleanup — use return await.

Decision table

SituationUse return await?Why
Inside try/catchYesLets the local catch observe the rejection
Function with local cleanup or metrics on errorYesCurrent function must see the error before propagating
Debugging-sensitive boundary with poor stack visibilityOften yesImproves async stack trace quality
Inside finally that depends on settlementYesEnsures finally runs after the Promise settles locally
Simple pass-through wrapper with no local logicUsually not requiredNo local handler benefits from observing the rejection
Async function returning without local handlingOptionalDepends on clarity preference and linting style

Linting and the modern rule

The @typescript-eslint/return-await rule replaces the older no-return-await from core ESLint. The old rule treated return await as universally unnecessary. The modern rule is smarter.

The most practical configuration is in-try-catch, which:

  • Requires return await inside try/catch/finally blocks, where it affects correctness.
  • Allows direct return elsewhere, where it is usually neutral.
// eslint.config.js (flat config)
import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.recommendedTypeChecked,
  {
    rules: {
      "@typescript-eslint/return-await": ["error", "in-try-catch"],
    },
  }
);

If your codebase still uses the old no-return-await rule from core ESLint, disable it and switch to the typescript-eslint version. The old rule does not understand try/catch context and will incorrectly flag code that needs return await for correctness.

Real-world examples where it matters

Service-layer function wrapping a third-party API

async function chargeCustomer(customerId: string, amount: number) {
  try {
    return await paymentProvider.charge(customerId, amount);
  } catch (error) {
    metrics.increment("payment_failure");
    logger.error("Charge failed", { customerId, amount, error });
    throw new PaymentError("Charge failed", { cause: error });
  }
}

Without await, the metrics and logging never run on failure. The caller receives a raw provider error instead of a clean PaymentError.

Loader function with centralised logging

async function loader({ params }: LoaderArgs) {
  try {
    return await db.query("SELECT * FROM posts WHERE id = $1", [params.id]);
  } catch (error) {
    logger.error("Loader query failed", { postId: params.id, error });
    throw new Response("Not Found", { status: 404 });
  }
}

The catch block converts a database error into a proper HTTP response. Without await, the caller would receive an unhandled database rejection instead of a 404.

Background job with cleanup

async function processJob(job: Job) {
  const lock = await acquireLock(job.id);
  try {
    return await executeJobLogic(job);
  } catch (error) {
    await markJobFailed(job.id, error);
    throw error;
  } finally {
    await lock.release();
  }
}

The lock must be released regardless of outcome. Without await, the finally block runs before the job Promise settles, potentially releasing the lock while the work is still in flight.

Common mistakes

  • Blindly removing all return await due to old lint advice. The old no-return-await rule does not understand try/catch context. Removing return await inside a catch boundary silently breaks error handling.
  • Assuming try/catch catches returned Promise rejections automatically. It does not. If you return a Promise without awaiting it, the function exits and the catch clause never fires for that rejection.
  • Using return await everywhere without thinking about intent. In simple pass-through wrappers, it adds noise. Apply it where it has a concrete effect on error handling, cleanup, or diagnostics.
  • Confusing readability debates with control-flow differences. Inside try/catch, the difference is not about style. It changes whether code executes. Treat it as a correctness issue, not a formatting one.

Automate the review

Style guides and lint rules catch many cases, but async control flow issues are easy to miss in large pull requests — especially when the try/catch and the return span different abstraction layers.

Run your code through CodeAva Code Audit to catch missing awaits, floating promises, and async patterns that look correct at a glance but fail under rejection. Automated analysis scales where manual review does not.

Practical checklist

Is this return inside a try/catch or try/finally?

Does this function need to log, transform, or re-wrap errors locally?

Would cleanup or metrics only run if the rejection is observed here?

Is stack trace quality important at this async boundary?

Is this just a pass-through wrapper with no local logic?

Does your lint configuration enforce return-await in try/catch contexts?

Conclusion

return await is not always redundant. Use it when the current function must observe a rejection locally:

  1. Inside try/catch— so the catch block can log, transform, or handle the error.
  2. At cleanup boundaries — so finally runs after the Promise settles.
  3. At debugging-sensitive paths— so the function appears in the async stack trace.

Skip it when there is no local handler, no cleanup, and no diagnostic benefit — simple pass-through wrappers where the caller handles everything.

Use CodeAva Code Audit to review async control flow across your codebase. Then read Unhandled Promises in TypeScript for the broader production-risk context on why floating promises are one of the most common sources of async failures.

#typescript#async-await#promises#error-handling#eslint#stack-traces#node-js#debugging

Frequently asked questions

More from Gareth Whitbey

Found this useful?

Share:

Want to audit your own project?

These articles are written by the same engineers who built CodeAva\u2019s audit engine.