How to Abort Fetch Requests with AbortController in JavaScript
Introduction
Network requests do not always complete on schedule. A user might navigate away from a page, close a modal, type a new search query before the previous results arrive, or simply grow impatient waiting for a slow server. In all these situations, the original request becomes irrelevant, but without explicit cancellation, it continues running in the background, consuming bandwidth, memory, and potentially triggering outdated UI updates when the response finally arrives.
The AbortController and AbortSignal APIs solve this problem. They provide a standardized mechanism for cancelling fetch() requests (and other asynchronous operations) cleanly and predictably. You create a controller, pass its signal to fetch(), and call abort() whenever you need to cancel. The fetch Promise rejects immediately with an AbortError, and the browser terminates the underlying network connection.
In this guide, you will learn how AbortController and AbortSignal work together, how to abort single and multiple fetch requests, how to implement timeouts with AbortSignal.timeout(), and how to apply these patterns in real-world scenarios like search-as-you-type, page navigation, and component lifecycle management.
AbortController and AbortSignal
The abort mechanism relies on two objects working together:
AbortController: The object you create and control. It has one property (signal) and one method (abort()).AbortSignal: The object you pass tofetch(). It communicates the abort status. You never create this directly; you get it from the controller.
Think of it like a walkie-talkie system: the AbortController is the transmitter (sends the cancel command), and the AbortSignal is the receiver (listens for it and tells fetch() to stop).
Creating an AbortController
const controller = new AbortController();
console.log(controller);
// AbortController { signal: AbortSignal }
console.log(controller.signal);
// AbortSignal { aborted: false, reason: undefined }
The controller starts in a non-aborted state. Its signal has an aborted property set to false.
The AbortSignal Object
The signal carries the abort state and can be inspected at any time:
const controller = new AbortController();
const signal = controller.signal;
console.log(signal.aborted); // false
console.log(signal.reason); // undefined
// Listen for the abort event
signal.addEventListener('abort', () => {
console.log('Signal was aborted!');
console.log('Reason:', signal.reason);
});
// Trigger the abort
controller.abort();
console.log(signal.aborted); // true
console.log(signal.reason); // DOMException: The operation was aborted.
Calling abort()
The abort() method on the controller does three things:
- Sets
signal.abortedtotrue - Sets
signal.reasonto anAbortErrorDOMException (or a custom reason if provided) - Fires the
abortevent on the signal
const controller = new AbortController();
// Abort with default reason
controller.abort();
console.log(controller.signal.reason);
// DOMException: The operation was aborted.
// Abort with a custom reason
const controller2 = new AbortController();
controller2.abort('User navigated away');
console.log(controller2.signal.reason);
// "User navigated away"
// Abort with a custom error
const controller3 = new AbortController();
controller3.abort(new Error('Request timeout'));
console.log(controller3.signal.reason);
// Error: Request timeout
Calling abort() multiple times is safe. After the first call, subsequent calls have no effect. The signal stays aborted with the original reason.
const controller = new AbortController();
controller.abort('first reason');
controller.abort('second reason'); // No effect
console.log(controller.signal.reason); // "first reason"
An AbortController Cannot Be Reused
Once an AbortController has been aborted, it cannot be "un-aborted" or reused. You must create a new controller for each cancellable operation:
// WRONG: Reusing an aborted controller
const controller = new AbortController();
controller.abort();
// This fetch is aborted immediately because the signal is already aborted
fetch('/api/data', { signal: controller.signal });
// Rejects instantly with AbortError
// CORRECT: Create a new controller for the new request
const newController = new AbortController();
fetch('/api/data', { signal: newController.signal });
// Works normally until newController.abort() is called
Aborting Fetch Requests
To make a fetch request cancellable, pass the controller's signal in the signal option:
Basic Abort
const controller = new AbortController();
// Start the fetch with the signal
const fetchPromise = fetch('https://jsonplaceholder.typicode.com/posts', {
signal: controller.signal
});
// Abort after 100ms
setTimeout(() => {
controller.abort();
}, 100);
try {
const response = await fetchPromise;
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch was aborted');
} else {
console.error('Fetch failed:', error);
}
}
When abort() is called, the fetch Promise rejects with a DOMException whose name property is "AbortError". This lets you distinguish between aborts and actual errors.
Identifying an AbortError
There are several ways to detect that a fetch was aborted:
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();
} catch (error) {
// Method 1: Check error name (most common)
if (error.name === 'AbortError') {
console.log('Request was cancelled');
return;
}
// Method 2: Check the signal
if (controller.signal.aborted) {
console.log('Request was cancelled');
return;
}
// If we get here, it was a real error
console.error('Network error:', error);
}
Why Distinguishing AbortError Matters
An abort is usually intentional and should be handled silently. A network error is unexpected and should be reported to the user. Treating them the same way leads to confusing error messages:
// WRONG: Shows "error" message when user simply cancelled
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();
showResults(data);
} catch (error) {
showErrorMessage('Something went wrong!'); // Fires on abort too!
}
// CORRECT: Handle abort separately
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
showResults(data);
} catch (error) {
if (error.name === 'AbortError') {
// Abort is expected: do nothing, or log quietly
console.log('Request cancelled');
return;
}
// Only show error for real failures
showErrorMessage(`Failed to load data: ${error.message}`);
}
Aborting During Body Reading
An abort can happen at any point during the fetch lifecycle: during the initial connection, while waiting for headers, or while reading the body:
const controller = new AbortController();
try {
// This could be aborted while connecting
const response = await fetch('/api/large-data', {
signal: controller.signal
});
// This could be aborted while downloading the body
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Aborted (could have been during connect OR body reading)');
}
}
// Abort at any time
document.getElementById('cancelBtn').addEventListener('click', () => {
controller.abort();
});
Aborting an Already-Aborted Signal
If the signal is already aborted when fetch() is called, the request never starts at all:
const controller = new AbortController();
controller.abort(); // Abort before fetch
try {
// This rejects immediately: no network request is sent
const response = await fetch('/api/data', {
signal: controller.signal
});
} catch (error) {
console.log(error.name); // "AbortError"
console.log('Request was never sent');
}
This behavior is important to understand for scenarios where you might create the controller earlier in the code and abort it conditionally before reaching the fetch call.
AbortSignal.timeout()
A very common use case for aborting is implementing request timeouts. The static method AbortSignal.timeout() creates a signal that automatically aborts after a specified number of milliseconds:
// Abort if the request takes longer than 5 seconds
const response = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000)
});
This is much cleaner than the manual approach with AbortController + setTimeout:
// The old manual approach (still works, but verbose)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId); // Don't forget to clear!
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
// The modern approach with AbortSignal.timeout()
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(5000)
});
return await response.json();
} catch (error) {
if (error.name === 'TimeoutError') {
console.log('Request timed out');
} else if (error.name === 'AbortError') {
console.log('Request aborted');
} else {
console.error('Network error:', error);
}
}
TimeoutError vs. AbortError
An important distinction: AbortSignal.timeout() throws a TimeoutError, not an AbortError. This lets you tell timeouts apart from manual aborts:
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(3000)
});
const data = await response.json();
} catch (error) {
switch (error.name) {
case 'TimeoutError':
console.log('The server took too long to respond');
break;
case 'AbortError':
console.log('The request was manually cancelled');
break;
default:
console.log('Network or other error:', error.message);
}
}
Combining Timeout with Manual Abort
What if you need both a timeout and the ability to cancel manually? You can combine signals using AbortSignal.any():
const controller = new AbortController();
const combinedSignal = AbortSignal.any([
controller.signal, // Manual abort
AbortSignal.timeout(10000) // 10-second timeout
]);
const fetchPromise = fetch('https://api.example.com/data', {
signal: combinedSignal
});
// The request will be aborted if either:
// 1. controller.abort() is called
// 2. 10 seconds pass
// Manual abort button
document.getElementById('cancelBtn').addEventListener('click', () => {
controller.abort();
});
try {
const response = await fetchPromise;
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === 'TimeoutError') {
console.log('Request timed out after 10 seconds');
} else if (error.name === 'AbortError') {
console.log('Request was cancelled by user');
} else {
console.error('Request failed:', error);
}
}
AbortSignal.any() takes an array of signals and creates a new signal that aborts when any of the input signals abort. It uses the reason from whichever signal aborted first. This is the standard way to combine manual abort with timeout, or to link multiple abort conditions together.
Practical Timeout Wrapper
A reusable function for fetch with timeout:
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
const { signal: externalSignal, ...restOptions } = options;
// Combine external signal (if any) with timeout
const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) {
signals.push(externalSignal);
}
const combinedSignal = signals.length > 1
? AbortSignal.any(signals)
: signals[0];
const response = await fetch(url, {
...restOptions,
signal: combinedSignal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
}
// Usage: 5-second timeout
try {
const response = await fetchWithTimeout('/api/data', {}, 5000);
const data = await response.json();
} catch (error) {
if (error.name === 'TimeoutError') {
showMessage('Server is not responding. Please try again.');
}
}
// Usage: timeout + manual abort
const controller = new AbortController();
try {
const response = await fetchWithTimeout(
'/api/data',
{ signal: controller.signal },
5000
);
const data = await response.json();
} catch (error) {
// Handles timeout, manual abort, and network errors
}
Using AbortSignal with Multiple Requests
A single AbortController can cancel multiple fetch requests simultaneously. This is because the same signal can be passed to any number of fetch calls.
Cancelling Multiple Requests with One Controller
const controller = new AbortController();
const signal = controller.signal;
// Start three requests with the same signal
const userPromise = fetch('/api/users', { signal });
const postsPromise = fetch('/api/posts', { signal });
const commentsPromise = fetch('/api/comments', { signal });
// One abort cancels all three
controller.abort();
// All three promises reject with AbortError
try {
const [users, posts, comments] = await Promise.all([
userPromise.then(r => r.json()),
postsPromise.then(r => r.json()),
commentsPromise.then(r => r.json())
]);
} catch (error) {
if (error.name === 'AbortError') {
console.log('All requests were cancelled');
}
}
Real-World Pattern: Search-As-You-Type
One of the most common use cases for abort is cancelling the previous search request when the user types a new character. Without cancellation, fast typing causes a race condition where older, slower responses arrive after newer ones, showing stale results:
let currentController = null;
const searchInput = document.getElementById('searchInput');
const results = document.getElementById('results');
searchInput.addEventListener('input', async (event) => {
const query = event.target.value.trim();
// Cancel the previous request (if any)
if (currentController) {
currentController.abort();
}
// Don't search for empty strings
if (!query) {
results.innerHTML = '';
return;
}
// Create a new controller for this request
currentController = new AbortController();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: currentController.signal }
);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const data = await response.json();
// Only update UI if this request wasn't aborted
results.innerHTML = data.results
.map(item => `<li>${item.title}</li>`)
.join('');
} catch (error) {
if (error.name === 'AbortError') {
// Previous request was cancelled: this is expected, do nothing
return;
}
results.innerHTML = `<li class="error">Error: ${error.message}</li>`;
}
});
Let us trace what happens when the user types "javascript" quickly:
User types "j" → Request A starts for "j"
User types "ja" → Request A is aborted, Request B starts for "ja"
User types "jav" → Request B is aborted, Request C starts for "jav"
User types "java" → Request C is aborted, Request D starts for "java"
(user pauses) → Request D completes, results shown for "java"
Without abort, all four requests would complete, and the results for "j" might arrive last (if it was slow), overwriting the correct results for "java".
Adding Debounce to Search
For even better performance, combine abort with debouncing to avoid sending a request on every single keystroke:
let currentController = null;
let debounceTimer = null;
searchInput.addEventListener('input', (event) => {
const query = event.target.value.trim();
// Cancel any pending request
if (currentController) {
currentController.abort();
currentController = null;
}
// Clear any pending debounce
clearTimeout(debounceTimer);
if (!query) {
results.innerHTML = '';
return;
}
// Wait 300ms after last keystroke before searching
debounceTimer = setTimeout(async () => {
currentController = new AbortController();
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: currentController.signal }
);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
results.innerHTML = data.results
.map(item => `<li>${item.title}</li>`)
.join('');
} catch (error) {
if (error.name === 'AbortError') return;
results.innerHTML = `<li class="error">${error.message}</li>`;
}
}, 300);
});
Page Navigation: Cancelling All Pending Requests
In a single-page application, when the user navigates to a different view, all requests from the previous view should be cancelled:
class PageController {
constructor() {
this.abortController = null;
}
// Called when entering a page/route
enter() {
// Create a fresh controller for this page's lifetime
this.abortController = new AbortController();
}
// Called when leaving a page/route
leave() {
// Cancel all requests made during this page's lifetime
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
}
// Get the signal for fetch requests on this page
get signal() {
return this.abortController?.signal;
}
// Make a fetch request tied to this page's lifecycle
async fetch(url, options = {}) {
if (!this.abortController) {
throw new Error('Page controller is not active');
}
const response = await fetch(url, {
...options,
signal: this.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
}
}
// Usage
const dashboardPage = new PageController();
// User navigates to dashboard
dashboardPage.enter();
// Multiple requests start: all share the same signal
async function loadDashboard() {
try {
const [statsRes, notificationsRes, activityRes] = await Promise.all([
dashboardPage.fetch('/api/stats'),
dashboardPage.fetch('/api/notifications'),
dashboardPage.fetch('/api/activity')
]);
const stats = await statsRes.json();
const notifications = await notificationsRes.json();
const activity = await activityRes.json();
renderDashboard(stats, notifications, activity);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Dashboard requests cancelled (user navigated away)');
return;
}
showError(error.message);
}
}
loadDashboard();
// User clicks a link to navigate away
dashboardPage.leave();
// All three requests are cancelled immediately
Component Cleanup Pattern
In component-based architectures (or with vanilla JS components), abort controllers help prevent memory leaks and stale updates:
class UserProfile {
constructor(container, userId) {
this.container = container;
this.userId = userId;
this.controller = new AbortController();
}
async load() {
try {
this.container.innerHTML = '<p>Loading profile...</p>';
const response = await fetch(`/api/users/${this.userId}`, {
signal: this.controller.signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
// Only update DOM if the component hasn't been destroyed
if (!this.controller.signal.aborted) {
this.container.innerHTML = `
<h2>${user.name}</h2>
<p>${user.email}</p>
`;
}
} catch (error) {
if (error.name === 'AbortError') return;
this.container.innerHTML = `<p class="error">${error.message}</p>`;
}
}
destroy() {
this.controller.abort();
this.container.innerHTML = '';
}
}
// Create and load
const profile = new UserProfile(document.getElementById('profile'), 42);
profile.load();
// Later, when the component should be removed
profile.destroy(); // Cancels any in-flight request
Parallel Requests with Independent Cancellation
Sometimes you want to cancel specific requests independently rather than all at once:
async function loadPageData() {
// Each request gets its own controller
const controllers = {
header: new AbortController(),
content: new AbortController(),
sidebar: new AbortController()
};
const requests = {
header: fetch('/api/header', { signal: controllers.header.signal })
.then(r => r.json()),
content: fetch('/api/content', { signal: controllers.content.signal })
.then(r => r.json()),
sidebar: fetch('/api/sidebar', { signal: controllers.sidebar.signal })
.then(r => r.json())
};
// Cancel only the sidebar request after 2 seconds
setTimeout(() => {
controllers.sidebar.abort();
console.log('Sidebar request cancelled - showing fallback');
document.getElementById('sidebar').innerHTML = '<p>Content unavailable</p>';
}, 2000);
// Wait for each independently
const results = await Promise.allSettled([
requests.header,
requests.content,
requests.sidebar
]);
if (results[0].status === 'fulfilled') renderHeader(results[0].value);
if (results[1].status === 'fulfilled') renderContent(results[1].value);
if (results[2].status === 'fulfilled') renderSidebar(results[2].value);
}
Using AbortSignal Beyond Fetch
AbortSignal is not limited to fetch(). It is a general-purpose cancellation mechanism that works with several other APIs.
With addEventListener
You can automatically remove event listeners when a signal aborts:
const controller = new AbortController();
document.addEventListener('click', (e) => {
console.log('Clicked!', e.target);
}, { signal: controller.signal });
document.addEventListener('keydown', (e) => {
console.log('Key pressed:', e.key);
}, { signal: controller.signal });
window.addEventListener('scroll', () => {
console.log('Scrolling...');
}, { signal: controller.signal });
// Remove ALL three event listeners at once
controller.abort();
This is much cleaner than storing handler references and calling removeEventListener for each one.
With Custom Async Operations
You can integrate AbortSignal into your own async functions:
function delay(ms, signal) {
return new Promise((resolve, reject) => {
// Check if already aborted
if (signal?.aborted) {
reject(signal.reason);
return;
}
const timeoutId = setTimeout(resolve, ms);
// Listen for abort
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(signal.reason);
}, { once: true });
});
}
// Usage
const controller = new AbortController();
try {
console.log('Waiting 5 seconds...');
await delay(5000, controller.signal);
console.log('Done waiting');
} catch (error) {
if (error.name === 'AbortError') {
console.log('Wait was cancelled');
}
}
// Cancel after 2 seconds
setTimeout(() => controller.abort(), 2000);
With Streams
const controller = new AbortController();
const response = await fetch('/api/stream', {
signal: controller.signal
});
const reader = response.body.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
processChunk(value);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('Stream reading cancelled');
reader.releaseLock();
}
}
Retry with Abort Support
A robust retry function that respects abort signals:
async function fetchWithRetry(url, options = {}, config = {}) {
const {
maxRetries = 3,
retryDelay = 1000,
backoffMultiplier = 2,
timeoutMs = 10000
} = config;
const { signal: externalSignal, ...fetchOptions } = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
// Create a signal that combines timeout, external abort, and per-attempt control
const signals = [AbortSignal.timeout(timeoutMs)];
if (externalSignal) signals.push(externalSignal);
const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
try {
const response = await fetch(url, { ...fetchOptions, signal });
if (!response.ok) {
// Don't retry client errors (4xx)
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error: ${response.status}`);
}
// Retry server errors (5xx)
throw new Error(`Server error: ${response.status}`);
}
return response;
} catch (error) {
// Never retry aborts or timeouts triggered by the caller
if (error.name === 'AbortError') throw error;
// TimeoutError from our per-request timeout is retryable
const isTimeout = error.name === 'TimeoutError';
const isServerError = error.message.startsWith('Server error');
const isNetworkError = error instanceof TypeError;
if ((isTimeout || isServerError || isNetworkError) && attempt < maxRetries) {
const waitTime = retryDelay * Math.pow(backoffMultiplier, attempt - 1);
console.log(`Attempt ${attempt} failed. Retrying in ${waitTime}ms...`);
// Wait, but respect the external abort signal during the wait
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, waitTime);
externalSignal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(externalSignal.reason);
}, { once: true });
});
continue;
}
throw error;
}
}
}
// Usage
const controller = new AbortController();
try {
const response = await fetchWithRetry(
'/api/unreliable-endpoint',
{ signal: controller.signal },
{ maxRetries: 3, timeoutMs: 5000 }
);
const data = await response.json();
console.log(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('Entire retry sequence was cancelled');
} else {
console.error('All retries failed:', error.message);
}
}
// User can cancel the entire retry process
cancelButton.addEventListener('click', () => controller.abort());
Common Mistakes
Mistake: Reusing an Aborted Controller
// WRONG: The controller is still aborted from the previous request
let controller = new AbortController();
async function makeRequest() {
controller.abort(); // Cancel previous
// BUG: controller is now aborted, so the next fetch is immediately cancelled too!
const response = await fetch('/api/data', { signal: controller.signal });
return response.json();
}
// CORRECT: Create a new controller each time
let controller;
async function makeRequest() {
if (controller) controller.abort(); // Cancel previous
controller = new AbortController(); // Fresh controller for new request
const response = await fetch('/api/data', { signal: controller.signal });
return response.json();
}
Mistake: Not Handling AbortError
// WRONG: AbortError shows as a failure to the user
async function loadData() {
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();
showData(data);
} catch (error) {
showErrorBanner(`Failed to load: ${error.message}`);
// Shows "Failed to load: The operation was aborted." to the user!
}
}
// CORRECT: Silently ignore AbortError
async function loadData() {
try {
const response = await fetch(url, { signal: controller.signal });
const data = await response.json();
showData(data);
} catch (error) {
if (error.name === 'AbortError') return; // Expected, not an error
showErrorBanner(`Failed to load: ${error.message}`);
}
}
Mistake: Forgetting to Clear Timeout
When implementing manual timeouts (instead of AbortSignal.timeout()), always clear the timer:
// WRONG: Timer fires even after successful response
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, { signal: controller.signal });
// If fetch completes in 1 second, the timer still fires after 5 seconds
// This doesn't cause a problem for THIS fetch, but the controller is now
// permanently aborted and can't be reused for anything else
// CORRECT: Clear the timer after fetch completes
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId); // Cancel the timer
return await response.json();
} catch (error) {
clearTimeout(timeoutId); // Cancel the timer even on error
throw error;
}
// OR: Just use AbortSignal.timeout() and avoid this entirely
const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
Summary
The AbortController and AbortSignal APIs provide a clean, standardized mechanism for cancelling fetch requests and other asynchronous operations in JavaScript.
AbortController is the command center: you create it, pass its .signal to fetch(), and call .abort() when you want to cancel. Each controller is single-use. Once aborted, create a new one for the next operation.
AbortSignal is the communication channel: it carries the aborted state and fires an abort event. The fetch API (and other consumers) listen to this signal and terminate when it fires.
AbortSignal.timeout(ms) creates a signal that automatically aborts after a specified time, throwing a TimeoutError instead of an AbortError. Use AbortSignal.any() to combine timeouts with manual abort for maximum flexibility.
One signal, many requests: A single signal can be shared across multiple fetch calls, letting you cancel all of them with one abort() call. This is essential for page lifecycle management and component cleanup.
The most important patterns to remember are: always check error.name === 'AbortError' to distinguish intentional cancellation from real errors, always create a new controller after aborting (controllers are not reusable), and always cancel previous requests in search-as-you-type and similar scenarios to avoid race conditions with stale responses.