Robust Data Fetching — fetch(), AbortController, Retry, and the Stale-Response Race
Client-side fetch resilience for seniors: HTTP gotchas, AbortController timeouts, exponential backoff, stale-response races, dedup, and a fetchJSON wrapper.
Tại sao fetch chưa đủ
Mọi frontend cuối cùng đều cần một data layer. Gọi API, hiện spinner, render JSON. Trong production, vòng lặp đó liên tục bị gãy: Wi-Fi rớt giữa request, CDN edge trả 502, user gõ "react hooks" rồi "react hooks advanced" trước khi response đầu về.
fetch chỉ là primitive transport mỏng. nó không có timeout, retry, dedup, hay bảo vệ stale response. Đó là việc của bạn.
Mô hình tư duy: coi
fetchnhưsocket.write()— giao hàng tin cậy cần policy phía trên.
Thử demo tương tác bên dưới: tăng failure rate, đặt timeout thấp hơn latency, burst search, rồi bật fix.
Mở demo đầy đủ:
Gotcha #1 — fetch KHÔNG reject khi HTTP lỗi
Đây là bug production phổ biến nhất. 404 hay 500 resolve Promise; chỉ lỗi network mới reject.
const res = await fetch('/api/user/42');
// ❌ res.status may be 404 — code below still runs
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const user = await res.json();
| Situation | fetch Promise | What you must do |
|---|---|---|
| DNS failure, offline, CORS block | rejects | catch network error |
| HTTP 4xx / 5xx | resolves with res.ok === false | check res.ok or res.status |
| HTTP 204 No Content | resolves, body empty | skip .json() |
| Redirect 301/302 | resolves after following | inspect final res.url if needed |
Luôn kiểm tra res.ok trước khi parse. Với API trả lỗi có cấu trúc trong JSON, vẫn đọc body khi fail:
async function parseResponse(res) {
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = new Error(data?.message ?? res.statusText);
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
Đọc body — một lần, cẩn thận
res.body là ReadableStream. Chỉ đọc được một lần. Gọi cả res.json() lẫn res.text() sẽ lỗi.
// ❌ second read throws
await res.json();
await res.text(); // TypeError: body already consumed
// ✅ clone if you truly need two reads (rare)
const clone = res.clone();
const meta = await res.json();
const raw = await clone.text();
Với payload lớn, nên stream — sẽ nói ngắn ở cuối.
Timeout với AbortController
fetch không có option timeout sẵn. Không timeout thì TCP treo khiến UI quay mãi.
Cách hiện đại: AbortSignal.timeout()
const res = await fetch('/api/dashboard', {
signal: AbortSignal.timeout(8_000), // 8 s hard limit
});
Khi hết giờ, fetch reject với AbortError. Coi như lỗi network có thể retry — trừ khi user đã rời trang.
Controller thủ công — gộp signal
Hữu ích khi cần cả timeout lẫn hủy bởi user:
function fetchWithTimeout(url, opts = {}) {
const { timeoutMs = 8_000, signal: outer, ...rest } = opts;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
outer?.addEventListener('abort', () => ctrl.abort(), { once: true });
return fetch(url, { ...rest, signal: ctrl.signal }).finally(() => {
clearTimeout(timer);
});
}
Mẹo production: timeout mỗi request nên khác theo endpoint. Search autocomplete: 3–5 giây. Upload file: phút, có progress. Health check: 1 giây.
Retry với exponential backoff + jitter
Lỗi tạm thời là bình thường ở quy mô lớn. Retry ngay lập tức đập server đang hồi phục. Backoff theo cấp số nhân giãn các lần thử; jitter tránh bão retry đồng bộ.
function backoffMs(attempt, base = 300) {
const exp = base * 2 ** attempt;
return Math.floor(exp * (0.5 + Math.random() * 0.5)); // full jitter
}
async function fetchWithRetry(url, opts = {}) {
const { maxRetries = 3, ...fetchOpts } = opts;
let lastErr;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await fetchWithTimeout(url, fetchOpts);
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res;
} catch (err) {
lastErr = err;
if (!isRetryable(err) || attempt === maxRetries) throw err;
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
}
}
throw lastErr;
}
Lỗi nào retry an toàn?
| Error | Retry? | Why |
|---|---|---|
Network offline, AbortError (timeout) | ✅ | Transient |
| HTTP 429 Too Many Requests | ✅ | Respect Retry-After header |
| HTTP 502 / 503 / 504 | ✅ | Upstream glitch |
| HTTP 408 Request Timeout | ✅ | Server gave up first |
| HTTP 400 / 401 / 403 / 404 | ❌ | Client mistake — same request will fail |
| HTTP 409 Conflict | ❌ | State conflict — needs user action |
| POST creating a resource (non-idempotent) | ⚠️ | Only if server supports idempotency keys |
function isRetryable(err) {
if (err.name === 'AbortError') return true;
if (!err.status) return true; // network-level
if (err.status === 429) return true;
if (err.status >= 500) return true;
return false;
}
Với 429, tuân Retry-After trước backoff tự tính:
function retryAfterMs(res) {
const h = res.headers.get('Retry-After');
if (!h) return null;
const sec = Number(h);
if (!Number.isNaN(sec)) return sec * 1000;
const date = Date.parse(h);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
Tính idempotent: retry an toàn với GET, HEAD, PUT, DELETE (thường). Với POST thanh toán/đơn hàng, gửi header
Idempotency-Key— nếu không, retry có thể trừ tiền hai lần.
Cuộc đua stale response
Search-as-you-type là failure mode kinh điển. user gõ "a" → request #1 chậm. rồi "ab" → request #2 nhanh. #2 render kết quả "ab". rồi #1 về và ghi đè data "a" cũ. User thấy kết quả sai mà không có lỗi.
Hai cách fix — thường kết hợp:
Fix A — Abort request trước
let ctrl = null;
input.addEventListener('input', async (e) => {
ctrl?.abort();
ctrl = new AbortController();
const q = e.target.value;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: ctrl.signal,
});
const data = await res.json();
render(data);
} catch (err) {
if (err.name === 'AbortError') return; // superseded — ignore
showError(err);
}
});
Fix B — Bỏ qua response cũ theo request id
Abort không lúc nào cũng được (browser cũ, một số thư viện). Theo dõi id mới nhất thay thế:
let latestId = 0;
async function search(q) {
const id = ++latestId;
const data = await fetchJSON(`/api/search?q=${encodeURIComponent(q)}`);
if (id !== latestId) return; // stale — discard
render(data);
}
Trong React, kết hợp useEffect cleanup abort khi unmount hoặc deps đổi:
useEffect(() => {
const ctrl = new AbortController();
fetchJSON('/api/profile', { signal: ctrl.signal })
.then(setProfile)
.catch((e) => {
if (e.name !== 'AbortError') setError(e);
});
return () => ctrl.abort();
}, [userId]);
Dedup request đang bay
Hai component mount cùng lúc đều fetch /api/config. Không dedup thì trả giá gấp đôi. Giữ Map<key, Promise> các request đang bay:
const inflight = new Map();
function dedupedFetch(key, fn) {
if (inflight.has(key)) return inflight.get(key);
const p = fn().finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
// usage
const config = await dedupedFetch('config', () =>
fetchJSON('/api/config')
);
Đây không phải cache — chỉ gộp các gọi giống nhau đồng thời. Với TTL cache xem HTTP cache headers hoặc thư viện data. Ở đây tập trung transport resilience từ đầu.
Phân loại lỗi & xử lý cho user
Không phải lỗi nào cũng cần toast. Phân loại trước:
| Class | Example | UX |
|---|---|---|
| Cancelled | AbortError, unmount | Silent — no UI |
| Offline / timeout | network fail, timeout | Retry button + offline banner |
| Auth | 401 | Redirect to login |
| Forbidden | 403 | Explain lack of permission |
| Not found | 404 | Empty state |
| Validation | 422 + field errors | Inline form errors |
| Rate limit | 429 | Backoff message, disable submit briefly |
| Server | 5xx | Generic error + support link |
function classifyFetchError(err) {
if (err.name === 'AbortError') return 'cancelled';
if (!err.status) return 'network';
if (err.status === 401) return 'auth';
if (err.status === 403) return 'forbidden';
if (err.status === 404) return 'not_found';
if (err.status === 422) return 'validation';
if (err.status === 429) return 'rate_limit';
if (err.status >= 500) return 'server';
return 'client';
}
Viết copy có thể hành động: “"Kết nối hết thời gian — kiểm tra mạng và thử lại" tốt hơn "Error 0".
credentials — cookie cross-origin
Mặc định là credentials: 'same-origin'.
await fetch('https://api.example.com/me', {
credentials: 'include',
});
Cấu hình sai gây vòng 401 im lặng hoặc lỗi CORS trên console. Bài này không giải thích lại cơ chế CORS — chỉ nút phía fetch.
Streaming response (ngắn gọn)
Với log NDJSON, feed kiểu SSE, hay stream token LLM, bỏ res.json(), đọc stream:
const res = await fetch('/api/stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
appendChunk(decoder.decode(value, { stream: true }));
}
Kết hợp AbortController để điều hướng hủy reader. Backpressure quan trọng với download lớn — cân nhắc response.body.pipeTo(writable) trên browser hiện đại.
Wrapper fetchJSON tái sử dụng
Gom các pattern lại. Wrapper xử lý timeout, lỗi HTTP, parse JSON, retry, và gộp signal tùy chọn:
class FetchError extends Error {
constructor(message, { status, body, cause } = {}) {
super(message, { cause });
this.name = 'FetchError';
this.status = status;
this.body = body;
}
}
async function fetchJSON(url, opts = {}) {
const {
timeoutMs = 8_000,
maxRetries = 2,
signal,
parse = true,
...init
} = opts;
let lastErr;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
signal?.addEventListener('abort', () => ctrl.abort(), { once: true });
try {
const res = await fetch(url, { ...init, signal: ctrl.signal });
const text = await res.text();
const data = text && parse ? JSON.parse(text) : text;
if (!res.ok) {
throw new FetchError(`HTTP ${res.status}`, {
status: res.status,
body: data,
});
}
return data;
} catch (err) {
lastErr = err;
const retryable =
err.name === 'AbortError' ||
!(err instanceof FetchError) ||
err.status === 429 ||
(err.status >= 500 && err.status <= 599);
if (!retryable || attempt === maxRetries) throw err;
await new Promise((r) => setTimeout(r, backoffMs(attempt)));
} finally {
clearTimeout(timer);
}
}
throw lastErr;
}
Dùng trong search hook có bảo vệ stale:
let searchCtrl = null;
let searchGen = 0;
async function onQueryChange(q) {
searchCtrl?.abort();
searchCtrl = new AbortController();
const gen = ++searchGen;
try {
const hits = await fetchJSON(`/api/search?q=${encodeURIComponent(q)}`, {
signal: searchCtrl.signal,
timeoutMs: 5_000,
maxRetries: 1,
});
if (gen !== searchGen) return;
renderHits(hits);
} catch (err) {
if (err.name === 'AbortError') return;
showSearchError(classifyFetchError(err), err);
}
}
Checklist trước khi ship
- Kiểm tra
res.okmọi response - Timeout mọi request do user khởi tạo
- Chỉ retry lỗi retryable, có backoff + jitter
- Abort hoặc bỏ qua stale response trong typeahead
- Abort khi unmount component / đổi route
- Dedup GET giống nhau đang bay
- Map lỗi sang UX có thể hành động
- Tuân
Retry-Afterkhi 429 - Idempotency key cho POST mutate có retry
Kết luận
fetch đưa bytes qua HTTP. Frontend resilient thêm policy: khi nào bỏ cuộc, khi nào thử lại, response nào thắng, user thấy gì khi fail. Demo trên cho bạn cảm nhận timeout, backoff, và stale race mà không cần deploy production. Bắt đầu từ checklist, bọc một lần bằng fetchJSON, và giữ guard stale response trên mọi input debounce.