fetchにタイムアウトを設定する — AbortControllerとAbortSignal.timeoutの違い

公開日:
目次

Next.jsで外部APIを叩く処理を書いていたら、相手のレスポンスが遅いせいでビルドが止まったままになる、ということが起きました。fetchはデフォルトではタイムアウトを持たないので、自分で制御を入れる必要があります。実装にあたって AbortControllerAbortSignal.timeout() のどちらを使うか調べたので、その整理です。

なぜfetchはタイムアウトしないのか

fetchはブラウザにもNode.jsにも組み込まれていますが、標準仕様ではタイムアウトオプションが用意されていません。fetch(url, { timeout: 5000 }) のような指定はできず、書いても黙って無視されます。

理由は、fetchがそもそも「リクエストの中止」を AbortSignal という汎用機構に任せているからです。タイムアウトもユーザー操作によるキャンセルも、同じ仕組みで処理する設計になっています。

つまりfetch側に専用オプションを増やす代わりに、外から「中止信号」を渡せばいい、というのが今の答えです。

AbortControllerで実装する基本パターン

伝統的な書き方は AbortControllersetTimeout を組み合わせるものです。Node.js 15 以降と、ある程度新しいブラウザならどこでも動きます[1]

async function fetchWithTimeout(url, ms = 5000) {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), ms)
  try {
    const res = await fetch(url, { signal: controller.signal })
    return res
  } finally {
    clearTimeout(timeoutId)
  }
}

流れはこうです。AbortController を作って setTimeout で一定時間後に abort() を呼ぶ予約を入れる。fetchにはそのシグナルを渡しておく。レスポンスが返ってくれば finally で予約を取り消す。タイムアウトに引っかかればabortが先に発火してfetchがrejectします。

finallyclearTimeout を呼ぶのは大事です。呼び忘れると、fetchが先に成功した場合でも数秒後にabortのタイマーがそのまま残り、メモリ上のオブジェクトを掴み続けます。短時間で多数のリクエストを投げる場合は地味に効いてきます。

abortされた場合の例外は name === 'AbortError' で拾えます。ユーザー操作で止めたのかタイムアウトで止まったのかを区別したいときは、abortする側で controller.abort(new Error('timeout')) のように理由を渡せます。

AbortSignal.timeoutを使うモダンな書き方

2022年頃から各ブラウザに入った AbortSignal.timeout() を使うと、上のボイラープレートがほぼ消えます[2]

const res = await fetch(url, { signal: AbortSignal.timeout(5000) })

タイマー管理も clearTimeout も書く必要がなく、5秒経過したら自動でabortされます。タイムアウトで中断した場合の例外名は AbortError ではなく TimeoutError になるので、ユーザー操作によるabortとちゃんと区別できます。

Node.js では v17.3.0 以降、Chrome は103以降、Firefox は100以降で利用できます[2:1]。ブラウザだけを相手にしている場合や、Node.jsのバージョンを十分新しく保てる環境なら、こちらを優先して問題ありません。

どちらを使うかの判断

新規コードで普通のタイムアウトだけが要件なら AbortSignal.timeout() の方が短く書けて読み手にもやさしいです。タイマーの後片付け漏れというバグの余地が消えるのも大きいです。

ただし以下のような場合は AbortController 側を選びます。

  • 古いNode.js(v17.3.0 未満)やレガシーブラウザを相手にしている
  • タイムアウト以外にも「ユーザーがキャンセルボタンを押したら止める」のような外部要因と組み合わせたい
  • 同じシグナルを複数のfetchで共有して、一斉に止めたい

ユーザー操作とタイムアウトの両方をハンドリングしたい、というのは検索フォームの自動補完などでよくある形です。新しめのランタイムなら AbortSignal.any([userController.signal, AbortSignal.timeout(5000)]) のように複数のシグナルを束ねる書き方もあります[3]。サポート状況はまだ追いつき中なので、本番投入する前に対応バージョンを確認しておくのが無難です。

Next.jsのビルド時fetchで実際に使う

冒頭の話に戻ります。自分が踏んだのは、ブログのビルド時に外部APIを叩いてリンクカードのHTMLを取りに行く処理でした。相手のサーバーが落ちているとfetchが返ってこなくて、next build が延々止まったまま、というやつです。

修正方針はシンプルでした。

try {
  const controller = new AbortController()
  const timeoutId = setTimeout(() => controller.abort(), 5000)
  const res = await fetch(url, { signal: controller.signal })
  clearTimeout(timeoutId)
  if (!res.ok) {
    return ''
  }
  const json = await res.json()
  return json.html
} catch {
  return ''
}

ポイントは、タイムアウトで失敗した時にビルド全体を止めずに空文字を返している点です。リンクカードは本文の付加要素なので、取れなかったときは諦めて素のリンクで表示すれば足ります。getStaticProps の中で外部APIを呼ぶときは、こんなふうに「失敗してもページ生成は通す」という割り切りを入れておくと、相手側の障害でデプロイが止まるのを防げます。

Next.jsの公式メッセージにも、静的生成が止まる原因として「Promise が解決しないfetch」が挙げられています[4]。タイムアウトを必ず付ける、という運用にしておくのが結局いちばん事故が少ないです。

まとめ

fetchのタイムアウトはふだん意識しないと忘れますが、外部APIに頼る処理では一行入れておくだけで運用がだいぶ違います。

今回は触れませんでしたが、関連で押さえておくと便利なものとして以下があります。

  • リトライ: タイムアウトしたら何回まで再試行するか。p-retry のようなライブラリが楽
  • サーキットブレーカ: 連続して失敗する相手をしばらく呼ばないようにする。バッチ処理だと効きが大きい
  • 速度低下の検出: タイムアウトに引っかからない範囲で「遅くなってきた」を計測する。実運用ではこちらの方が早く気付ける

AbortSignal 周りはMDNのページ[5]に他のユーティリティも載っているので、軽く流し読みしておくとあとで「あ、こういうのあったな」と思い出せます。

脚注
  1. AbortController - Web APIs - MDN Web Docs (2026-05-15 アクセス) ↩︎

  2. AbortSignal: timeout() static method - Web APIs - MDN Web Docs (2026-05-15 アクセス) ↩︎ ↩︎

  3. AbortSignal: any() static method - Web APIs - MDN Web Docs (2026-05-15 アクセス) ↩︎

  4. Static page generation timed out - Next.js (2026-05-15 アクセス) ↩︎

  5. AbortSignal - Web APIs - MDN Web Docs (2026-05-15 アクセス) ↩︎