AbortSignal.timeout() の使い方|fetch にタイムアウトを設定する方法とタイムアウトしない原因【Node.js / ブラウザ】

公開日:2026-06-15/カテゴリ:Node.js・バックエンド/AnchorUp 技術ブログ

結論:AbortSignal.timeout() とは?fetch にタイムアウトを設定するには?

AbortSignal.timeout(ms) は「指定ミリ秒後に自動で中断(abort)される AbortSignal を返す静的メソッド」です。 fetch には timeout オプションが存在しないため、fetch(url, { signal: AbortSignal.timeout(5000) }) と渡すのが、ブラウザでも Node.js でも共通の「fetch にタイムアウトを設定する方法」 です。時間切れになると fetch は name が TimeoutError の DOMException で reject されます。

// これが最小の正解(ブラウザ / Node.js 共通)
try {
  const res = await fetch(url, { signal: AbortSignal.timeout(5000) }) // 5秒
  const data = await res.json()
} catch (err) {
  if (err.name === 'TimeoutError') {
    console.error('5秒以内に応答がなかった')
  } else if (err.name === 'AbortError') {
    console.error('タイムアウト以外の理由で中断された')
  } else {
    throw err // ネットワークエラー(TypeError)など
  }
}
知りたいこと 答え
fetch のデフォルトタイムアウトは? 仕様上なし(Node.js は undici のソケットタイムアウト約5分が最後の砦)
タイムアウトの設定方法は? signal: AbortSignal.timeout(ms) を渡す
タイムアウト時のエラーは? DOMException、err.name === 'TimeoutError'
手動キャンセルと併用するには? AbortSignal.any([AbortSignal.timeout(ms), controller.signal])
使えるバージョンは? Node.js 17.3+ / Chrome 124+(103〜123は部分対応)/ Firefox 100+ / Safari 16+
response.json() の読み込み中も切れる? 切れる(同じ signal がボディ読み込みにも効く)
引数で ERR_OUT_OF_RANGE / ERR_INVALID_ARG_TYPE が出る Node.js は 0〜4294967295 の整数しか受け付けない(文字列・小数・NaN は不可)
Node.js の setTimeout(timers/promises)などで使うと? AbortError になり、TimeoutError は err.cause に入る
タイマーを途中で取り消せる? 取り消せない(必要なら AbortController + setTimeout)

以下、対応バージョン、エラーの見分け方、引数エラーや fetch 以外の API での注意点、Node.js サーバーでタイムアウト未設定が引き起こす障害、状況別の実装パターンを順に解説する。


AbortSignal.timeout() はどのバージョンから使える?(Node.js / ブラウザ対応表)

現行の Node.js LTS(20 / 22 / 24)と主要ブラウザの最新版ではすべて使える。 ただし古い Chrome には「タイムアウトしても TimeoutError ではなく AbortError になる」部分実装の時期があるので、エラー判定を書くときは注意する。

実行環境 AbortSignal.timeout() AbortSignal.any() 備考
Node.js 17.3.0+(16.14.0 にもバックポート) 20.3.0+(18.17.0 にもバックポート) 組み込み fetch は 18.0 でフラグ不要、21.0 で stable
Chrome / Edge 124+(103〜123 は部分対応) 116+ 103〜123 はタイムアウト時も常に AbortError
Firefox 100+ 124+
Safari 16+ 17.4+
Deno 1.20+ 1.39+
Bun 1.0+ 1.1.4+

(出典: Node.js 公式ドキュメントの “Added in”、MDN browser-compat-data)

未対応環境で呼ぶと AbortSignal.timeout is not a function の TypeError になる。古いブラウザもサポートする必要があるなら、後述の 対策2(AbortController + setTimeout) を使う。

ブラウザでは、AbortSignal.timeout() の時間は「経過時間」ではなくアクティブな時間で数えられる点も押さえておきたい。MDN によると、ドキュメントが bfcache(戻る/進むキャッシュ)に入っている間や、Worker が一時停止している間はタイマーが進まない。


TimeoutError と AbortError の違いは?エラーの見分け方

AbortSignal.timeout() による時間切れは TimeoutError、AbortController.abort() による手動中断は AbortError。どちらも DOMException なので、err.name で分岐する。 instanceof で区別しようとしても両方 DOMException なので見分けられない。

