jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 fetch như 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();
Situationfetch PromiseWhat you must do
DNS failure, offline, CORS blockrejectscatch network error
HTTP 4xx / 5xxresolves with res.ok === falsecheck res.ok or res.status
HTTP 204 No Contentresolves, body emptyskip .json()
Redirect 301/302resolves after followinginspect 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.bodyReadableStream. 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?

ErrorRetry?Why
Network offline, AbortError (timeout)Transient
HTTP 429 Too Many RequestsRespect Retry-After header
HTTP 502 / 503 / 504Upstream glitch
HTTP 408 Request TimeoutServer gave up first
HTTP 400 / 401 / 403 / 404Client mistake — same request will fail
HTTP 409 ConflictState 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:

ClassExampleUX
CancelledAbortError, unmountSilent — no UI
Offline / timeoutnetwork fail, timeoutRetry button + offline banner
Auth401Redirect to login
Forbidden403Explain lack of permission
Not found404Empty state
Validation422 + field errorsInline form errors
Rate limit429Backoff message, disable submit briefly
Server5xxGeneric 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".


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.ok mọ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-After khi 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.