こんにちは、かつコーチです。
前回はundefinedとnullの違いを解説しました。
今回のテーマは、多くの学習者が一度は必ずつまずく「非同期処理」です。
async/awaitやPromiseの書き方自体は覚えても、いざ実務で使うと「あれ、なんで順番通りに動かないんだろう」と混乱することがよくあります。
今回は非同期処理特有の罠と、その対処法を実例を交えて解説していきます。
非同期処理の基本をおさらい
同期処理と非同期処理の違い
同期処理は、コードを書いた順番に上から下へ実行される処理です。
一方非同期処理は、「時間のかかる処理」を裏で走らせつつ、その完了を待たずに次のコードを実行してしまう処理のことです。
console.log('1番目');
setTimeout(() => {
console.log('2番目(1秒後)');
}, 1000);
console.log('3番目');
// 出力順:1番目 → 3番目 → 2番目(1秒後)
コードの見た目上は「1番目→2番目→3番目」の順に見えますが、実際の出力は「1番目→3番目→2番目」になります。
setTimeoutの中身は「1秒後に実行してね」と予約されるだけで、その間もJavaScriptは次の行を先に実行してしまうためです。
Promiseとasync/awaitの関係
Promiseは「非同期処理の結果を後から受け取る箱」のようなものです。
function fetchUser() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ name: 'かつコーチ' });
}, 1000);
});
}
fetchUser().then((user) => {
console.log(user.name); // 1秒後に「かつコーチ」
});
async/awaitは、このPromiseをより同期処理っぽい見た目で書けるようにする糖衣構文です。
async function showUser() {
const user = await fetchUser();
console.log(user.name); // 1秒後に「かつコーチ」
}
showUser();
awaitは「このPromiseが完了するまで、この関数の中だけ処理を一時停止する」という意味になります。
罠1:forEachの中でawaitが効かない
かつコーチが実際にハマった話
私が実務で一番時間を溶かしたのが、forEachの中でawaitを使ったときの挙動です。
複数のユーザーIDを順番にAPIへ問い合わせて、結果を1つずつ処理したいというよくある場面でした。
❌ Before:forEachの中でawaitを使う
async function fetchUserById(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function showAllUsers(ids) {
console.log('開始');
ids.forEach(async (id) => {
const user = await fetchUserById(id);
console.log(user.name);
});
console.log('終了');
}
showAllUsers([1, 2, 3]);
// 出力順:開始 → 終了 → (バラバラのタイミングで)各ユーザー名
「開始→ユーザー名を順番に表示→終了」という順番になることを期待していたのに、実際には「開始→終了」が先に表示され、ユーザー名は後からバラバラの順番で出てきました。
原因は、forEachが渡されたコールバック関数の完了を待たない仕様になっているためです。
forEachは各要素に対してコールバックを呼び出すだけで、その中でawaitがあってもforEach自体はすぐに次のループへ進んでしまいます。
結果としてshowAllUsers関数は、中の非同期処理が終わるのを待たずに「終了」まで実行し切ってしまうのです。
✅ After:for…ofループを使ってawaitを正しく効かせる
async function fetchUserById(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
async function showAllUsers(ids) {
console.log('開始');
for (const id of ids) {
const user = await fetchUserById(id);
console.log(user.name);
}
console.log('終了');
}
showAllUsers([1, 2, 3]);
// 出力順:開始 → ユーザー1の名前 → ユーザー2の名前 → ユーザー3の名前 → 終了
for...ofループなら、ループ自体がasync関数の中にあるためawaitが正しく効き、1件ずつ処理が完了してから次のループに進みます。
「順番に1件ずつ処理したい」場面ではforEachではなくfor...ofを使う、と覚えておくとこの罠にはハマらなくなります。
並列で処理したい場合はPromise.allを使う
順番を気にせず、全部同時に処理を走らせて構わない場合はPromise.allを使うと高速です。
async function showAllUsersParallel(ids) {
console.log('開始');
const users = await Promise.all(ids.map((id) => fetchUserById(id)));
users.forEach((user) => console.log(user.name));
console.log('終了');
}
ids.mapで複数のPromiseをまとめて作り、Promise.allで全部の完了を待ってから次に進む、という書き方です。
順番を保証したいならfor...of、速度を優先するならPromise.all、と目的によって使い分けましょう。
罠2:try…catchでawaitのエラーを捕まえ忘れる
エラーがcatchされずに落ちてしまう例
awaitで発生したエラーは、try...catchで囲まないと関数の外まで伝播してしまいます。
❌ Before:try…catchで囲んでいない
async function fetchUserById(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error('ユーザー取得に失敗しました');
}
return res.json();
}
async function showUser(id) {
const user = await fetchUserById(id);
console.log(user.name);
}
showUser(999);
// Uncaught (in promise) Error: ユーザー取得に失敗しました
APIが404などのエラーを返した場合、throwされたエラーがどこにもキャッチされず、コンソールに「Uncaught (in promise)」という形で表示されてしまいます。
ユーザーには何のフィードバックも出せないまま、処理だけが止まってしまう状態です。
✅ After:try…catchでエラーハンドリングする
async function fetchUserById(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error('ユーザー取得に失敗しました');
}
return res.json();
}
async function showUser(id) {
try {
const user = await fetchUserById(id);
console.log(user.name);
} catch (error) {
console.error('エラーが発生しました:', error.message);
// 画面にエラーメッセージを表示するなどのフォローができる
}
}
showUser(999);
// エラーが発生しました: ユーザー取得に失敗しました
try...catchで囲むことで、エラー発生時に画面へメッセージを表示したり、再試行を促したりといった適切なフォローができるようになります。
async関数を書くときは、「エラーが起きる可能性のあるawaitには必ずtry...catchをセットで考える」という習慣をつけておくと安全です。
罠3:Promiseを返し忘れて呼び出し元でawaitできない
関数の中でawaitを忘れるパターン
自作の関数がPromiseを返しているつもりで、実はreturnし忘れているケースもよくあります。
async function fetchAndLog(id) {
fetchUserById(id).then((user) => {
console.log(user.name);
});
// returnしていないので、この関数はundefinedを返すのと同じ扱いになる
}
async function main() {
await fetchAndLog(1);
console.log('完了');
// fetchAndLogの中身が終わる前に「完了」が出てしまう
}
fetchAndLogの中で.thenを使って非同期処理を行っていますが、そのPromiseをreturnしていません。
そのため呼び出し元でawait fetchAndLog(1)としても、実際にはfetchAndLog関数自体の処理(何も返さない)が終わった時点ですぐに次へ進んでしまいます。
非同期処理を行う関数を自作するときは、その中のPromiseをきちんとreturnしているか、常に確認する癖をつけましょう。
まとめ
この記事のポイント
forEachの中でawaitを使っても待ってくれないため、順番に処理したいときはfor...ofを使う- 並列で処理して構わない場合は
Promise.allを使うと速度面で有利 awaitのエラーはtry...catchで囲まないと捕捉されずに落ちてしまう- 非同期処理を行う自作関数は、内部の
Promiseをreturnし忘れていないか確認する - 「順番を保証したいか」「速度を優先したいか」で書き方を使い分ける
次に読むべき記事
非同期処理の罠を理解したら、次は環境によって挙動が変わる「ブラウザ互換性」の考え方を見ていきます。
→ 次の記事:ブラウザ互換性対応の考え方