Node.js 22 で、応答を返さないサーバーに対して実際に投げたときのエラー全文は次のとおり。

# AbortSignal.timeout(300) で時間切れになった場合
DOMException [TimeoutError]: The operation was aborted due to timeout
    at node:internal/deps/undici/undici:14976:13
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)

# controller.abort() で手動中断した場合
DOMException [AbortError]: This operation was aborted
中断のしかた err.name err.message(Node.js 22) err.code
AbortSignal.timeout(ms) の時間切れ TimeoutError The operation was aborted due to timeout 23
controller.abort()(引数なし) AbortError This operation was aborted 20
controller.abort(new Error('custom')) 渡したエラーの name(例: Error) 渡したエラーの message —
ネットワーク到達不能・DNS失敗など TypeError fetch failed(Node.js) —

3行目がハマりどころで、abort(reason) に理由を渡すと、fetch はその reason をそのまま reject する。「AbortError で判定していたのに catch で拾えない」場合は、どこかで abort(何か) と理由付きで中断していないか確認する。

function classifyFetchError(err) {
  if (err?.name === 'TimeoutError') return 'timeout'   // 時間切れ → リトライ候補
  if (err?.name === 'AbortError') return 'aborted'     // ユーザー操作・画面遷移 → 黙って無視してよいことが多い
  if (err instanceof TypeError) return 'network'       // 接続不可・DNS・CORS など
  return 'unknown'
}

なお Chrome 103〜123 は時間切れでも AbortError を投げるので、古い Chrome を含むブラウザ向けコードで「タイムアウトをユーザーに通知したい」なら、TimeoutError と AbortError の両方をタイムアウト扱いにするのが無難だ。


AbortSignal.timeout() の引数でエラーになるのはなぜ?(TypeError / RangeError)

Node.js の AbortSignal.timeout(delay) は、delay が「0〜4294967295 の整数(number 型)」でないと、その場で例外を投げる。 よくあるのは、環境変数から読んだタイムアウト値を文字列のまま渡すケースだ。fetch が始まる前に例外になるので、「タイムアウトの設定を入れたらリクエスト自体が飛ばなくなった」という症状になる。

Node.js 20.20 で実際に渡したときの結果は次のとおり。

AbortSignal.timeout('5000')
TypeError [ERR_INVALID_ARG_TYPE]: The "delay" argument must be of type number. Received type string ('5000')

AbortSignal.timeout(-1)
RangeError [ERR_OUT_OF_RANGE]: The value of "delay" is out of range. It must be >= 0 && <= 4294967295. Received -1

AbortSignal.timeout(1.5)
RangeError [ERR_OUT_OF_RANGE]: The value of "delay" is out of range. It must be an integer. Received 1.5

AbortSignal.timeout(NaN)
RangeError [ERR_OUT_OF_RANGE]: The value of "delay" is out of range. It must be an integer. Received NaN
渡した値 Node.js の結果 よくある原因
'5000'(文字列) TypeError / ERR_INVALID_ARG_TYPE process.env.TIMEOUT_MS をそのまま渡した
NaN RangeError / ERR_OUT_OF_RANGE Number(undefined)(環境変数が未設定)
1.5 などの小数 RangeError / ERR_OUT_OF_RANGE 秒→ミリ秒の換算で端数が出た
-1 などの負数 RangeError / ERR_OUT_OF_RANGE 残り時間の計算結果がマイナスになった
4294967296 以上 RangeError / ERR_OUT_OF_RANGE 上限は約49.7日
0 エラーにならない すぐに abort される

Node.js の実装(lib/internal/abort_controller.js)は validateUint32(delay, 'delay', false) で検査しているため、このような結果になる。ブラウザは仕様上、0〜Number.MAX_SAFE_INTEGER の範囲(MDN)を受け付けるので、同じコードでも Node.js だけで落ちることがある。環境変数から読むときは、整数に直してから渡す。

// 環境変数のタイムアウト値を安全に数値化する
function readTimeoutMs(raw, fallback = 5000) {
  const n = Math.trunc(Number(raw))
  // 未設定・空文字・0・範囲外は既定値にする
  return n > 0 && n <= 4294967295 ? n : fallback
}

const res = await fetch(url, {
  signal: AbortSignal.timeout(readTimeoutMs(process.env.API_TIMEOUT_MS)),
})

