Có một bug xuất hiện ở hầu hết dự án frontend: user gõ ô search, mỗi keystroke bắn một request, request chậm đến sau request nhanh, UI flash kết quả cũ đè lên kết quả mới. User gõ “react” nhưng thấy kết quả cho “rea” vì response cho “rea” đến sau cùng. Race condition kinh điển, và giải pháp đúng không phải debounce (debounce chỉ giảm tần suất, không xử lý thứ tự response).
Giải pháp đúng là huỷ request cũ trước khi gửi request mới. Và API chuẩn để làm việc đó trong JavaScript là AbortController, xuất hiện từ 2017, được fetch hỗ trợ cùng thời, nhưng đến nay vẫn bị rất nhiều codebase bỏ qua hoặc dùng sai.
Bài này đi từ pattern cơ bản đến các API mới như AbortSignal.timeout và AbortSignal.any, cách tích hợp trong React/Vue, và những bẫy tinh vi hay gặp.
Controller và Signal: mô hình cancel
AbortController hoạt động theo mô hình producer-consumer. Controller là người phát lệnh huỷ, Signal là kênh truyền lệnh đó đến consumer.
const controller = new AbortController();
const signal = controller.signal;
fetch("/api/data", { signal })
.then((res) => res.json())
.then((data) => console.log(data))
.catch((err) => {
if (err.name === "AbortError") {
console.log("Request bị huỷ");
} else {
throw err;
}
});
// Huỷ sau 5 giây
setTimeout(() => controller.abort(), 5000);
Khi controller.abort() được gọi, signal chuyển sang trạng thái aborted (signal.aborted = true), event abort fire trên signal, và mọi API đang lắng nghe signal đó sẽ dừng. Với fetch, promise reject với DOMException có name === "AbortError".
Kiểm tra err.name === "AbortError" để phân biệt lỗi huỷ (do code chủ động) với lỗi mạng thật (cần báo user). Đây là pattern mà bạn sẽ thấy xuyên suốt bài, mọi chỗ catch AbortError đều cần filter.
Từ 2022, abort() nhận thêm reason, bất kỳ giá trị nào:
controller.abort(new Error("User navigated away"));
// signal.reason === Error("User navigated away")
Reason giúp phân biệt lý do huỷ, timeout khác user cancel khác component unmount. Khi có monitoring, log reason giúp debug nhanh hơn nhiều so với chỉ thấy “AbortError”.
Timeout: ba cách làm
Cách thủ công
Trước khi có AbortSignal.timeout, cách làm thủ công:
async function fetchWithTimeout(url, { timeout = 5000, ...options } = {}) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(new Error("timeout")), timeout);
try {
const res = await fetch(url, {
...options,
signal: controller.signal,
});
return res;
} finally {
clearTimeout(id);
}
}
finally block clear timer, nếu fetch xong trước timeout, timer không “lủng lẳng” giữ reference. Đây là detail nhỏ nhưng quan trọng: timer chưa clear giữ closure → giữ controller → giữ request state trong memory.
AbortSignal.timeout, gọn hơn
Từ Chrome 103, Firefox 100, Safari 15.4, Node 17.3:
const res = await fetch("/api/data", {
signal: AbortSignal.timeout(5000),
});
Browser tạo signal, set timer nội bộ, tự clean up khi request kết thúc hoặc timeout. Một dòng thay cả function. Cách này phù hợp cho mọi fetch đơn giản, gọn và engine optimize tốt hơn timer thủ công.
Nhưng AbortSignal.timeout không cho bạn cancel thủ công, nếu user click nút “Huỷ” thì signal timeout vẫn chờ đủ 5 giây. Khi cần kết hợp timeout với cancel thủ công, dùng cách tiếp theo.
AbortSignal.any, kết hợp nhiều tín hiệu
API mới nhất (chuẩn hóa 2024, hỗ trợ browser hiện đại): tạo signal tổng hợp, abort khi bất kỳ signal con nào abort.
const userCtrl = new AbortController();
const unmountCtrl = new AbortController();
const signal = AbortSignal.any([
userCtrl.signal, // user click cancel
unmountCtrl.signal, // component unmount
AbortSignal.timeout(5000), // timeout 5s
]);
fetch(url, { signal });
Request bị huỷ khi user cancel, HOẶC component unmount, HOẶC quá 5 giây, bất cứ cái nào xảy ra trước. Đây là pattern mạnh nhất, một signal gom hết các lý do huỷ, code fetch chỉ cần catch AbortError một chỗ.
Nếu cần hỗ trợ browser cũ, polyfill đơn giản:
function anySignal(signals) {
const ctrl = new AbortController();
for (const s of signals) {
if (s.aborted) {
ctrl.abort(s.reason);
break;
}
s.addEventListener("abort", () => ctrl.abort(s.reason), {
once: true,
});
}
return ctrl.signal;
}
Race condition: huỷ request cũ trước khi gửi mới
Quay lại vấn đề search ở đầu bài. User gõ “a”, request bay đi. Gõ thêm “b”, request mới cho “ab” bay đi. Nếu response cho “a” đến sau “ab”, UI flash kết quả sai.
Giải pháp: huỷ request trước khi gửi request mới cho cùng data.
let controller = null;
async function search(query) {
controller?.abort(); // huỷ request cũ
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
return await res.json();
} catch (err) {
if (err.name === "AbortError") return null; // bỏ qua, có request mới rồi
throw err;
}
}
Mỗi lần search() được gọi, request cũ bị huỷ ngay. Response cũ reject với AbortError, handler return null, không set state. Chỉ response của request mới nhất mới đi đến set state. Hết race condition.
Nên kết hợp pattern này với debounce, debounce giảm tần suất gọi search (user gõ nhanh thì chờ 300ms), abort controller đảm bảo thứ tự đúng nếu có request overlap. Hai thứ bổ sung nhau, không thay thế nhau.
Trong React
useEffect cleanup
Đây là pattern cơ bản nhất, abort request khi effect re-run hoặc component unmount:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((r) => r.json())
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [id]);
Khi id thay đổi, cleanup chạy trước, abort request cũ, rồi effect mới chạy với id mới. Khi component unmount, cleanup chạy, abort request đang pending. Không còn warning “setState on unmounted component”.
Event handler
Fetch trong event handler không có cleanup tự động của useEffect. Lưu controller trong ref:
const ctrlRef = useRef<AbortController | null>(null);
async function handleSearch(q: string) {
ctrlRef.current?.abort();
ctrlRef.current = new AbortController();
try {
const res = await fetch(`/api/search?q=${q}`, {
signal: ctrlRef.current.signal,
});
setResults(await res.json());
} catch (err) {
if ((err as Error).name !== "AbortError") setError(err as Error);
}
}
// Cleanup khi unmount
useEffect(() => () => ctrlRef.current?.abort(), []);
useRef giữ controller qua render cycle mà không trigger re-render. useEffect cleanup abort khi unmount, đảm bảo không có request lủng lẳng.
React 18 Strict Mode
Strict Mode mount → unmount → mount effect hai lần trong dev. Cleanup abort() chạy giữa hai lần mount, request đầu tiên bị huỷ. Developer có thể thấy request bị cancel trong DevTools, đó là đúng hành vi, code đang cleanup chuẩn. Production chỉ chạy một lần.
React Query, SWR, TanStack Query
Các thư viện data fetching hiện đại nhận signal trong fetcher function:
useQuery({
queryKey: ["user", id],
queryFn: ({ signal }) =>
fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
});
Thư viện tự abort khi query key thay đổi hoặc component unmount. Nếu dùng React Query hay SWR, bạn gần như không cần tự quản lý AbortController, chỉ cần truyền signal vào fetch. Nên dùng thư viện cho data fetching phức tạp, chúng xử lý caching, deduplication, retry, stale-while-revalidate, tất cả cùng abort logic.
Trong Vue
Composition API
import { onBeforeUnmount, ref, watch } from "vue";
const ctrl = ref<AbortController | null>(null);
async function load(id: string) {
ctrl.value?.abort();
ctrl.value = new AbortController();
const res = await fetch(`/api/users/${id}`, {
signal: ctrl.value.signal,
});
return res.json();
}
watch(userId, (id) => load(id), { immediate: true });
onBeforeUnmount(() => ctrl.value?.abort());
Pattern tương tự React, abort khi data source thay đổi hoặc component destroy. TanStack Query cho Vue và VueUse cũng hỗ trợ signal, nên dùng thay vì tự quản lý.
AbortSignal ngoài fetch
AbortSignal là chuẩn cancel chung của platform, không chỉ cho fetch. Nhiều API hỗ trợ, và nhiều dev không biết điều này.
addEventListener
const ctrl = new AbortController();
element.addEventListener("click", handler, { signal: ctrl.signal });
element.addEventListener("keydown", handler2, { signal: ctrl.signal });
// ...
ctrl.abort(); // remove cả hai listener cùng lúc
Một abort() bỏ hết listener đăng ký cùng signal. Tiện hơn nhiều so với lưu reference từng handler rồi gọi removeEventListener từng cái. Pattern này hữu ích cho cleanup phức tạp, ví dụ modal dialog có 5-6 listener, đóng modal thì abort() một lần.
Streams
ReadableStream, WritableStream nhận signal trong một số API, abort cắt stream giữa chừng. Hữu ích khi download file lớn mà user cancel.
Node.js
Node.js áp dụng AbortSignal rộng rãi: fs/promises.readFile(path, { signal }), events.on(emitter, 'event', { signal }), child_process.spawn, timersPromises.setTimeout(ms, val, { signal }). Cùng một controller có thể huỷ cả file read, event listener, và child process, cancel model thống nhất.
Những bẫy tinh vi
AbortError không phải lỗi thật
Đừng để Sentry, Datadog hay error monitoring báo động khi user navigate đi, AbortError là tín hiệu huỷ chủ động, không phải lỗi:
if (err.name !== "AbortError") reportError(err);
Nhưng phân biệt timeout abort với user cancel abort, bạn có thể muốn theo dõi tỷ lệ timeout (chỉ dấu server chậm hoặc network issue). Dùng signal.reason để phân loại: controller.abort(new TimeoutError()) vs controller.abort(new Error("user cancel")).
Body stream cũng bị abort
Fetch có hai phase: gửi request/nhận header, rồi đọc body. Abort ở phase đầu cancel connection. Nhưng abort sau khi response header đã nhận, body đang stream? Body stream bị cắt, res.json() throw AbortError:
const res = await fetch(url, { signal }); // resolve rồi
// abort ở đây → res.json() throw AbortError
const data = await res.json();
Try/catch phải trùm cả phần parse body, không chỉ fetch call.
Abort sau khi xong thì vô hại
Nếu fetch đã resolve, body đã đọc xong, abort() không làm gì, request đã hoàn tất. Không error, không side effect. Nên đừng lo gọi abort “quá muộn”, nó safe.
Delay có thể huỷ
Browser setTimeout không nhận signal. Nếu cần delay mà abort được (ví dụ retry backoff), bọc Promise:
function delayAbort(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true }
);
});
}
Node.js thì dùng import { setTimeout as delay } from "timers/promises", nhận signal native.
Loop phải check signal
while (!signal.aborted) {
await longWork(signal);
}
longWork phải nhận signal và reject khi abort, nếu không, longWork chạy mãi mà signal.aborted check ở đầu loop không bao giờ đánh giá lại. Luôn truyền signal vào function con.
Retry phải check abort
Khi implement retry logic, check signal trước mỗi lần retry. Nếu đã abort, đừng retry:
async function retrying(url, { signal, retries = 3 }) {
for (let i = 0; i <= retries; i++) {
if (signal?.aborted)
throw signal.reason ?? new DOMException("aborted", "AbortError");
try {
return await fetch(url, { signal });
} catch (err) {
if (err.name === "AbortError") throw err; // don't retry abort
if (i === retries) throw err;
await delayAbort(2 ** i * 100, signal);
}
}
}
Abort trong retry mà không check → retry vô nghĩa, tốn resource, delay response cho user.
Testing
Test abort logic không phức tạp nhưng hay bị bỏ qua.
Unit test cancel: tạo controller, start fetch, abort ngay, assert promise reject với AbortError. Đơn giản nhưng confirm code handle AbortError đúng.
Timeout test: dùng fake timers (Jest, Vitest), advance time qua timeout, assert promise reject.
Race condition test: mock hai fetch với delay khác nhau (response A chậm 500ms, response B nhanh 100ms), gọi A rồi B, assert state cuối là kết quả B. Đây là test quan trọng nhất, confirm abort logic thực sự chặn stale response.
Tóm tắt
AbortController và AbortSignal là chuẩn cancel chung cho JavaScript, fetch, event listener, stream, Node.js IO. Luôn truyền signal vào fetch, không truyền thì không huỷ được, request lủng lẳng tốn resource.
Timeout: dùng AbortSignal.timeout(ms) cho case đơn giản, AbortSignal.any() khi cần kết hợp timeout với cancel thủ công. Race condition: abort request cũ trước khi gửi mới, debounce giảm tần suất, abort đảm bảo thứ tự.
Trong React: abort trong useEffect cleanup và useRef cho event handler. Trong Vue: abort trong onBeforeUnmount và watch. Hoặc dùng React Query, SWR, TanStack Query, chúng tự xử lý.
AbortError là tín hiệu chủ động, không phải lỗi, filter khỏi error monitoring. Nhưng theo dõi tỷ lệ timeout vì đó là metric về health của backend.
Một codebase dùng AbortController xuyên suốt sẽ ít race condition, ít stale state, ít memory leak, ít noise trong error tracking. Không phải optimization lớn tiếng, chỉ là thói quen tốt tạo ra sản phẩm mượt mà hơn.