If you’ve worked with fetch in React/Next.js, you’ve probably hit this: component unmounts or state changes, but your network request is still running. Then the response lands late and updates stale state or worse, throws “state update on unmounted component.” AbortController fixes this. It’s a tiny API that lets you cancel in-flight requests cleanly.
What is AbortController?
AbortController is a browser API. You create a controller, pass its signal to fetch, and call controller.abort() whenever you want to cancel. The fetch promise rejects with a DOMException named “AbortError”. That’s the whole story.
Quick
- new AbortController() → controller
- controller.signal → pass to fetch
- controller.abort() → cancel
- catch AbortError → don’t treat it as a failure
Basic Example (Vanilla fetch)
1const controller = new AbortController();
2try {
3 const res = await fetch("/api/data", {
4 signal: controller.signal
5 });
6 const json = await res.json();
7 console.log(json);
8} catch (err) {
9 if (err.name === "AbortError") {
10 console.log("Request aborted.");
11 } else {
12 console.error("Network error:", err);
13 }
14}
15// Somewhere else:
16controller.abort();React: Abort on Unmount
Classic pattern: start a request in useEffect, abort when the component unmounts or when deps change.
1import { useEffect, useState } from "react";
2
3export default function Users() {
4const [users, setUsers] = useState([]);
5const [error, setError] = useState(null);
6
7useEffect(() => {
8const controller = new AbortController();
9
10async function load() {
11try {
12const res = await fetch("/api/users", { signal: controller.signal });
13if (!res.ok) throw new Error(`HTTP ${res.status}`);
14const data = await res.json();
15setUsers(data);
16} catch (err) {
17if (err.name === "AbortError") return; // ignore
18setError(err.message);
19}
20}
21
22load();
23
24return () => controller.abort();
25}, []);
26
27if (error) return <p>Error: {error}</p>;
28return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
29}Abort on Rapid Input (Debounced Search)
If you fire a request on every keystroke, old requests should be canceled to avoid race conditions.
1import { useEffect, useState } from "react";
2
3export default function SearchBox() {
4const [q, setQ] = useState("");
5const [results, setResults] = useState([]);
6
7useEffect(() => {
8if (!q) {
9setResults([]);
10return;
11}
12
13const controller = new AbortController();
14const id = setTimeout(async () => {
15try {
16const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
17signal: controller.signal,
18});
19const json = await res.json();
20setResults(json);
21} catch (err) {
22if (err.name === "AbortError") return;
23console.error(err);
24}
25}, 300);
26
27return () => {
28controller.abort(); // cancel fetch
29clearTimeout(id); // cancel debounce
30};
31}, [q]);
32
33return (
34<div>
35<input value={q} onChange={e => setQ(e.target.value)} placeholder="Search..." />
36<ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>
37</div>
38);
39}Abort with Route Changes
When the route changes, you often want to cancel page-specific requests to avoid updating a page that’s no longer shown.
1"use client";
2import {
3 useEffect,
4 useState
5} from "react";
6import {
7 usePathname
8} from "next/navigation";
9export default function PageData() {
10 const pathname = usePathname();
11 const [data, setData] = useState(null);
12 useEffect(() => {
13 const controller = new AbortController();
14 async function load() {
15 try {
16 const res = await fetch(`/api/page-data?path=${pathname}`, {
17 signal: controller.signal,
18 cache: "no-store",
19 });
20 const json = await res.json();
21 setData(json);
22 } catch (err) {
23 if (err.name !== "AbortError") console.error(err);
24 }
25 }
26 load();
27 return () => controller.abort();
28 }, [pathname]);
29 if (!data) return < p > Loading... < /p>;
30 return < pre > {
31 JSON.stringify(data, null, 2)
32 } < /pre>;
33}Abort in Server Code (Node + fetch)
In Node the pattern is the same. You can add a timeout to auto-cancel slow requests.
1export async function fetchWithTimeout(url, opts = {}, ms = 5000) {
2 const controller = new AbortController();
3 const t = setTimeout(() => controller.abort(), ms);
4 try {
5 const res = await fetch(url, {
6 ...opts,
7 signal: controller.signal
8 });
9 return res;
10 } finally {
11 clearTimeout(t);
12 }
13}
14// usage
15try {
16 const res = await fetchWithTimeout("https://api.example.com/data", {}, 3000);
17 const json = await res.json();
18 console.log(json);
19} catch (err) {
20 if (err.name === "AbortError") {
21 console.warn("Timed out.");
22 } else {
23 console.error(err);
24 }
25}Abort Multiple Requests Together
You can share one controller across multiple fetches and abort them all at once.const controller = new AbortController();
1const p1 = fetch("/api/a", {
2 signal: controller.signal
3});
4const p2 = fetch("/api/b", {
5 signal: controller.signal
6});
7const p3 = fetch("/api/c", {
8 signal: controller.signal
9});
10// Cancel all
11controller.abort();Pitfalls and Gotchas
- Handle AbortError explicitly: treat it as “expected” not a failure.
- Don’t reuse an aborted controller: once aborted, the signal remains aborted—create a new one.
- Beware race conditions: cancel the older request before starting the new one when state changes.
- Streams: if you’re reading a ReadableStream, aborting closes the stream—clean up readers.
- Axios: axios has its own cancel token historically; modern axios supports AbortController via signal too.
Pattern I Like: Request Manager Hook
A reusable hook that returns a cancellable runner so you don’t repeat boilerplate.
1import { useRef, useEffect } from "react";
2export function useAbortable() {
3const controllerRef = useRef<AbortController | null>(null);
4
5useEffect(() => () => {
6controllerRef.current?.abort();
7}, []);
8
9function run<T>(fn: (signal: AbortSignal) => Promise<T>) {
10controllerRef.current?.abort(); // cancel previous
11controllerRef.current = new AbortController();
12const signal = controllerRef.current.signal;
13return fn(signal);
14}
15
16return { run, abort: () => controllerRef.current?.abort() };
17}
18
19// usage
20const { run } = useAbortable();
21
22useEffect(() => {
23run(async (signal) => {
24const res = await fetch("/api/items", { signal });
25const json = await res.json();
26setItems(json);
27}).catch(err => {
28if (err.name !== "AbortError") console.error(err);
29});
30}, [filters]);Testing Abort Flows
- Slow your API with artificial delay (setTimeout) and verify abort prevents late state updates.
- Simulate rapid interaction (type fast, switch routes) and check console for “AbortError”.
- In dev tools, watch the Network tab: aborted requests should stop downloading.
When to Use AbortController
- Search/autocomplete
- Route transitions
- Infinite scroll pagination when filters change
- Timeouts around third-party APIs
- Bulk operations that the user can cancel (e.g., “Stop syncing”)
Written by
Abhinav Yadav