Cancellable fetch with AbortController
Every modern UI eventually needs to cancel in-flight requests: a search box that fires on every keystroke, a route change that abandons a partially loaded page, a tab close that should free sockets. `AbortController` is the standard primitive for this. This snippet covers the minimal abort pattern, a `fetchWithTimeout` helper that aborts on a deadline, and a hook-friendly cleanup pattern that pairs each request with its own controller.
1,059 views
6
// In real code: const res = await fetch(url, { signal: ac.signal });
// We simulate fetch here so the snippet runs offline.
function fakeFetch(url, { signal } = {}) {
return new Promise((resolve, reject) => {
const id = setTimeout(() => resolve({ ok: true, url }), 200);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
}
});
}
const ac = new AbortController();
fakeFetch('/api/users', { signal: ac.signal })
.then((r) => console.log('done', r.url))
.catch((err) => console.log('err:', err.name));
// Caller cancels almost immediately.
setTimeout(() => ac.abort(), 10);An AbortController exposes a .signal you pass into any abort-aware async API and an .abort() method that flips the signal. When abort() fires, fetch (or your fake fetch) rejects with a DOMException whose name is 'AbortError', which lets callers tell user-cancelled work apart from real failures. Always store the controller alongside the request so a follow-up handler can reach it (route change, search input, component unmount). Once aborted, a controller cannot be reused; create a fresh one per request.
function fakeFetch(url, { signal } = {}) {
return new Promise((resolve, reject) => {
const id = setTimeout(() => resolve({ ok: true, url }), 200);
if (signal) signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
}
async function fetchWithTimeout(url, options = {}, timeoutMs = 50) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
return await fakeFetch(url, { ...options, signal: ac.signal });
} finally {
clearTimeout(timer);
}
}
fetchWithTimeout('/api/slow', {}, 30)
.then((r) => console.log(r))
.catch((err) => console.log('timed out:', err.name));Pairing a fresh AbortController with a setTimeout gives you a per-request deadline without manually plumbing a timer across the codebase. The finally block clears the pending timer so a fast successful response does not leave a dangling timeout in the event loop. Callers see an AbortError either way (timeout or manual cancel), so build your error UI around the error name, not the trigger. For user-facing forms, surface a friendlier message when the cancel was a real timeout vs a navigation.
function fakeFetch(url, { signal } = {}) {
return new Promise((resolve, reject) => {
const id = setTimeout(() => resolve({ ok: true, url }), 50);
if (signal) signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
}
function makeSearch() {
let inflight = null;
return async function search(query) {
if (inflight) inflight.abort();
const ac = new AbortController();
inflight = ac;
try {
const res = await fakeFetch(`/api/search?q=${encodeURIComponent(query)}`, { signal: ac.signal });
if (inflight === ac) return res;
} catch (err) {
if (err.name !== 'AbortError') throw err;
}
return null;
};
}
const search = makeSearch();
search('a');
setTimeout(() => search('ab').then((r) => console.log('latest:', r && r.url)), 5);Search-as-you-type only cares about the most recent query, so each new keystroke should abort the previous request before starting the next one. Tracking the in-flight controller in a closure makes that bookkeeping local instead of leaking through component state. The if (inflight === ac) return res guard discards a result that finished after a newer request started but before the abort propagated. Swallowing 'AbortError' keeps the UI clean since cancelling is expected behavior, not a real failure to surface.