fetch 以外の Node.js API で使うと TimeoutError にならないのはなぜ?

fetch はタイムアウト時に TimeoutError で reject するが、timers/promises の setTimeout や child_process.execFile など Node.js 独自の API は、name が AbortError(code: 'ABORT_ERR')のエラーを投げ、TimeoutError は err.cause に入る。 そのため fetch 用に書いた err.name === 'TimeoutError' の判定をそのまま使うと、時間切れを「手動キャンセル」と取り違える。

Node.js 20.20 で確認したコードと出力は次のとおり。

import { setTimeout as sleep } from 'node:timers/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'

try {
  await sleep(1000, null, { signal: AbortSignal.timeout(50) })
} catch (err) {
  console.log(err.name, err.code, err.cause?.name)
}

try {
  await promisify(execFile)('sleep', ['5'], { signal: AbortSignal.timeout(50) })
} catch (err) {
  console.log(err.name, err.code, err.cause?.name)
}
AbortError ABORT_ERR TimeoutError
AbortError ABORT_ERR TimeoutError
API タイムアウト時の err.name TimeoutError の場所
fetch TimeoutError err そのもの
timers/promises の setTimeout AbortError err.cause
child_process.execFile(promisify) AbortError err.cause

fetch と Node.js の API を両方扱う場所では、err と err.cause の両方を見る判定にしておくと取り違えない。

function isTimeout(err) {
  return err?.name === 'TimeoutError' || err?.cause?.name === 'TimeoutError'
}

AbortSignal.timeout() は取り消せる?プロセスを待たせる?

AbortSignal.timeout() のタイマーは取り消せない。また Node.js では、このタイマーだけではプロセスは終了を待たない。 どちらも、setTimeout と同じ感覚で使うとハマる点だ。

  1. 処理が早く終わってもタイマーは止まらない:MDN には「タイムアウトを取り消す方法はない」とあり、処理が先に終わっても、AbortSignal.any() で合成した別のシグナルが先に abort されても、タイマーは残る。abort イベントのリスナーを付けたままにすると、タイムアウトまでシグナルが解放されない。長いタイムアウトでリスナーを付ける場合は、使い終わったらリスナーを外すか、MDN が勧めるように AbortController + setTimeout にして clearTimeout する(対策2)。
  2. Node.js ではタイマーだけでプロセスは残らない:Node.js の実装はタイマーに unref() を呼んでいる(ソースのコメントに「タイマーだけで Node.js のプロセスを開いたままにしたくない」とある)。そのため、次のコードは fired を出さずにすぐ終了する。「一定時間後に処理を走らせる」タイマーとして AbortSignal.timeout() を使ってはいけない。
// 他に待つ処理がないので、2秒待たずにプロセスが終了する
const signal = AbortSignal.timeout(2000)
signal.addEventListener('abort', () => console.log('fired'))

fetch などの処理を待っている間は、その処理がプロセスを生かしているので、タイムアウトは普通に発火する。


fetch がタイムアウトしないとどうなる?(Node.js サーバーが無限に詰まる)

次のような何の変哲もないコードが本番に潜んでいるとする。

app.get('/user/:id', async (req, res) => {
  // タイムアウト指定なし
  const response = await fetch(`https://external-api.example.com/users/${req.params.id}`)
  const json = await response.json()
  res.json(json)
})

外部APIが応答を返さない(落ちている/ネットワークが詰まっている)と、こうなる。

「デプロイ直後は問題ないのに数時間後に急に応答が遅くなる」「監視で特定エンドポイントだけタイムアウトが多発する」という症状の犯人として、このタイムアウト未設定は非常に多い。


なぜ fetch はタイムアウトしないのか?(Fetch 仕様に timeout オプションがない)

WHATWG Fetch 仕様にはタイムアウトを指定するオプションが定義されていない。これはブラウザの fetch() も同じで、リクエストの中断は AbortController / AbortSignal を使うのが仕様上の正規ルートだからだ。

Node.js の組み込み fetch は内部的に undici を使っており、この仕様に従う。つまりタイムアウトは開発者が明示的に実装しなければならない。axios の timeout オプションや node-fetch の感覚で書くと、ここに落とし穴がある。

