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 awaitis 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 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
| Situation | Use return await? | Why |
|---|---|---|
Inside try/catch | Yes | Lets the local catch observe the rejection |
| Function with local cleanup or metrics on error | Yes | Current function must see the error before propagating |
| Debugging-sensitive boundary with poor stack visibility | Often yes | Improves async stack trace quality |
Inside finally that depends on settlement | Yes | Ensures finally runs after the Promise settles locally |
| Simple pass-through wrapper with no local logic | Usually not required | No local handler benefits from observing the rejection |
| Async function returning without local handling | Optional | Depends 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 awaitinsidetry/catch/finallyblocks, 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 awaitdue to old lint advice. The oldno-return-awaitrule does not understandtry/catchcontext. Removingreturn awaitinside a catch boundary silently breaks error handling. - Assuming
try/catchcatches returned Promise rejections automatically. It does not. If youreturna Promise without awaiting it, the function exits and the catch clause never fires for that rejection. - Using
return awaiteverywhere 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:
- Inside
try/catch— so the catch block can log, transform, or handle the error. - At cleanup boundaries — so
finallyruns after the Promise settles. - 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.



