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.timeoutAbortSignal.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);
  • AbortController phát tín hiệu huỷ qua abort().
  • AbortSignal lắng nghe. Các API (fetch, addEventListener, streams) nhận signal và chủ động dừng khi signal bị abort.
  • Signal bị abort → event abort trigger; 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/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/AbortSignalchuẩn cancel chung cho fetch, listener, streams, Node IO.
  • Luôn truyền signal và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.
  • AbortError là 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.