1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

`await` を書いたのに、次の処理が先に走った — forEach は async 関数を待たない

1
Last updated at Posted at 2026-08-06

await を書いたのに、forEach を抜けた直後に次の処理が走った。原因はバグではなく仕様。Array.prototype.forEach はコールバックの戻り値を捨てるので、async 関数が返した Promise は誰にも待たれない。直すのは、順番が要るなら for...of、要らないなら Promise.all(items.map(fn)) に置き換える一行。

この記事は、なぜ待たれないのか・どう直すのか・mapfilter に潜む同じ穴・try/catch をすり抜けてプロセスが落ちる挙動までを、Node.js v26 で実際に取った出力とセットで残す。同時実行数を絞らずに Promise.all へ寄せた場合の副作用も、50件で実測した数字を出す。対象は「forEach await 効かない」で検索した人。

[toc]

再現コード。forEach を抜けた後に保存が終わった

まず最小の再現から。3件を保存する処理で、それぞれ待ち時間を変えてある。

foreach-async.js
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 の実行結果
--- 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 関数に効く。

fix-for-of.js
// 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 でまとめて待つ。こちらは全件を同時に走らせる。

fix-promise-all.js
// 全件を同時に開始し、全部終わるまで待つ
export async function saveAll(items) {
  return await Promise.all(items.map((item) => save(item.id, item.ms)));
}

Promise.all は完了した順ではなく、渡した配列の順に結果を並べて返す。実行順序はバラバラでも、受け取る配列の並びは入力と一致する。5件・1件20msで測るとこうなった。

直列と並列の実測(5件・各20ms)
直列 for...of : 104ms
並列 Promise.all: 21ms
比 : 5.0倍
結果は同じか : true

件数ぶんの倍率で縮む。「結果は同じか」が true なので、並び順も一致している。相手側に順序の制約がなければ Promise.all を選ぶ理由は十分ある。逆に、前の結果を次の入力に使う処理や、書き込み順が意味を持つ処理では for...of しか選べない。

map と filter にも同じ穴がある

forEach だけの話ではない。mapasync 関数を渡すと、返るのは値の配列ではなく Promise の配列になる。

map-returns-promises.js
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 のレスポンスに空オブジェクトの配列が並んだら、まずこれを疑っていい。mapPromise.all とセットで使う前提のメソッドだと考えておくと踏み外しにくい。

もっと静かに壊れるのが filterfilter はコールバックの戻り値を真偽値として見るが、Promise は常に truthy なので、条件が何であれ1件も落ちない。

filter-never-filters.js
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 する。

async-filter.js
// 判定を先に全部済ませてから、同期的に絞る
const flags = await Promise.all(users.map((u) => isActive(u)));
const actives = users.filter((_, i) => flags[i]);

例外が catch をすり抜けて、プロセスごと落ちた

順序が崩れるだけなら気づける。厄介なのは例外の扱い。forEach の中で投げた例外は、外側の try/catch に届かない。

error-escapes.js
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);
}

実行するとこうなる。パスは長いので途中を省略した。

実行結果(Node.js v26.0.0)
--- 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 なら catch できる
--- for...of の中で throw する ---
  catch できた: failed: x

1件の失敗で全部捨てないために allSettled

Promise.all に寄せると別の性質が付いてくる。1件でも reject すると全体が reject し、成功した分の結果は受け取れない。

all-vs-allsettled.js
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 を使う。

allsettled.js
// 全件を待って、成否を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件を同時実行した場合の同時接続数のピークを測った。

concurrency-peak.js
let running = 0, peak = 0;
async function call(i) {
  running++; peak = Math.max(peak, running);   // 同時に走っている数を記録
  await sleep(20);
  running--;
  return i;
}
50件での実測
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行ほどで書ける。

map-with-limit.js
// 同時実行数を 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 を有効にして、冒頭の壊れたコードをかけてみた。

src/bad.ts
export async function run(ids: string[]): Promise<void> {
  ids.forEach(async (id) => {
    await save(id);
  });
}
npx eslint src/bad.ts
  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 を有効にする。

eslint.config.js
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-promisesno-floating-promises を error で有効にして CI に通す
  • 外部 API を叩く Promise.all の箇所を洗い、件数が可変なら mapWithLimit(items, 5, fn) を挟んで同時接続数の上限を決める
  • リトライ処理がある箇所の Promise.allPromise.allSettled に変え、成功分を保存してから失敗分だけ再送する形にする

今月やること

  • バッチの実行ログに「開始件数」と「完了件数」を両方出し、数が合わない回を検知できるようにする
  • MDN の Promise.all()Promise.allSettled() を読み直し、reject 時の戻り値の違いをチームの設計ガイドに1行残す
1
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?