JavaScriptのasync/await構文は、コールバック地獄から私たちを救ってくれるはずでした。しかし、その代わりに、より静かで、より厄介な問題をもたらしました。それは、見た目は正しいのに、予測不能な挙動をするコードです。関数内にawaitがあるのを見て、すべてが一行ずつ丁寧に一時停止すると思い込んでしまいがちです。しかし、実際にはそうならないことがよくあります。ループは先に進んでしまい、たった一つのリクエストの失敗によってバッチ全体が崩壊します。エントリーファイルには、理由もなく醜いasyncラッパーが蔓延します。もしこれらに直面したことがあるなら、これら3つのパターンが解決の糸口となるでしょう。
forEach 内での await の使用をやめる
一見すると無害に見える、よくある間違いがこちらです:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
これを実行すると、レスポンスが返ってくる前に 'All done!' が出力されてしまいます。なぜでしょうか?forEachは、すべての要素に対してコールバックを即座に実行するからです。各イテレーション内のPromiseを待機しません。asyncキーワードは各コールバックをPromiseに変えますが、forEachはそれをすぐに無視してしまいます。ループはマイクロ秒で終了し、ネットワークリクエストは勝手に動き出します。エラーを順番に処理する必要がある場合や、次のリクエストが始まる前に現在のリクエストが完了することを保証する必要がある場合、このパターンはその両方の保証を密かに破ってしまいます。
代わりに for...of ループを使用してください:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
これで、ループは各 await で確実に停止します。2番目のリクエストは1番目の完了を待ちます。'All done!' はすべてが完了した後にのみ出力されます。
順序が重要な場合(レート制限を守るためにファイルを一つずつアップロードする場合、特定の順序でデータベースの行を書き込む場合、または前のレスポンスのデータが必要なAPIコールを連鎖させる場合など)は、for...of を使用してください。もし並列実行を本当に望んでいるのであれば、forEach で試行錯誤してはいけません。意図を次の開発者に明確に伝えるために、明示的に Promise.all を使いましょう。しかし、同期的な挙動を期待して await と forEach を混ぜ合わせることは絶対にしないでください。そんなことは起こりません。
「ゼロ」が答えになり得ないときは Promise.allSettled を使う
Promise.all は意味論的に誠実です。Promiseの配列を渡すと、結果の配列を返します。問題は、どれか一つのPromiseがrejectされた瞬間に、全体が即座にrejectされることです。他の保留中のPromiseはそれぞれ勝手に終了しますが、それらの結果にはアクセスできなくなります。本番環境において、この「全か無か」の挙動は痛手となります。
あなたのアプリケーションが、トラフィック分析、収益データ、ユーザーフィードバック、サーバーの健全性という4つの独立したサービスからダッシュボードのウィジェットを取得していると想像してください。収益APIが一時的なタイムアウトを起こしました。Promise.all を使っていると、ダッシュボード全体がエラーを投げます。正常な3つのレスポンスは虚空へと消えてしまいます。データの4分の1が不具合を起こしただけで、ユーザーにはスピナーが表示された後にエラー画面が表示されることになります。
Promise.allSettled は、より理にかなった契約を提供します。これは、各Promiseがどのような結果になろうとも、すべてが完了するまで待機します。解決された値は、各結果を記述したオブジェクトの配列です:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
レスポンスが破棄されることはありません。表示できるものは表示し、失敗を切り離すことができます。このパターンは、一括通知、サードパーティのWebhook送信、複数のCSVストリームからのレコードのインポートなど、関連性のない操作を扱う際に重要です。中央集権的なエラー追跡は引き続き必要ですが、アプリケーションの動作自体は維持されます。
実用的な注意点として、allSettled はすべての結果を返すため、結果を精査して、その機能にとって「部分的な成功」が何を意味するかを決定する必要があります。返された配列を、すべてが成功したデータとして扱わないでください。ステート層にデータを投入する前に、必ず status フィールドを確認してください。
Top-level await を宣言して Wrapper IIFE を廃止する
長年、ファイルのルートで何かを await したい場合は、即時実行されるasync関数(IIFE)でラップする必要がありました:
(async () => {
const config = await loadConfig();
startServer(config);
})();
これでも動作はしますが、ノイズになります。ES modules でネイティブにサポートされている Top-level await を使えば、儀式のようなボイラープレートを排除できます:
const config = await loadConfig();
startServer(config);
アプリケーションのエントリーポイントや、他の何かが実行される前に初期化を完了させる必要がある専用の構成モジュールで使用してください。環境ファイルの読み込み、データベース接続プールの確立、リモートのフィーチャーフラグの取得などは、すべてこれに適しています。Top-level await はモジュールグラフの実行をブロックするため(このファイルをインポートする他のファイルは、Promiseが解決されるまで待機します)、保証された状態を得ることができます。コードベースの他の部分は、db をインポートするだけで、接続がすでに確立されていることを知ることができます。
注意点が2つあります。第一に、ランタイムまたはバンドラーが ES modules をサポートしている必要があります。Node.js の場合、.mjs 拡張子を使用するか、package.json で "type": "module" を設定することを意味します。第二に、モジュールレベルでの待機はすべてのインポーターを遅延させるため、await する処理は最小限に留めてください。頻繁にインポートされるユーティリティファイルの冒頭で重い逐次的なフェッチを行うと、アプリケーション全体のコールドスタートが遅くなります。トップレベル await は、他のモジュールが真に依存している、真のブートストラップ・タスクのために取っておきましょう。
これらのパターンを採用することで、実際に何が変わるのか
最初の恩恵は「予測可能性」です。for...of ループを読めば、その下のブロックがいつ終了するかが正確にわかります。背後で勝手に走る「ゴースト・プロミス」も、エラーハンドラーから切り離されてしまう foreach コールバックも存在しません。コントロールフローが、画面上のコードの見た目と一致するようになります。
次に「回復力(レジリエンス)」です。Promise.allSettled を使うことで、すべての外部システムが完璧であり続けることを期待するのではなく、部分的な失敗について考えるよう促されます。本番環境のソフトウェアは、二進法的(オール・オア・ナッシング)ではありません。エンドポイントが不安定になることもあれば、ファイルの読み込みで権限エラーが発生することもあります。点在する失敗という現実に即して設計することで、正当なデータを隠蔽することなく、アプリケーションを安定して稼働させ続けることができます。
そして、これらを一つにまとめるのが「明快さ」です。for...of は、まるで平易な英語の進行文のように読めます。allSettled はその名前自体に意図が示されています。トップレベル await は、難解な IIFE ラッパーを排除し、エントリーファイルが構文上の曲芸ではなく、ビジネスロジックから始まるようにします。6ヶ月後のあなたであれ、締め切りに追われているチームメイトであれ、次にそのファイルに触れるエンジニアは、あなたに感謝することでしょう。
実践的なまとめ
async/await を、既存のコードに振りかけるだけの「万能な解決策」として扱わないでください。現在のプロジェクトにおいて、これら3つの具体的なアンチパターンがないか監査してください。forEach ブロック内の await を探し、それを for...of または意図的な Promise.all に置き換えます。外部サービスと通信するすべての Promise.all を見直し、単一の失敗が本当にオペレーション全体を台無しにする必要があるのかを検討してください。もしそうでないなら、Promise.allSettled に切り替えて、混在する結果を適切に処理しましょう。最後に、ES モジュールのエントリーポイントから async IIFE を取り除き、トップレベル await にブートストラップ・シーケンスを直接処理させます。これらは些細な機械的な変更に過ぎませんが、これらを組み合わせることで、脆弱な非同期スクリプトを、真に信頼できるコードへと変えることができるのです。