なお、undici にはソケットレベルの headersTimeout(既定約5分)・bodyTimeout(既定約5分)が存在するが、これは「リクエスト全体を素早く切る」用途には長すぎる。アプリ要件に合わせた中断は自前で行う。


対策1(推奨):AbortSignal.timeout() で fetch にタイムアウトを設定する

Node.js 17.3 以降(現行のLTS全てで利用可)では AbortSignal.timeout(ms) が使える。指定ミリ秒後に自動で abort する使い捨てシグナルを返すので、これを fetch に渡すだけでよい。

async function getUser(id) {
  try {
    const res = await fetch(`https://external-api.example.com/users/${id}`, {
      signal: AbortSignal.timeout(5000), // 5秒で中断
    })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)
    return await res.json()
  } catch (err) {
    if (err.name === 'TimeoutError') {
      // AbortSignal.timeout による中断はこの name になる
      throw new Error('外部APIがタイムアウトしました')
    }
    throw err
  }
}

ポイントはエラーの name で分岐すること。AbortSignal.timeout 起因の中断は TimeoutError、手動 abort(後述)は AbortError になる。HTTPステータスのエラーとは別物なので、ログとリトライ判定で区別する。


対策2:AbortController + setTimeout でタイムアウトを実装する(古い環境・動的制御)

「ユーザー操作でキャンセルもしたい」「タイムアウト値を動的に変えたい」場合は AbortController を自分で持つ。

async function getUser(id, timeoutMs = 5000) {
  const controller = new AbortController()
  const timer = setTimeout(() => controller.abort(), timeoutMs)
  try {
    const res = await fetch(`https://external-api.example.com/users/${id}`, {
      signal: controller.signal,
    })
    return await res.json()
  } finally {
    clearTimeout(timer) // タイマーリークを防ぐため必ず解放する
  }
}

finally での clearTimeout を忘れると、リクエストが早く終わってもタイマーが残り続ける。手動方式では必ず後始末する。

この方式には2つ注意点がある。

  1. エラー名は AbortError になる。controller.abort() を引数なしで呼ぶと TimeoutError ではなく AbortError で reject されるため、タイムアウトと手動キャンセルを区別したいなら controller.abort(new DOMException('timeout', 'TimeoutError')) のように理由を渡す。
  2. clearTimeout はボディを読み終えてから。fetch が返った直後にタイマーを解除すると、res.json() の読み込み中に相手が止まったとき守られない。上の例のように return await res.json() まで try の中に入れる。

対策3:共通ラッパーにして全 fetch に強制する

個別の呼び出しでタイムアウトを付け忘れるのが事故の元なので、プロジェクト全体で使うラッパー関数にしてしまうのが堅実だ。

// src/lib/http.js
const DEFAULT_TIMEOUT = 5000

export async function httpFetch(url, options = {}) {
  const { timeout = DEFAULT_TIMEOUT, signal, ...rest } = options
  const timeoutSignal = AbortSignal.timeout(timeout)
  const res = await fetch(url, {
    ...rest,
    // 呼び出し側の signal があれば合成する(上書きして消さない)
    signal: signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal,
  })
  if (!res.ok) {
    throw new Error(`HTTP ${res.status} for ${url}`)
  }
  return res
}

レビューで「生の fetch を直接使っていないか」だけ見ればよくなり、付け忘れを構造的に防げる。ラッパーで signal: AbortSignal.timeout(...) を単純に上書きすると、呼び出し側が渡したキャンセル用の signal が黙って無視されるので、上のように AbortSignal.any() で合成しておく。


対策4:AbortSignal.any() でタイムアウトとキャンセルを組み合わせる

AbortSignal.any([...]) は、渡したシグナルのうちどれか1つが abort されたら abort される合成シグナルを返す。 「5秒のタイムアウト」と「リクエストスコープのキャンセル」を両方効かせたいときに使う(Node.js 20.3+ / 18.17+、Chrome 116+、Firefox 124+、Safari 17.4+)。

async function getUser(id, externalSignal) {
  const signal = AbortSignal.any([
    AbortSignal.timeout(5000),
    externalSignal, // 例: リクエスト中断時に abort されるシグナル
  ].filter(Boolean))

  const res = await fetch(`https://api.example.com/users/${id}`, { signal })
  return res.json()
}

