await を書いたのに、forEach を抜けた直後に次の処理が走った。原因はバグではなく仕様。Array.prototype.forEach はコールバックの戻り値を捨てるので、async 関数が返した Promise は誰にも待たれない。直すのは、順番が要るなら for...of、要らないなら Promise.all(items.map(fn)) に置き換える一行。
この記事は、なぜ待たれないのか・どう直すのか・map と filter に潜む同じ穴・try/catch をすり抜けてプロセスが落ちる挙動までを、Node.js v26 で実際に取った出力とセットで残す。同時実行数を絞らずに Promise.all へ寄せた場合の副作用も、50件で実測した数字を出す。対象は「forEach await 効かない」で検索した人。
[toc]
再現コード。forEach を抜けた後に保存が終わった
まず最小の再現から。3件を保存する処理で、それぞれ待ち時間を変えてある。
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function save(id, ms) {
await sleep(ms);
console.log(` saved: ${id} (${ms}ms)`);
return id;
}
const items = [
{ id: 'A', ms: 30 },
{ id: 'B', ms: 10 },
{ id: 'C', ms: 20 },
];
items.forEach(async (item) => {
await save(item.id, item.ms);
});
console.log(' done (forEach を抜けた直後)');
await が3箇所とも書いてある。素直に読めば A → B → C の順で保存され、最後に done が出そうに見える。実際の出力はこうなった。
--- forEach + async ---
done (forEach を抜けた直後)
saved: B (10ms)
saved: C (20ms)
saved: A (30ms)
done が先頭に来ている。保存が1件も終わっていない時点で、forEach の次の行に進んでいる。そのうえ保存の完了順は待ち時間の短い順、B → C → A になった。書いた順は A → B → C なのに。
自分がこれを踏んだのは、配列の件数が少ないうちは表面化しなかった。処理が速すぎて、次の行に進んだ頃には全部終わっていたから。件数と1件あたりの待ち時間が増えた瞬間に「保存したはずのデータが無い」で表面化する。原因の切り分けに数時間使った。
同じ処理を for...of に書き換えると、期待通りに動く。
--- for...of + await ---
saved: A (30ms)
saved: B (10ms)
saved: C (20ms)
done (for...of を抜けた直後)
原因は forEach がコールバックの戻り値を捨てること
async 関数は、中身が終わったかどうかに関係なく、呼ばれた瞬間に Promise を返す。await sleep(30) に到達したところで一旦呼び出し元へ制御が戻り、残りは後で実行される。
問題は、その返ってきた Promise を forEach が受け取らないこと。MDN の Array.prototype.forEach() には「callbackFn の返り値は破棄される」と書かれている。仕様側も同じで、ECMAScript の Array.prototype.forEach は各要素に対してコールバックを呼ぶだけで、その結果を保持する手続きを持たない。
つまり forEach から見ると、コールバックは呼んだ時点で「終わった」ことになる。実際には Promise が宙に浮いたまま3つ並走し、それぞれが自分の待ち時間だけ経ってから完了する。完了順が待ち時間の短い順になったのはこのため。
await は「この async 関数の中で待つ」という意味しか持たない。コールバック関数の内側で待っても、その外側にいる forEach には何も伝わらない。ここを「await と書けばどこであれ呼び出し元が待つ」と読んでいたのが、自分の勘違いだった。
直し方は2通り。順番が要るなら for...of、要らないなら Promise.all
直列に、書いた順で1件ずつ処理したいなら for...of を使う。for...of は言語構文なので、ループ本体の await は外側の async 関数に効く。
// 1件ずつ順番に。前の完了を待ってから次へ
export async function saveAll(items) {
const results = [];
for (const item of items) {
results.push(await save(item.id, item.ms));
}
return results;
}
順番が要らないなら map で Promise の配列を作り、Promise.all でまとめて待つ。こちらは全件を同時に走らせる。
// 全件を同時に開始し、全部終わるまで待つ
export async function saveAll(items) {
return await Promise.all(items.map((item) => save(item.id, item.ms)));
}
Promise.all は完了した順ではなく、渡した配列の順に結果を並べて返す。実行順序はバラバラでも、受け取る配列の並びは入力と一致する。5件・1件20msで測るとこうなった。
直列 for...of : 104ms
並列 Promise.all: 21ms
比 : 5.0倍
結果は同じか : true
件数ぶんの倍率で縮む。「結果は同じか」が true なので、並び順も一致している。相手側に順序の制約がなければ Promise.all を選ぶ理由は十分ある。逆に、前の結果を次の入力に使う処理や、書き込み順が意味を持つ処理では for...of しか選べない。
map と filter にも同じ穴がある
forEach だけの話ではない。map に async 関数を渡すと、返るのは値の配列ではなく Promise の配列になる。
const mapped = users.map(async (u) => await fetchOne(u));
console.log('map の戻り値の型 :', mapped[0].constructor.name);
console.log('中身をそのまま出す:', JSON.stringify(mapped));
console.log('await Promise.all :', await Promise.all(mapped));
map の戻り値の型 : Promise
中身をそのまま出す: [{},{},{},{},{}]
await Promise.all : [ 'A', 'B', 'C', 'D', 'E' ]
JSON.stringify すると [{},{},{},{},{}] になる。Promise には列挙可能な自前プロパティが無いので、空オブジェクトとして出力される。API のレスポンスに空オブジェクトの配列が並んだら、まずこれを疑っていい。map は Promise.all とセットで使う前提のメソッドだと考えておくと踏み外しにくい。
もっと静かに壊れるのが filter。filter はコールバックの戻り値を真偽値として見るが、Promise は常に truthy なので、条件が何であれ1件も落ちない。
const isActive = async (u) => { await sleep(1); return u.name === 'a'; };
const filtered = users.filter(async (u) => await isActive(u));
console.log('filter の残った件数:', filtered.length, '/', users.length);
console.log('Boolean(Promise) :', Boolean(Promise.resolve(false)));
filter の残った件数: 5 / 5
Boolean(Promise) : true
Promise.resolve(false) すら Boolean() に通すと true になる。5件中1件だけ残る条件を書いたのに5件とも残った。エラーも警告も出ない。非同期の条件で絞りたいときは、判定を先に済ませてから同期的に filter する。
// 判定を先に全部済ませてから、同期的に絞る
const flags = await Promise.all(users.map((u) => isActive(u)));
const actives = users.filter((_, i) => flags[i]);
例外が catch をすり抜けて、プロセスごと落ちた
順序が崩れるだけなら気づける。厄介なのは例外の扱い。forEach の中で投げた例外は、外側の try/catch に届かない。
async function boom(id) {
await sleep(5);
throw new Error(`failed: ${id}`);
}
try {
['x', 'y'].forEach(async (id) => { await boom(id); });
console.log(' try ブロックは素通りした');
} catch (e) {
console.log(' catch できた:', e.message);
}
実行するとこうなる。パスは長いので途中を省略した。
--- forEach の中で throw する ---
try ブロックは素通りした
Error: failed: x
at boom (.../exp3.js:5:9)
at runNextTicks (node:internal/process/task_queues:65:5)
at listOnTimeout (node:internal/timers:567:9)
at process.processTimers (node:internal/timers:541:7)
Node.js v26.0.0
catch は一度も呼ばれず、try ブロックを素通りしている。そのあと未処理の rejection としてプロセスが終了コード1で落ちた。Node.js は 15 以降、未処理の Promise 拒否をデフォルトで致命的エラーとして扱う。挙動は Node.js の --unhandled-rejections オプション で変えられるが、変えて握りつぶすのは別の事故を呼ぶ。
エラーハンドリングを書いたつもりで、実際には1行も効いていない状態になる。バッチ処理でこれをやると「ログには成功しか出ていないのに、途中でプロセスが消えている」という切り分けの難しい形になる。for...of に直せば、同じ例外がそのまま catch に届く。
--- for...of の中で throw する ---
catch できた: failed: x
1件の失敗で全部捨てないために allSettled
Promise.all に寄せると別の性質が付いてくる。1件でも reject すると全体が reject し、成功した分の結果は受け取れない。
const send = async (id) => {
await sleep(5);
if (id === 'B') throw new Error(`SMTP timeout: ${id}`);
return id;
};
try {
await Promise.all(['A', 'B', 'C'].map(send));
} catch (e) {
console.log('Promise.all の catch :', e.message);
}
Promise.all の catch : SMTP timeout: B
→ 成功した A と C の結果はどこにも残らない
A と C は送信済みなのに、その事実がコードのどこにも残らない。リトライを組むと A と C に二重送信する。件数と成否を1件ずつ知りたいなら Promise.allSettled を使う。
// 全件を待って、成否を1件ずつ受け取る
const r = await Promise.allSettled(['A', 'B', 'C'].map(send));
const ok = r.filter((x) => x.status === 'fulfilled').map((x) => x.value);
const ng = r.filter((x) => x.status === 'rejected').map((x) => x.reason.message);
console.log(`成功 ${ok.length}件 / 失敗 ${ng.length}件`, ok, ng);
allSettled の結果:
A: fulfilled value=A
B: rejected reason=SMTP timeout: B
C: fulfilled value=C
成功 2件 / 失敗 1件 [ 'A', 'C' ] [ 'SMTP timeout: B' ]
使い分けの軸は単純で、1件でも落ちたら処理全体を諦めるなら Promise.all、部分的な成功を保存してリトライ対象を絞りたいなら Promise.allSettled。仕様の詳細は MDN の Promise.allSettled() にある。
全部並列にすると今度は相手が落ちる
Promise.all は速い。ただし件数ぶんのリクエストを同時に投げる。50件を同時実行した場合の同時接続数のピークを測った。
let running = 0, peak = 0;
async function call(i) {
running++; peak = Math.max(peak, running); // 同時に走っている数を記録
await sleep(20);
running--;
return i;
}
Promise.all : 22ms 同時実行のピーク=50
mapWithLimit(5) : 211ms 同時実行のピーク=5
for...of 直列 : 1051ms 同時実行のピーク=1
順序は保たれたか : true
Promise.all は 1051ms → 22ms、約48倍速い。同時に50本の接続が立つ。相手が外部 API なら 429 が返るし、DB ならコネクションプールを使い切って他のリクエストが待たされる。速さと引き換えに、負荷を全部相手に渡している。
現実的な落とし所は、同時実行数の上限を決めること。上限5なら 211ms で、直列の 1051ms より5倍速く、ピークは50から5に下がる。依存を増やさないなら20行ほどで書ける。
// 同時実行数を limit 本に抑えつつ、結果は入力順で返す
export async function mapWithLimit(items, limit, fn) {
const results = new Array(items.length);
let cursor = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (cursor < items.length) {
const i = cursor++; // 添字を先に確保してから await する
results[i] = await fn(items[i], i);
}
},
);
await Promise.all(workers);
return results;
}
ワーカーを limit 本だけ起動し、各ワーカーが空いた順に次の要素を取る。添字を await の前に確保しているので、結果配列は入力順のまま埋まる。実測でも「順序は保たれたか : true」になった。ライブラリを入れてよいなら p-limit が同じことをする。自前実装は依存が増えないかわりに、リトライやタイムアウトは自分で足すことになる。
ESLint で書けなくする
直したところで、次に書くときにまた同じ手が動く。TypeScript でも型エラーにはならない。型情報を使う lint ルールなら検出できる。@typescript-eslint/no-misused-promises を有効にして、冒頭の壊れたコードをかけてみた。
export async function run(ids: string[]): Promise<void> {
ids.forEach(async (id) => {
await save(id);
});
}
8:8 error Async function 'run' has no 'await' expression @typescript-eslint/require-await
9:15 error Promise returned in function argument where a void return was expected @typescript-eslint/no-misused-promises
✖ 2 problems (2 errors, 0 warnings)
2件とも刺さった。「void を返すはずの関数の引数に Promise を返す関数を渡している」が、まさに今回の状態。設定はこう。型情報が要るので projectService を有効にする。
import tseslint from 'typescript-eslint';
export default tseslint.config(
...tseslint.configs.recommendedTypeChecked,
{
languageOptions: {
// 型情報を使うルールに必要
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: true }],
'@typescript-eslint/no-floating-promises': 'error',
},
},
);
Promise.all に直したファイルを同じ設定でかけると、違反は0件になった。検証環境は eslint 10.8.0 / typescript-eslint 8.66.0 / TypeScript 6.0.3。ルールの詳細は typescript-eslint の no-misused-promises にある。
ついでに検討されがちな ESLint の no-await-in-loop は、今回の対策としては使わないほうがいい。このルールは for...of の中の await を警告するが、順序が必要で意図的に直列にしている場合まで潰してしまう。並列化の見落としを拾いたい目的なら warn で入れる価値はあるが、error にすると正しいコードを書き換える圧力になる。
まとめとアクションリスト
await は「その async 関数の中で待つ」だけの構文で、呼び出し元に待たせる力は持たない。forEach はコールバックの戻り値を捨てるので、渡した Promise は宙に浮く。順序が要るなら for...of、要らないなら Promise.all(items.map(fn))、部分的な成功を残したいなら Promise.allSettled、相手に負荷をかけたくないなら同時実行数の上限を決める。この4択で足りる。
自分が一番危ないと感じたのは、順序が崩れることより例外が catch に届かないこと。ログには何も出ず、プロセスだけが静かに落ちる。
今日やること(10分)
-
grep -rn "forEach(async" src/とgrep -rn "filter(async" src/を実行して、該当箇所を洗い出す - 見つかった
forEach(asyncを、順序が要るならfor (const x of xs)に、要らないならawait Promise.all(xs.map(fn))に置き換える -
node -e "['a','b'].forEach(async x => { await new Promise(r=>setTimeout(r,10)); console.log(x) }); console.log('done')"を実行し、done が先に出ることを自分の目で確認する
今週やること
-
npm i -D typescript-eslintを入れ、@typescript-eslint/no-misused-promisesとno-floating-promisesを error で有効にして CI に通す - 外部 API を叩く
Promise.allの箇所を洗い、件数が可変ならmapWithLimit(items, 5, fn)を挟んで同時接続数の上限を決める - リトライ処理がある箇所の
Promise.allをPromise.allSettledに変え、成功分を保存してから失敗分だけ再送する形にする
今月やること
- バッチの実行ログに「開始件数」と「完了件数」を両方出し、数が合わない回を検知できるようにする
- MDN の Promise.all() と Promise.allSettled() を読み直し、reject 時の戻り値の違いをチームの設計ガイドに1行残す