2510 字
13 分鐘

fetch 逾時怎麼處理:用 AbortSignal.timeout() 取消慢請求

fetch() 卡住時,很多人會寫一個 Promise.race(),但留下的請求仍可能在背景執行。現在瀏覽器提供 AbortSignal.timeout():它不只讓等待逾時,還能把 signal 傳給 fetch,要求請求中止。

直接做法是:把 timeout signal 傳進 fetch,並把逾時、使用者取消、真正的網路或 HTTP 錯誤分開處理。 逾時不是「服務一定壞了」,而是這次呼叫超過你的介面願意等待的時間。

最小 timeout 範例#

async function loadProfile() {
try {
const response = await fetch('/api/profile', {
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
if (error.name === 'TimeoutError') {
throw new Error('讀取逾時,請稍後再試');
}
throw error;
}
}

fetch 在 HTTP 404 或 500 時不會自行 reject,因此仍要看 response.ok。相反地,signal 被中止時,等待中的 fetch 會 reject;錯誤名稱要依中止原因判斷,不要一律顯示「網路斷線」。

逾時與使用者取消不是同一件事#

搜尋建議、切換路由或離開 modal 時,較好的做法是保留一個 AbortController,讓新操作主動取消舊請求:

let activeController;
async function search(query) {
activeController?.abort();
activeController = new AbortController();
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: activeController.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}

這裡的取消是 UI 狀態更新,不該彈出錯誤通知。若同時需要使用者取消與逾時,可用 AbortSignal.any() 合併 signal;捕捉例外後,使用合併 signal 保留的第一個中止原因決定訊息與重試策略。

同時支援手動取消與 timeout#

搜尋頁通常同時需要兩個條件:使用者輸入新關鍵字時取消舊請求,以及伺服器太慢時自動停止等待。AbortSignal.any() 會在任一個輸入 signal 中止時中止 fetch;它只合併通知,不會替你取消其他 controller,也不會把 timeout signal 變成可清除的計時器。

下面保留合併後的 signal,以它的 reason 判斷最先發生的取消:

let activeController;
async function search(query) {
activeController?.abort();
const controller = new AbortController();
const timeoutSignal = AbortSignal.timeout(8_000);
activeController = controller;
const signal = AbortSignal.any([controller.signal, timeoutSignal]);
try {
const response = await fetch(
'/api/search?q=' + encodeURIComponent(query),
{ signal },
);
if (!response.ok) throw new Error('HTTP ' + response.status);
return await response.json();
} catch (error) {
if (signal.aborted && signal.reason === timeoutSignal.reason) {
throw new Error('搜尋逾時,請縮小關鍵字或稍後再試');
}
if (signal.aborted && signal.reason === controller.signal.reason) {
return null;
}
throw error;
} finally {
if (activeController === controller) {
activeController = undefined;
}
}
}

這段程式有兩個容易漏掉的細節。第一,先呼叫舊 controller 的 abort,才能避免快速輸入時留下多個請求;第二,finally 只在目前 controller 仍是自己時清除狀態,否則舊請求結束可能把新請求的 controller 一起清掉。

AbortSignal.any() 會以第一個中止的 signal 作為合併 signal 的 reason。如果手動取消先發生,timeout 隨後也到期,兩個輸入 signal 都會是 aborted;只檢查 timeoutSignal.aborted 可能把手動取消錯報成逾時。依 DOM Standard,合併 signal 保留先觸發的 reason;上例用獨立的預設 AbortError/TimeoutError 物件比對,不把後來的狀態當成最初原因。若應用程式刻意讓多個來源共用同一個自訂 reason,這個比對就無法辨識來源,應另設不同原因物件。

fetch 成功回傳,不代表 JSON 已經讀完#

await fetch() 取得的是 Response;本文仍可能透過網路傳送。原本傳入的 signal 會繼續影響 response.text()、response.json() 等本文讀取,所以 timeout 要涵蓋這一段。取消後本文讀取會 reject;不要只在 fetch 外圍捕捉錯誤,卻把 JSON Promise 直接交出去。

前面的 loadProfile() 使用 return await response.json(),讓讀取失敗也進入同一個 catch。這裡的 await 有實際用途:改成 return response.json(),非同步本文讀取的 rejection 就不會由該 catch 處理。

以下測試建立只綁定 loopback 的 HTTP 伺服器,立刻送出 headers 與部分 JSON,再延遲一秒完成本文。將內容存成 fetch-timeout-check.mjs,使用支援 fetch、AbortSignal.timeout/any 的 Node.js 執行 node fetch-timeout-check.mjs:

import assert from 'node:assert/strict';
import http from 'node:http';
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'application/json' });
response.write('{"ready":');
const timer = setTimeout(() => response.end('true}'), 1_000);
response.on('close', () => clearTimeout(timer));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
try {
const signal = AbortSignal.timeout(500);
const response = await fetch(url, { signal });
assert.equal(response.status, 200);
// headers 已收到,但本文尚未完整下載。
await assert.rejects(response.json(), { name: 'TimeoutError' });
assert.equal(signal.reason.name, 'TimeoutError');
// 已中止的 signal 無法重新開始;重試要建立新 signal。
await assert.rejects(fetch(url, { signal }), { name: 'TimeoutError' });
const manual = new AbortController();
const deadline = new AbortController();
const combined = AbortSignal.any([manual.signal, deadline.signal]);
manual.abort();
deadline.abort(new DOMException('late deadline', 'TimeoutError'));
assert.equal(combined.reason, manual.signal.reason);
assert.equal(deadline.signal.aborted, true);
console.log('fetch timeout checks passed');
} finally {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
}

此範例由本次維護流程在 Node.js v24.13.0 執行,本文讀取與重用 signal 都收到 TimeoutError;瀏覽器、背景分頁與 bfcache 沒有實測。測試另檢查 signal.reason,避免將 Node 的例外結果推論成所有執行環境的固定行為。

timeout signal 的生命週期#

依 MDN timeout() 文件,瀏覽器以 active time 計時,文件進入 bfcache 或 worker 暫停時也會暫停,因此不能把它當成精確的牆鐘截止時間。AbortSignal.timeout() 的計時器本身不能被提早取消;把 fetch 的合併 signal 中止,也不會回頭取消 timeout signal。這通常只是一個短暫的計時器,但若你在長時間頁面中建立大量請求,仍應讓請求完成或 abort 後移除自己的事件監聽器,避免把 controller 和結果留在元件狀態裡。

AbortSignal.any() 與 AbortSignal.timeout() 在現代瀏覽器和 Workers 已有廣泛支援;若產品仍要支援更舊的執行環境,先做 feature detection,再決定是否提供 AbortController 加 setTimeout/clearTimeout 的 fallback。不要為了相容性退回 Promise.race() 卻忘了中止真正的網路請求。

要提早清掉計時器時,改用可收束的 wrapper#

AbortSignal.timeout() 沒有取消自身 timeout 的方法。若操作提早結束,而且你需要明確清理長計時器,MDN timeout 文件建議使用 AbortController 搭配 setTimeout()/clearTimeout()。下面的 wrapper 也能在沒有 AbortSignal.any() 時轉送呼叫端取消;仍需環境支援 fetch、AbortController、signal.reason 與 DOMException。

async function fetchJsonWithTimeout(url, {
timeoutMs = 8_000,
signal: callerSignal,
} = {}) {
if (!Number.isInteger(timeoutMs) || timeoutMs < 0 || timeoutMs > 2_147_483_647) {
throw new RangeError('timeoutMs exceeds the supported timer range');
}
// 已取消就不要啟動請求,也不要建立計時器。
if (callerSignal?.aborted) throw callerSignal.reason;
const controller = new AbortController();
const forwardAbort = () => controller.abort(callerSignal.reason);
callerSignal?.addEventListener('abort', forwardAbort, { once: true });
const timer = setTimeout(() => {
controller.abort(new DOMException('Request timed out', 'TimeoutError'));
}, timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error('HTTP ' + response.status);
return await response.json();
} finally {
clearTimeout(timer);
callerSignal?.removeEventListener('abort', forwardAbort);
}
}

這裡的 return await response.json() 是必要的:finally 要等本文讀取與 JSON 解析完成才清除計時器。若換成 return response.json(),函式會提早進入 finally,慢本文就失去 timeout 保護。它處理的是讀取 JSON 的 GET;要支援其他 method、headers 或 schema,需擴充並另外驗收。

