1715 字
9 分鐘

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() 卻忘了中止真正的網路請求。

不要用 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

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