AbortController xuất hiện trong tiêu chuẩn DOM từ 2017, được fetch hỗ trợ cùng thời, nhưng rất nhiều code frontend vẫn bỏ qua — hoặc dùng sai. Hậu quả: tab ngốn CPU vì pending request không ai muốn, UI flash state cũ vì race condition, timeout dựa vào setTimeout tự chế thiếu edge case.
Bài này đi sâu: cách AbortController/AbortSignal hoạt động, các pattern chuẩn cho timeout và cancel, cách dùng AbortSignal.timeout và AbortSignal.any (các API mới), và các bẫy khi kết hợp với React/Vue hook.
1. Mô hình: controller sinh signal
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 was aborted");
} else {
throw err;
}
});
setTimeout(() => controller.abort(), 5000);
AbortControllerphát tín hiệu huỷ quaabort().AbortSignallắng nghe. Các API (fetch, addEventListener, streams) nhận signal và chủ động dừng khi signal bị abort.- Signal bị abort → event
aborttrigger;signal.aborted = true. - Fetch đang pending → promise reject với
AbortError(DOMException).
AbortError.name === 'AbortError' để phân biệt lỗi huỷ với lỗi mạng thật.
1.1. Có reason
Từ 2022, abort(reason) nhận một tham số bất kỳ:
controller.abort(new Error("User navigated away"));
// err.name === 'AbortError' vẫn đúng, nhưng thêm err.cause hoặc rethrow reason
// signal.reason === <Error 'User navigated away'>
Ứng dụng: log rõ lý do huỷ, phân biệt timeout vs user cancel.
2. Timeout chuẩn
2.1. Tự viết
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);
}
}
- Dùng
finallyđể clear timer — nếu fetch xong trước timeout, không để timer “lủng lẳng” giữ reference. - Đừng bỏ
signal— fetch vẫn chạy dù ta “bỏ quên” promise.
2.2. AbortSignal.timeout (mới, gọn hơn)
const res = await fetch("/api/data", { signal: AbortSignal.timeout(5000) });
- Browser tạo signal, set timer, clean up khi request kết thúc.
- Hỗ trợ: tất cả trình duyệt hiện đại (Chrome 103+, Firefox 100+, Safari 15.4+), Node 17.3+, Deno, Bun.
- Gọn cho trường hợp timeout đơn thuần. Không dùng được nếu bạn còn muốn cancel thủ công.
2.3. AbortSignal.any — kết hợp nhiều tín hiệu
Bạn muốn request bị huỷ khi một trong vài điều xảy ra: timeout, user click cancel, component unmount.
const userCtrl = new AbortController();
const unmountCtrl = new AbortController();
const signal = AbortSignal.any([
userCtrl.signal,
unmountCtrl.signal,
AbortSignal.timeout(5000),
]);
fetch(url, { signal });
Signal tổng bị abort nếu bất kỳ signal con bị abort. AbortSignal.any chuẩn hóa gần đây (2024), hỗ trợ trình duyệt hiện đại. Polyfill đơn giản nếu cầ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;
}
3. Race condition khi state thay đổi
3.1. Vấn đề
User search “a”, rồi sửa thành “ab” nhanh. Hai request bay: /search?q=a và /search?q=ab. Nếu request thứ nhất đến sau (chậm mạng), UI hiển thị kết quả của a sau khi đã nhập ab → lừa người dùng.
3.2. Giải pháp
Huỷ request cũ trước khi gửi mới:
let controller = null;
async function search(query) {
controller?.abort();
controller = new AbortController();
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
return await res.json();
} catch (err) {
if (err.name === "AbortError") return null;
throw err;
}
}
Lần thứ 2 gọi search('ab') → request a bị huỷ, không kịp set state.
4. Trong React
4.1. Pattern cơ bản trong useEffect
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]);
- Cleanup
controller.abort()khi effect re-run (id đổi) hoặc component unmount. - Tránh set state trên component đã unmount — warning kinh điển.
4.2. Với fetch trong event handler
Không dùng được cleanup 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(), []);
4.3. React 18 Strict Mode
Strict Mode mount-unmount-mount effect hai lần trong dev. Cleanup abort() sẽ chạy → dev có thể thấy request bị huỷ ngay. Đó là đúng — code bạn đang xử lý cleanup chuẩn; production chỉ chạy một lần.
4.4. React Query / SWR
Các thư viện data fetching hiện đại nhận signal trong fetcher:
useQuery({
queryKey: ["user", id],
queryFn: ({ signal }) =>
fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
});
Thư viện tự huỷ request khi query key đổi hoặc component unmount. Dùng nó tránh phải tự quản lý controller.
5. Trong Vue
5.1. 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());
5.2. VueUse / TanStack Query for Vue
Tương tự React — dùng lib để khỏi tự quản.
6. Abort ngoài fetch
AbortSignal là chuẩn chung, nhiều API hỗ trợ:
6.1. addEventListener với signal
const ctrl = new AbortController();
element.addEventListener("click", handler, { signal: ctrl.signal });
// ...
ctrl.abort(); // auto remove listener
Tiện khi có nhiều listener: một ctrl.abort() bỏ hết cùng lúc. Rất hợp cho cleanup.
6.2. Streams
ReadableStream, WritableStream nhận signal trong một số API — huỷ đọc/ghi.
6.3. Node.js
fs/promises.readFile(path, { signal }).events.on(emitter, 'event', { signal }).child_process.spawn,setTimeout(timersPromises.setTimeout(ms, val, { signal })).
Thống nhất cancel model: cùng một AbortController có thể huỷ nhiều operation.
7. Bẫy tinh vi
7.1. AbortError không phải lỗi
Đừng để Sentry/monitor báo động khi người dùng navigate đi — lọc:
if (err.name !== "AbortError") reportError(err);
Nhưng phân biệt với timeout (cũng là AbortError): bạn có thể muốn theo dõi tỷ lệ timeout. Dùng controller.abort(new TimeoutError()) và kiểm err.cause hoặc signal.reason.
7.2. fetch resolved rồi mới đọc body
const res = await fetch(url, { signal });
// signal abort ở đây: body stream bị cắt, res.json() throw AbortError
const data = await res.json();
Huỷ không chỉ cancel connection — còn cancel cả body reading. Đảm bảo try/catch trọn phần parse.
7.3. Abort sau khi response xong
Nếu fetch đã resolve và body đã đọc xong, abort() không có tác dụng — request đã hoàn tất. OK, không error, chỉ không hiệu quả — đừng trông cậy.
7.4. Signal ở setTimeout polyfill
Không phải mọi polyfill hỗ trợ signal option cho setTimeout. Node dùng timersPromises.setTimeout(ms, value, { signal }):
import { setTimeout as delay } from "timers/promises";
await delay(1000, undefined, { signal: ctrl.signal });
Browser setTimeout không nhận signal; nếu muốn delay hủy được, 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 }
);
});
}
7.5. Infinite loop với abort trong while
while (!signal.aborted) {
await longWork(signal);
}
longWork phải nhận signal và reject khi abort — không thì loop vô tận sau khi abort. Luôn truyền signal vào function con.
7.6. Retry và abort
Khi retry fetch, 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;
if (i === retries) throw err;
await delayAbort(2 ** i * 100, signal);
}
}
}
8. Testing
8.1. Unit test cancel
test("cancels in-flight fetch", async () => {
const ctrl = new AbortController();
const p = fetch("/slow", { signal: ctrl.signal });
ctrl.abort();
await expect(p).rejects.toThrow(/aborted/i);
});
8.2. Timeout test
Dùng mock fake timers; đảm bảo sau timeout ms, promise reject.
8.3. Race condition test
Mô phỏng 2 fetch với delay khác nhau; assert state cuối = kết quả của fetch mới nhất.
9. Hiệu năng và quản lý tài nguyên
- Request bị huỷ sớm giảm tải server (server có thể phát hiện client disconnect và dừng query DB).
- Tab không đóng request → giữ kết nối HTTP/2, tốn slot, ngăn reuse.
- Nhiều component trên trang → nhiều controller; abort khi không cần tránh tích tụ.
- Với
keepalive: true(beacon-like fetch), request không bị abort khi tab đóng — đúng vậy, không dùng cho công việc user-centric.
10. Tóm tắt
AbortController/AbortSignallà chuẩn cancel chung cho fetch, listener, streams, Node IO.- Luôn truyền
signalvào fetch để huỷ hiệu quả. - Timeout: ưu tiên
AbortSignal.timeout(ms); tự viết khi cần kết hợp logic khác. - Combine:
AbortSignal.any([...])để huỷ khi bất kỳ tín hiệu xảy ra. - Trong React/Vue: abort trong cleanup effect/unmount để tránh state trên component đã unmount.
- Race condition: luôn huỷ request cũ trước khi gửi mới cho cùng dữ liệu.
AbortErrorlà tín hiệu chứ không phải lỗi — lọc khỏi báo lỗi, nhưng vẫn theo dõi tỷ lệ timeout.- Dùng React Query / TanStack Query / SWR / VueUse nếu có thể — chúng tự xử lý phần lớn.
Một codebase dùng AbortController xuyên suốt sẽ có ít state flash, ít memory leak, ít noise trong error tracking. Đó không phải optimization lớn tiếng — chỉ là thói quen đúng mà dần dà tạo ra sản phẩm cảm giác mượt hơn.