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 と同じ感覚で使うとハマる点だ。
- 処理が早く終わってもタイマーは止まらない:MDN には「タイムアウトを取り消す方法はない」とあり、処理が先に終わっても、
AbortSignal.any()で合成した別のシグナルが先に abort されても、タイマーは残る。abortイベントのリスナーを付けたままにすると、タイムアウトまでシグナルが解放されない。長いタイムアウトでリスナーを付ける場合は、使い終わったらリスナーを外すか、MDN が勧めるようにAbortController+setTimeoutにしてclearTimeoutする(対策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が応答を返さない(落ちている/ネットワークが詰まっている)と、こうなる。
await fetch(...)が完了せずブロックされ続ける- そのリクエストを処理しているワーカー/イベントループ上の処理が解放されない
- 同じ症状のリクエストが積み重なり、コネクションとメモリを食い潰す
- 最終的にサーバー全体が応答不能・OOMでクラッシュ
「デプロイ直後は問題ないのに数時間後に急に応答が遅くなる」「監視で特定エンドポイントだけタイムアウトが多発する」という症状の犯人として、このタイムアウト未設定は非常に多い。
なぜ 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つ注意点がある。
- エラー名は
AbortErrorになる。controller.abort()を引数なしで呼ぶとTimeoutErrorではなくAbortErrorで reject されるため、タイムアウトと手動キャンセルを区別したいならcontroller.abort(new DOMException('timeout', 'TimeoutError'))のように理由を渡す。 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 を使う |
- ボディ読み込み中も同じ signal で切れる:
AbortSignal.timeout()を渡したfetchは、ヘッダー受信後に相手が止まった場合でもres.json()/res.text()がTimeoutErrorで reject される(Node.js 22 で、ヘッダーだけ返してボディを送らないサーバーに対して確認済み)。タイムアウト値は「ヘッダー到着まで」ではなく「ボディを読み終えるまでの合計時間」として決める。大きなファイルのダウンロードでは短すぎる値にしないこと。 Promise.raceでの自前タイムアウトは中途半端:Promise.race([fetch(...), timeoutPromise])は「待つのをやめる」だけで、裏のリクエスト自体はキャンセルされない。コネクションは開いたまま残る。必ずsignalで中断する。- エラーの name を取り違える:
AbortSignal.timeout→TimeoutError、controller.abort()→AbortError。ログ集計や監視のフィルタで混同しないこと。 AbortSignal.timeout()は使い捨て:一度 abort したシグナルは元に戻らない。リトライのたびに新しくAbortSignal.timeout()を作る(対策5のようにループ内で生成する)。
まとめ
| やりたいこと | 方法 |
|---|---|
| 一定時間で確実に中断(基本) | 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 キャッシュが効かない原因)。