JavaScript

JavaScriptの非同期エラーハンドリングと並列実行|try/catch・Promise.all・allSettledの使い分け

JavaScriptの非同期エラーハンドリングと並列実行|try/catch・Promise.all・allSettledの使い分け

非同期処理の事故は、書き方ではなく制御の側で起きます。エラーが握りつぶされて成功扱いで進む、10件のAPI呼び出しを直列で回して10倍の時間がかかる、1件の失敗で全体が落ちる。ここでは例外の伝播、fetch の失敗判定、Promise.all と allSettled の選び分け、タイムアウトとリトライまでを実装コードで扱います。async/await の文法とTypeScriptの型付けは非同期関数の書き方|async宣言・await式・TypeScriptの型定義とdelay関数の実装に、非同期処理そのものの考え方は非同期処理とは?同期処理との違いから実装方式まで実装者目線で解説にまとめてあります。

まとめ|非同期のエラー処理と並列実行で押さえる要点

  • await を付け忘れたPromiseのrejectは try/catch では捕まりません。Node.js 15以降は既定でプロセスが落ちます。
  • fetch は404や500では例外になりません。res.ok を見て自分で分岐しない限り、エラーページのHTMLを正常な応答として処理してしまいます。
  • 全件成功が前提なら Promise.all、1件失敗しても残りの結果が要るなら Promise.allSettled です。
  • for ループの中の await は直列実行です。所要時間は各処理の合計になります。互いに依存しない処理は map して Promise.all に渡します。
  • タイムアウトは AbortSignal.timeout(ms) を fetch の signal に渡すのが最短です(Node.js 17.3.0以降、Chrome 124以降)。

try/catchで捕まる例外と、捕まらない例外

awaitの付け忘れで素通りするreject

try/catch が捕まえるのは、その try ブロックの実行中に投げられた例外だけです。await を書かずに呼んだ非同期関数は、Promiseを返した時点でブロックを抜けています。中でrejectされても、catch節には届きません。

try {
  save(order);          // await を書き忘れている
} catch (e) {
  console.error(e);     // ここには来ない。処理は成功したように見える
}

try {
  await save(order);    // await があって初めて catch に伝わる
} catch (e) {
  console.error(e);
}

行き場を失ったrejectは未処理拒否(unhandled rejection)になります。Node.jsは15で既定の挙動を警告からthrowへ変更したため、ハンドラを登録していないプロセスはそこで終了します。ブラウザでは window の unhandledrejection イベントに流れ、監視ツールを入れていなければコンソールに残るだけです。「例外は出ていないのにデータが保存されていない」という報告の多くはこの形です。

再throwとcauseによる原因の保持

catchした例外を握りつぶすと、上位の層は何が起きたか分からなくなります。文脈を足して投げ直すときは、元の例外を cause に入れます。

async function loadOrder(id) {
  try {
    return await api.get(`/orders/${id}`);
  } catch (e) {
    // 元の例外を cause に保持したまま、業務上の文脈を足して投げ直す
    throw new Error(`注文の取得に失敗しました: ${id}`, { cause: e });
  } finally {
    metrics.record('order.load');   // 成否にかかわらず必ず実行される
  }
}

cause はES2022で入った標準の引数で、スタックトレースを追うときに元の失敗(接続断なのか404なのか)を残せます。finally は return の後でも実行されるため、接続の解放や計測はここに置きます。ログを出すだけで再throwしない catch は、呼び出し側から見れば成功なので書かないでください。

fetchのエラー判定|4xx・5xxでは例外にならない

fetch がrejectするのは、通信そのものが成立しなかったときだけです。DNS解決の失敗、接続断、CORS違反、中断がこれにあたります。サーバーが404や500を返した場合、通信は成功しているのでPromiseは解決します。

const res = await fetch('/api/orders');

if (!res.ok) {
  // res.ok は status が 200〜299 のときだけ true
  throw new Error(`APIエラー: ${res.status} ${res.statusText}`);
}

const data = await res.json();   // ここも失敗しうる(HTMLが返れば構文エラー)

