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 明確判斷,不把
fetchresolve 當成成功。 - 對 timeout、手動 abort 與其他錯誤給不同處理。
- 元件卸載或查詢條件改變時,取消不再需要的讀取。
- 有副作用的請求由伺服器負責冪等與最終狀態,前端 timeout 不當作失敗證明。
參考資料: