Cancel the Promises
Jan 6th 2026I see a lot of strong engineers and web developers that don't know about the AbortController API. Fewer still think about request lifetimes or cancellation as first-class concerns in frontend applications.
In Go there is a concept that permeates all codebases: the context. This context allows you to cancel async tasks—among other things—generated by a parent task when the parent task ceases to exist. It's usually the first parameter for most functions. Take this example from the Go standard library:
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "...", nil)
If the parent context is canceled or the timeout expires, the HTTP request is automatically aborted. The same principle applies to database queries, file operations, and pretty much any I/O operation that could take time.
All your fetch requests should have an AbortSignal attached to them. So that when the component unmounts or when the request becomes stale—like when a new request supersedes it—you can properly cancel the in-flight request.
Imagine this scenario: you are working on a data-heavy dashboard for a SaaS platform. It's an old SPA that has grown over many years. You have a user selector in the sidebar, where an admin can quickly click to see activity logs for different team members. On click, there's a fetch request to get that user's specific data. But what happens if the admin starts clicking down the list of users in rapid succession? There is a race condition between ongoing requests. The state gets updated to whichever request completes last, which often doesn't match the user currently highlighted in the UI. This happens because the previous fetch request wasn't canceled when the admin clicked the next user.
This is terrible for UX but also for resources on low-end mobile devices—not everyone has a cool and expensive iPhone.
As a minimal example in React:
useEffect(() => {
// Simplified example. In production, use a data-fetching abstraction
// like TanStack Query and proper error/loading handling.
const controller = new AbortController();
async function fetchData() {
const signal = controller.signal;
const res = await fetch(url, { signal });
const data = await res.json();
setUserData(data);
};
fetchData();
// Abort on unmount or dependency change.
return () => controller.abort();
}, [url]);
I also see a lot of people correctly doing parallelization in JavaScript, but they completely forget to cancel the ongoing requests when one fails. For example:
const promises: Promise<User>[] = [];
// `users` is an array of 30 users.
for (const user of users) {
// `fetchUserData` is an async function that returns a promise.
promises.push(fetchUserData(user));
}
try {
// Mistake 1: you probably want `Promise.allSettled` unless
// partial results are useless.
const results = await Promise.all(promises);
} catch (error) {
// Mistake 2: if one of the requests fails, the other 29 will still
// run in the background. You should abort them here.
}
Improved:
const controller = new AbortController();
const signal = controller.signal;
const promises: Promise<User>[] = [];
for (const user of users) {
promises.push(fetchUserData(user, { signal }));
}
try {
const results = await Promise.all(promises);
} catch (error) {
controller.abort(); // Cancels in-flight requests.
}
In frontend code, request lifetimes are easier to overlook, but the problems are very real.