async 関数の最後で return await fetchData() と書くと、「await は要らない、return fetchData() で同じ」と言われることがあります。ESLint にも、return await を警告する no-return-await というルールがあります(今は非推奨)。
結論から書くと、try / catch の外では、呼び出し元に届く値や例外は同じです(スタックトレースの出方は変わることがあります。Q4 で扱います)。一方、try の中では、catch と finally の動きが変わります。この記事では、Node.js v24.14.1 で実際に試した結果をもとに、どこで違いが出るのかを Q&A で整理します。コードは .mjs ファイルに保存して node ファイル名.mjs で実行しています(関数の外で await を使うため)。
Q1. try の外なら、本当に同じ?
呼び出し元に届く結果は同じでした。どちらも、失敗すると呼び出し元の catch に届きます。
const fail = () =>
new Promise((_, reject) => setTimeout(() => reject(new Error("取得失敗")), 10));
async function g1() { return fail(); }
async function g2() { return await fail(); }
try { await g1(); } catch (e) { console.log("g1:", e.message); } // g1: 取得失敗
try { await g2(); } catch (e) { console.log("g2:", e.message); } // g2: 取得失敗
Q2. try の中だと、何が変わる?
自分の関数の中の catch で拾えるかどうかが変わります。
// fail は Q1 と同じ
async function withoutAwait() {
try {
return fail();
} catch (e) {
return "catchで拾えた";
}
}
async function withAwait() {
try {
return await fail();
} catch (e) {
return "catchで拾えた";
}
}
console.log(await withAwait()); // catchで拾えた
await withoutAwait().catch((e) => console.log("例外:", e.message)); // 例外: 取得失敗
実行した結果です。
| 書き方 | 結果 |
|---|---|
return fail() |
中の catch を素通りして、呼び出し元に例外が届いた |
return await fail() |
中の catch で拾えた("catchで拾えた" が返った) |
try / catch が拾えるのは、try の中で投げられた例外だけです。Promise の失敗(reject)は、await したところで初めて例外になります。return fail() は Promise をそのまま返すだけで、try の中では何も投げないので、catch は動きません。await を付けると、try の中で失敗が例外になるので、catch で受け止められます。
失敗するタイミングの問題ではありません。返す時点ですでに失敗している Promise でも、同じく素通りしました。
async function bad() { throw new Error("すぐ失敗"); }
async function h() {
try {
return bad();
} catch {
return "catchで拾えた";
}
}
h().then((v) => console.log("値:", v), (e) => console.log("例外:", e.message));
// 例外: すぐ失敗
return Promise.reject(new Error("reject済み")) でも同じ結果でした。
なお、async の付いていない普通の関数がその場で throw した場合は、try の中で例外が投げられるので、await がなくても catch で拾えました。素通りするのは、Promise の失敗を await せずに返したときです。
「エラーのときは代わりの値を返す」つもりで try / catch を書いたのに、await がないせいで例外がそのまま上に飛ぶ、という形でバグになります。
Q3. finally はどう変わる?
実行される順番が変わりました。
const log = [];
const slow = () =>
new Promise((resolve) => setTimeout(() => { log.push("処理完了"); resolve("ok"); }, 10));
async function f1() { try { return slow(); } finally { log.push("finally"); } }
async function f2() { try { return await slow(); } finally { log.push("finally"); } }
await f1(); console.log(log); // [ 'finally', '処理完了' ]
log.length = 0;
await f2(); console.log(log); // [ '処理完了', 'finally' ]
| 書き方 | log の順番 |
|---|---|
return slow() |
finally → 処理完了 |
return await slow() |
処理完了 → finally |
finally で「読み込み中」の表示を消したり、接続を閉じたりしている場合、await がないと、処理が終わる前に片付けが走ります。保存中のボタンを finally で元に戻す書き方は 保存ボタンを2回押すと、データが2件登録される で扱いました。そこでも try の中で通信を await してから、finally でボタンを戻しています。await なしで Promise を返す形にすると、通信が終わる前にボタンが押せる状態に戻ります。
Q4. try の外なら、付けないほうが速い?
速さを理由に外す必要は、今はありません。ESLint の公式ドキュメントでは、no-return-await は使わないことを勧めています(ESLint v8.46.0 で非推奨)。理由として挙げられているのは次の2つです。
- 仕様の変更で、
return awaitによって余分な待ち(マイクロタスク)が増えることはなくなった -
return awaitのほうが、デバッグのときのスタックトレースが分かりやすい
スタックトレースの違いは、手元(Node.js v24.14.1)でも確かめられました。
async function inner() { await null; 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 (e) { console.log(e.stack); }
}
q4.mjs という名前で保存して実行したときに、実際に出た内容です(1つ目が outerNoAwait、2つ目が outerAwait。フォルダの場所は省略しています)。
Error: boom
at inner (q4.mjs:1:44)
Error: boom
at inner (q4.mjs:1:44)
at async outerAwait (q4.mjs:3:38)
at async q4.mjs:6:9
outerAwait 経由で失敗したときは、エラーのスタックトレースに at async outerAwait の行が出ました。outerNoAwait 経由のときは at inner の行だけで、outerNoAwait の名前は出ませんでした。どこから呼ばれて失敗したのかを追いたいときに、この差が効きます。
なお、inner の1行目の await null を消して、待たずにすぐ失敗させた場合は、どちらにも名前が出ました。差が出たのは、inner が一度待ってから失敗した場合です。
Node.js の機能が作るエラーでも試しました。inner の中身を return await fs.promises.readFile("存在しないファイル", "utf8") に変えた場合は、同じく outerAwait 経由のときだけ名前が出ました。一方、return await fetch("http://localhost:1") に変えて通信を失敗させた場合は、どちらにも名前は出ませんでした。いつも差が出るわけではありませんが、試した範囲では、await を付けたせいで名前が消えることはありませんでした。
Q5. じゃあ、どう書けばいい?
迷ったら、async 関数の中で Promise を返すときは return await と書いておけば、try の中でも外でも意図どおりに動きます。try の外だけで await を省く書き方も間違いではありませんが、あとで try で囲んだときに書き換え忘れると Q2 のバグになります。
TypeScript を使っている場合は、typescript-eslint の return-await ルールで、try の中で await が抜けているところを見つけられます。このルールは型の情報を使うので、typescript-eslint の型情報を使う設定で有効にします。
設定によって、try の外の扱いが変わります。
- 既定の
in-try-catch:tryの中ではawaitを付けるよう指摘し、tryの外のreturn awaitは外すよう指摘します -
always:tryの中でも外でも、awaitを付けるよう指摘します
この記事のおすすめ(迷ったら常に付ける)に合わせるなら always です。
AI が書いた async 関数を見るときの確認ポイント
-
tryの中でreturn 何か()のように、awaitなしで Promise を返していないか -
finallyで後片付けをしているのに、tryの中のreturnにawaitが付いていない、という組み合わせがないか -
catchで代わりの値を返すつもりの関数で、失敗したときに本当に代わりの値が返るかを、わざと失敗させて試したか
forEach の中の async 関数の失敗を try / catch で拾えない話は、AIが書いたtry-catchで囲んだ非同期処理、なぜUnhandledPromiseRejectionが出るのか に書きました。どちらも「Promise の失敗を try で拾えるのは、try の中で await したときだけ」という同じ仕組みです。
まとめ
-
try/catchの外では、return fail()とreturn await fail()で、呼び出し元に届く結果は同じだった -
tryの中では、awaitがないと中のcatchを素通りし(すでに失敗している Promise でも同じ)、finallyも処理より先に走った - ESLint は公式ドキュメントで、
no-return-awaitを v8.46.0 で非推奨にした理由として、return awaitによる追加の待ちが今はないことを挙げている -
return awaitのほうが、スタックトレースに呼び出し元の名前が残る場合があった(試した範囲では、付けたせいで名前が消えた例はなかった)
未経験者向けの講座を運営しています
未経験から Next.js + Supabase + Claude Code で Webアプリを公開するまで を、全24セッションで体系化した教材です。Claude Code を学習パートナーにする CLAUDE.md と学習モード(learner / developer)の設計までセットで含みます。
- ▶ ライブデモ(インストール不要・無料で今すぐ見られます)→ https://trial-web-ayies128s-projects.vercel.app/?ref=qiita
- 教材完全版+月5,500円のメンタリング(全24セッション+チャット質問し放題)→ https://menta.work/plan/20251?ref=qiita
- 無料体験版(git clone して自分の手元で動かす・最初の数セッション分・⭐ Star もよろしくお願いします)→ https://github.com/ayies128/next-ai-camp-trial
- YouTube『AIエンジニア情報局』(AI×開発ニュースを1本5分でキャッチアップできる別運営チャンネル・無料)→ https://www.youtube.com/channel/UC1rXVD9WYsQPQEWZyd-A1KA/?ref=qiita
※ 中上級者には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。