どちらか早い方の中断で fetch が止まる。タイムアウトとキャンセルを別々に if で書くより安全だ。

合成シグナルの abort 理由は最初に abort されたシグナルの理由がそのまま引き継がれる。そのため catch 側の判定は対策1と同じでよい。Node.js 22 で確認した挙動は次のとおり。

先に発火したシグナル fetch の reject
AbortSignal.timeout(ms) TimeoutError(The operation was aborted due to timeout)
controller.abort() AbortError(This operation was aborted)

ブラウザでの典型例は、React の useEffect で「アンマウント時のキャンセル」と「タイムアウト」を両立するパターンだ。

useEffect(() => {
  const controller = new AbortController()
  const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(8000)])

  fetch('/api/items', { signal })
    .then((res) => res.json())
    .then(setItems)
    .catch((err) => {
      if (err.name === 'AbortError') return // アンマウント → 無視してよい
      if (err.name === 'TimeoutError') setError('通信がタイムアウトしました')
      else setError('通信に失敗しました')
    })

  return () => controller.abort() // クリーンアップでキャンセル
}, [])

開発環境で useEffect が2回実行されて1回目が AbortError になるのは StrictMode の仕様で、バグではない(詳しくは React useEffect が2回呼ばれる原因)。


対策5:タイムアウト+リトライをセットにする

外部API連携では、タイムアウトしたら一定回数リトライしたいことが多い。タイムアウト(TimeoutError)とサーバー側5xxだけを対象に、指数バックオフで再試行する。

async function fetchWithRetry(url, { retries = 2, timeout = 3000 } = {}) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const res = await fetch(url, { signal: AbortSignal.timeout(timeout) })
      if (res.status >= 500) throw new Error(`server ${res.status}`)
      return res
    } catch (err) {
      const retryable = err.name === 'TimeoutError' || /server 5/.test(err.message)
      if (!retryable || attempt === retries) throw err
      await new Promise((r) => setTimeout(r, 2 ** attempt * 200)) // 200ms, 400ms, ...
    }
  }
}

リトライ対象を絞らないと、4xx(クライアント起因)まで無駄に叩いて負荷を増やすので注意する。


タイムアウトが効かない・うまく動かない時の注意点

症状 原因 対処
ヘッダーは返ったのに res.json() で止まる 自前タイマーを fetch 直後に解除している 同じ signal を生かしたままボディを読む
タイムアウト後も裏で通信が続く Promise.race で「待つのをやめた」だけ signal で中断する
TimeoutError の判定に入らない controller.abort() 方式・古い Chrome(103〜123) AbortError も考慮する
AbortSignal.timeout is not a function Node.js 17.3 未満・古いブラウザ 対策2の AbortController 方式
呼び出し側のキャンセルが効かない ラッパーで signal を上書き AbortSignal.any() で合成
ERR_INVALID_ARG_TYPE / ERR_OUT_OF_RANGE 環境変数の文字列・NaN・小数を渡した 整数に直してから渡す
Node.js の API で TimeoutError の判定に入らない AbortError の cause に入っている err.cause?.name も見る
abort イベントが発火せずプロセスが終わる Node.js ではタイマーが unref() されている 遅延実行には setTimeout を使う

まとめ

やりたいこと 方法
一定時間で確実に中断(基本) fetch(url, { signal: AbortSignal.timeout(ms) })
キャンセルや動的タイムアウト AbortController + setTimeout + finally で clearTimeout
付け忘れ防止 共通 httpFetch ラッパーに集約
タイムアウト+キャンセル両立 AbortSignal.any([...])(Node 20.3+)
タイムアウト時のリトライ name === 'TimeoutError' と5xxだけ指数バックオフ
エラーの見分け TimeoutError=時間切れ / AbortError=手動中断 / TypeError=ネットワーク

fetch は Node.js でもブラウザでも「タイムアウトが無いのがデフォルト」と覚えておくのが安全だ。新規コードでは生の fetch を直接使わず、必ず AbortSignal.timeout 付きのラッパー経由にすることで、本番のハング障害をまとめて防げる。

Next.js の Server Components で外部APIを叩いている場合は、タイムアウトに加えてキャッシュの挙動にも注意が必要だ(Next.js App Router で fetch キャッシュが効かない原因)。

技術ブログの記事一覧へ