res.ok を見ずに res.json() を呼ぶと、エラーページのHTMLをJSONとして解析しようとして構文エラーになります。この例外は fetch の行ではなく json() の行で出るため、原因がAPI側のエラー応答だと気付くまで時間を取られます。fetch を直接書く代わりにHTTPクライアントを挟む設計が好まれるのは、この判定を1箇所に閉じ込められるからです。

並列と順次の使い分け|Promise.all・allSettled・any・race

4つの合成メソッドの違い

複数のPromiseをまとめる関数は4つあり、失敗したときの振る舞いで選びます。

メソッド 解決する条件 失敗時 主な用途
Promise.all 全件成功 1件でも失敗した時点でreject 全部揃わないと画面を出せない
Promise.allSettled 全件決着 rejectしない(status付きで返る) 一括処理の成否レポート
Promise.any 最初の成功 全滅時にAggregateError 複数ミラーからの取得
Promise.race 最初の決着 最初が失敗ならreject タイムアウトとの競走
// 全件必要:1件でも失敗すれば例外
const [user, orders] = await Promise.all([fetchUser(id), fetchOrders(id)]);

// 失敗込みで全件の結果が欲しい
const results = await Promise.allSettled(ids.map((i) => save(i)));
const failed = results.filter((r) => r.status === 'rejected');
console.log(`${failed.length}件失敗`, failed.map((r) => r.reason));

Promise.all が1件の失敗でrejectしても、残りの処理が中断されるわけではありません。走り続けて、結果だけが捨てられます。副作用のある処理(課金、メール送信)を all で束ねると、「例外は出たが半分は実行済み」という状態が生まれます。この用途では allSettled で全件の成否を受け取り、失敗分だけを補償する形にしてください。allSettled はNode.js 12.9.0・Chrome 76以降、any はNode.js 15.0.0・Chrome 85以降で使えます。

forループ内のawaitが直列になる理由

ループの中で await を書くと、前の処理が終わるまで次が始まりません。1件200msのAPIを10件呼べば2秒かかります。

// 直列:合計2秒(依存関係がある場合はこれが正しい)
for (const id of ids) {
  await fetchDetail(id);
}

// 並列:最も遅い1件の時間で終わる
await Promise.all(ids.map((id) => fetchDetail(id)));

逆に、前の結果を次のリクエストで使う場合や、更新の順序が結果に影響する場合は直列が正解です。「遅いから全部並列に」ではなく、依存関係の有無で決めます。map の中に async コールバックを書いた時点で全件が同時に走り始める点にも注意してください。Promise.all に渡す前から通信は始まっています。

同時実行数の制御

1,000件の配列をそのまま Promise.all に渡すと、1,000本の接続を同時に開こうとします。相手のレート制限に当たるか、自分のメモリを使い切ります。

async function mapLimit(items, limit, worker) {
  const results = [];
  let cursor = 0;
  const runners = Array.from({ length: limit }, async () => {
    while (cursor < items.length) {
      const index = cursor++;              // 各ランナーが順に次の要素を取る
      results[index] = await worker(items[index]);
    }
  });
  await Promise.all(runners);
  return results;
}

const details = await mapLimit(ids, 5, fetchDetail);   // 同時5本まで

並列数は相手の制限から逆算します。APIの上限が毎秒10リクエストなら、平均応答時間0.5秒として同時5本が目安です。上限が公開されていない外部APIでは、まず3〜5本から始めて429の有無を見ます。

タイムアウトと中断|AbortSignal.timeoutとAbortController

応答が返らない相手を待ち続けないために、fetch にはタイムアウトを設定します。AbortSignal.timeout(ms) は指定時間で自動的に中断するシグナルを作ります。

// 3秒で打ち切る
try {
  const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
} catch (e) {
  if (e.name === 'TimeoutError') console.error('3秒以内に応答がありません');
  if (e.name === 'AbortError') console.error('中断されました');
}

// 画面遷移などで能動的に打ち切る場合は AbortController
const ac = new AbortController();
document.querySelector('#cancel').addEventListener('click', () => ac.abort());
const res = await fetch(url, { signal: ac.signal });

