AbortController không chỉ để huỷ fetch — nó cứu bạn khỏi race condition
Ô search sản phẩm gọi API theo từng lần user gõ. User nhập ip, rồi rất nhanh nhập tiếp thành iphone. Request iphone đi sau nhưng lại trả về trước. Vài trăm mili giây sau, request ip cũ mới về và overwrite state. UI bây giờ hiển thị kết quả sai.
Đây là race condition frontend rất điển hình. Và nó xảy ra thường xuyên hơn nhiều team nghĩ.
Vấn đề không phải fetch “không ổn định”. Vấn đề là app không quản lý lifecycle của request.
Bug nhìn vô hại nhưng tác động thật
Code kiểu này có ở khắp nơi:
let results = [];
async function search(query) {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
results = await res.json();
render(results);
}User gõ liên tục:
search('ip')search('iph')search('iphone')
Không có gì đảm bảo response sẽ quay về đúng thứ tự đã gửi.
Kết quả:
- UI flicker
- dữ liệu stale overwrite dữ liệu mới
- loading state nhảy loạn
- analytics và logs khó đọc vì nhìn như backend trả sai
Fix nửa mùa: bỏ qua response cũ bằng request id
Nhiều team làm kiểu này:
let currentRequestId = 0;
async function search(query) {
const requestId = ++currentRequestId;
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
if (requestId !== currentRequestId) return;
render(data);
}Cách này tránh stale response overwrite UI. Tốt hơn không có gì. Nhưng nó vẫn có vấn đề:
- request cũ vẫn chạy đến cùng
- bandwidth vẫn bị đốt
- server vẫn xử lý request vô ích
- loading/error state vẫn phải tự cẩn thận
Bạn đã ngăn hậu quả ở UI, nhưng chưa huỷ nguyên nhân.
AbortController: huỷ request cũ thật sự
Browser có API đúng bài toán này:
let controller;
async function search(query) {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data = await res.json();
render(data);
} catch (err) {
if (err.name === 'AbortError') return;
throw err;
}
}Mỗi lần search mới bắt đầu:
- request trước đó bị huỷ
- fetch reject với
AbortError - chỉ request mới nhất còn sống
Đây là model đúng cho live search, autocomplete, tab switch, filter panel và mọi UI mà “request mới làm request cũ trở nên vô nghĩa”.
abort() không phải rollback magic
Một hiểu lầm phổ biến: gọi abort() là mọi thứ phía server cũng biến mất.
Không hẳn.
AbortController đảm bảo:
- browser ngừng chờ response
- browser có thể dừng download body
- promise ở client kết thúc sớm
Nó không đảm bảo backend đã ngừng xử lý business logic. Nếu request đã tới server và server đang query DB, việc server có dừng hay không còn phụ thuộc hạ tầng và implementation phía backend.
Nhưng với frontend, chừng đó đã đủ giá trị:
- UI không bị stale overwrite
- app không tốn công parse body không cần thiết
- resource ở client được giải phóng sớm hơn
React: cleanup trong useEffect là nơi đúng để abort
Pattern rất phổ biến:
useEffect(() => {
const controller = new AbortController();
async function loadProduct() {
try {
const res = await fetch(`/api/products/${productId}`, {
signal: controller.signal,
});
const data = await res.json();
setProduct(data);
} catch (err) {
if (err.name === 'AbortError') return;
setError(err);
}
}
loadProduct();
return () => controller.abort();
}, [productId]);Khi productId đổi hoặc component unmount:
- request cũ bị huỷ
- state của screen mới không bị request cũ chạm vào
- tránh warning kiểu update state sau unmount
Nếu không cleanup, bug có thể chỉ xuất hiện ngẫu nhiên khi user click rất nhanh hoặc mạng đủ chậm. Đó là loại bug khó tái hiện nhất.
Không phải request nào cũng nên abort
Abort có ý nghĩa khi request cũ không còn giá trị.
Nên abort:
- live search
- autosuggest
- filter/sort thay đổi liên tục
- điều hướng sang entity khác
- infinite scroll nếu user đổi view đột ngột
Không nhất thiết abort:
- mutation quan trọng như
POST /checkout - log/telemetry cần gửi best-effort
- upload file đang diễn ra mà user vẫn muốn tiếp tục nền
Rule đơn giản:
- request mới thay thế request cũ → abort
- request mới không thay thế request cũ → cân nhắc
Race condition không chỉ đến từ network
Ngay cả khi bạn abort đúng, vẫn còn một loại stale update khác:
const res = await fetch(url, { signal });
const data = await res.json();
const filtered = expensiveNormalize(data);
render(filtered);Nếu abort xảy ra sau khi fetch() xong nhưng trước khi expensiveNormalize() kết thúc, bạn vẫn có thể mất thời gian xử lý dữ liệu không còn cần.
Với flow nặng, nên check signal thêm:
async function load(url, signal) {
const res = await fetch(url, { signal });
const data = await res.json();
if (signal.aborted) return;
const normalized = expensiveNormalize(data);
if (signal.aborted) return;
render(normalized);
}Abort không chỉ là network concern. Nó là lifecycle signal cho cả pipeline xử lý dữ liệu.
Gộp nhiều request vào cùng một signal
Một interaction đôi khi kéo theo nhiều request:
const controller = new AbortController();
const { signal } = controller;
const productPromise = fetch('/api/product/42', { signal });
const reviewsPromise = fetch('/api/product/42/reviews', { signal });
const relatedPromise = fetch('/api/product/42/related', { signal });Khi user rời màn hình sản phẩm:
controller.abort();Toàn bộ group request bị huỷ cùng lúc. Đây là pattern tốt cho page-level data loading.
Timeout cũng là một dạng abort
Một request treo 20 giây không hẳn sai logic, nhưng sai UX.
Bạn có thể tự đặt timeout:
function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal })
.finally(() => clearTimeout(id));
}Use case:
- search/autocomplete nên fail nhanh
- suggestion API không đáng để user đợi quá lâu
- nội dung phụ ở sidebar không nên giữ spinner vô tận
Timeout ngắn đúng chỗ thường tốt hơn “cứ chờ mãi rồi hy vọng”.
Loading state cũng cần theo request lifecycle
Một bug khác:
setLoading(true);
try {
const res = await fetch(url, { signal });
const data = await res.json();
render(data);
} finally {
setLoading(false);
}Nếu request A bị abort vì request B bắt đầu, finally của A có thể tắt loading của B nếu state management không tách bạch.
Fix là gắn loading theo request hiện hành, hoặc để layer query quản lý thay vì boolean global quá ngây thơ.
AbortController giúp rất nhiều, nhưng nếu state model mơ hồ thì race condition vẫn quay lại theo đường khác.
Kiến trúc thực tế cho request-driven UI
Cho các UI nhiều tương tác, model an toàn thường là:
- request mới abort request cũ
- response chỉ commit nếu còn là request hiện hành
- cleanup xảy ra ở unmount hoặc dependency change
- loading/error gắn với request lifecycle, không phải một boolean toàn cục
Pseudo-code:
let controller = null;
let requestSeq = 0;
async function runSearch(query) {
controller?.abort();
controller = new AbortController();
const seq = ++requestSeq;
try {
setLoading(true);
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const data = await res.json();
if (controller.signal.aborted) return;
if (seq !== requestSeq) return;
render(data);
} catch (err) {
if (err.name === 'AbortError') return;
if (seq !== requestSeq) return;
showError(err);
} finally {
if (seq === requestSeq) setLoading(false);
}
}Có vẻ verbose. Nhưng nó explicit và đúng. Còn race condition thì không tự biến mất chỉ vì code ngắn.
Frontend hiện đại không chỉ là “fetch rồi setState”. Khi user tương tác nhanh, request lifecycle là một phần của UX. AbortController đáng để coi là primitive mặc định, không phải edge-case API.