forwardAbort 保留呼叫端的 reason,可以是 DOMException、Error 或自訂物件。呼叫端不要只用 error.name === 'AbortError' 判斷取消,也不要看到任意例外就改成 timeout。若業務需要識別特定取消,核對呼叫端 signal 的 aborted 與 reason;本文讀取階段拋出的例外不保證就是自訂 reason 物件,不要只比對 catch 收到的物件。HTTP 錯誤與 JSON SyntaxError 仍應保留原本類別。

這個 fallback 使用一般計時器,不具備原生 timeout 在 bfcache/suspended worker 的 active-time 語意;setTimeout 也可能延後執行。它不是跨環境的精準 wall-clock SLA。範圍檢查限制到常見 timer 的 32-bit 上限,是這份 wrapper 的契約,沒有宣稱與原生 timeout 可接受的數值範圍完全相同。

用已送出標頭、延後本文的 HTTP fixture 驗收#

把上面函式和以下內容存為同一份 .mjs,執行 node 檔名.mjs。測試只監聽 loopback 的隨機 port,不呼叫外部 API;server 延後結束 JSON 本文,讓 request 取得標頭後仍需等待。

import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
if (req.url === '/fast') return res.end('{"ok":true}');
if (req.url === '/invalid') return res.end('not json');
res.flushHeaders();
const timer = setTimeout(() => res.end('{"ok":true}'), 500);
res.on('close', () => clearTimeout(timer));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = 'http://127.0.0.1:' + server.address().port;
try {
assert.deepEqual(await fetchJsonWithTimeout(base + '/fast'), { ok: true });
await assert.rejects(fetchJsonWithTimeout(base + '/slow', { timeoutMs: 150 }),
error => error.name === 'TimeoutError');
await assert.rejects(fetchJsonWithTimeout(base + '/invalid'), SyntaxError);
const controller = new AbortController();
const reason = new Error('使用者離開');
const pending = fetchJsonWithTimeout(base + '/slow', { signal: controller.signal });
controller.abort(reason);
await assert.rejects(pending, error => error === reason);
await assert.rejects(fetchJsonWithTimeout(base + '/fast', { signal: controller.signal }),
error => error === reason);
console.log('success, body timeout, invalid JSON and cancellation passed');
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}

這份刊登範例於 2026-10-14 由維護流程在 Node.js v24.13.0 實際執行。另外以相同函式檢查 HTTP 500、非法 timeout 值,並觀察成功、取消、解析失敗及本文逾時後計時器與 abort listener 都有移除。這些檢查沒有涵蓋 Safari、Firefox、bfcache 或真實 API;150ms/500ms 是重現用 fixture 設定,不是正式環境的 timeout 建議。

不要用 timeout 掩蓋後端問題#

timeout 值應從操作目的決定:輸入搜尋可短,儲存草稿或匯出檔案應讓使用者看見進度並提供取消。若同一 endpoint 經常超時,請記錄 request ID、URL、時間與回應狀態,回到 API、資料庫或代理層找原因。

而且中止 client 端等待不保證伺服器已停止工作。對有副作用的 POST、付款或建立資源操作,伺服器端仍要有 idempotency key 或可查詢的處理狀態;不要因為前端超時就盲目重送。

這類取消邏輯適合放在讀取遠端資料的元件邊界;若你還要處理 cookie、credentials 或 CORS,可先看 Fetch API 與 Cookie 的設定方式。

檢查清單#

  • 對 HTTP status 明確判斷,不把 fetch resolve 當成成功。
  • 對 timeout、手動 abort 與其他錯誤給不同處理。
  • 元件卸載或查詢條件改變時,取消不再需要的讀取。
  • 有副作用的請求由伺服器負責冪等與最終狀態,前端 timeout 不當作失敗證明。

參考資料:

MDN:AbortSignal.any()

MDN:AbortSignal.timeout()

MDN:Using the Fetch API - Canceling a request

MDN:setTimeout 計時器限制

DOM Standard:AbortSignal.any() 與中止原因

fetch 逾時怎麼處理:用 AbortSignal.timeout() 取消慢請求
https://laplusda.com/posts/javascript-fetch-abortsignal-timeout/
作者
Zero
發佈於
2026-07-31
許可協議
CC BY-NC-SA 4.0