AbortSignal.timeout() はNode.js 17.3.0、Firefox 100、Safari 16で使えます。Chromeは103で入りましたが、123までは中断時に TimeoutError ではなく AbortError を投げる部分実装で、名前で分岐するコードは124以降が前提です。ユーザー操作による中断とタイムアウトを1つのシグナルにまとめたい場合は AbortSignal.any([...])(Node.js 20.3.0・Chrome 116以降)を使います。

Promise.race でタイマーと競走させる古い書き方は、負けた側の fetch が裏で走り続ける点が違います。接続を実際に切りたいなら signal を使ってください。

リトライしてよい失敗と、してはいけない失敗の線引き

再試行は万能ではありません。同じリクエストを2回実行しても結果が変わらない(冪等である)操作にだけ入れます。 決済や送信のように、2回実行されると二重課金・二重送信になる操作は、リトライの前に冪等キーの仕組みを用意するのが先です。

async function retry(fn, { attempts = 3, base = 500 } = {}) {
  for (let i = 0; i < attempts; i++) {
    try {
      return await fn();
    } catch (e) {
      const retriable = e.status === 429 || e.status >= 500 || e.name === 'TimeoutError';
      if (!retriable || i === attempts - 1) throw e;   // 400番台は再試行しない
      await new Promise((r) => setTimeout(r, base * 2 ** i));
    }
  }
}

再試行してよいのは、429(レート制限)、5xx(サーバー側の一時障害)、タイムアウトと接続断です。400や422のような入力の誤りは、何度送っても同じ結果にしかならず、相手の負荷を増やすだけです。401も、トークンを更新せずに繰り返せばアカウントロックを招きます。

待ち時間は固定にせず、失敗のたびに倍にします(指数バックオフ)。障害から復旧した瞬間に全クライアントが同時に殺到する事態を避けるには、さらに乱数のゆらぎを足します。冪等性そのものの設計はデータパイプラインとは?種類・構成要素と冪等性から考える設計判断を解説【2026年版】で扱っています。

よくある質問

JavaScriptの非同期処理でエラーが捕まらないのはなぜですか?

await の付け忘れが最も多い原因です。try ブロックの中で非同期関数を await なしで呼ぶと、Promiseが返った時点でブロックを抜けるため、後からrejectされてもcatchには届きません。.catch() も付けていない場合は未処理拒否となり、Node.js 15以降は既定でプロセスが終了します。

非同期処理と並列処理の違いは何ですか?

非同期処理は「完了を待たずに次へ進む」実行方式、並列処理は「複数の処理を同時に走らせる」実行形態です。JavaScriptは単一スレッドで動くため、Promise.all で同時に走らせているのはI/Oの待ち時間であって、計算そのものが並列になるわけではありません。CPUを使う処理を速くしたい場合はWeb Workerなど別スレッドの仕組みが要ります。

await fetchで404を検出するにはどうすればよいですか?

res.ok(status が200〜299のときのみtrue)を確認して自分で例外を投げます。fetch はネットワーク層の失敗だけをrejectするため、404や500はそのまま解決されます。ステータスコードで分岐したい場合は res.status を直接見ます。

非同期処理は英語で何と言いますか?

asynchronous processing、実行単位を指す場合は asynchronous operation です。同期処理は synchronous processing で、コード上の sync/async はこの語の略です。ライブラリのドキュメントでは「non-blocking(ブロックしない)」という表現も同じ性質を指して使われます。

複数のAPIを呼ぶとき、どれを使えば速くなりますか?

互いに依存しない呼び出しなら Promise.all か Promise.allSettled に渡します。所要時間は合計から「最も遅い1件」に変わります。ただし件数が多い場合は同時実行数を絞ってください。相手のレート制限に当たると429が返り、リトライで余計に遅くなります。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2026.01.09 コラム 違背実験とは?読み方・意味と具体例、ガーフィンケル原典の数値とエスノメソドロジー
  3. 2026.10.09 テックブログ スカラコミュニケーションズの不正アクセスと最大71万件:管理サイトから他社へ広げない設計
  4. 2025.11.04 テックブログ Runpodとは?料金・使い方・支払い方法とGPUクラウドの選び方【2026年10月】

RELATED POSTS 関連記事

目次