Fixing result overlaps caused by parallel requests using AbortController (ReactJS)
1) A rare case when requests run in parallel and cause a problem.
For this article, I’ll create a simple example app. The user can enter a joining date to see the bonus information of employees.
I know the business idea of this app doesn’t make much sense — it’s just for demonstration purposes 😅.
The app includes a dropdown to select a year and a search button. (We won’t disable the button while loading, because the UX guy wants a “better user experience,” LOL.)
When the user clicks Search, the app will call an API to get data and display the result in the results section.
Below is the results section, which shows a loading state while the API is pending, and the result once it’s complete.
Front-end:
Regarding the following code:
1import { useState } from 'react';
2
3interface SearchResult {
4 bonus: number;
5 dateOffBonus: number;
6 dummyValue: number;
7}
8
9export const AbortControllerTest = () => {
10 const [joinedYear, setJoinedYear] = useState('');
11 const [loading, setLoading] = useState(false);
12 const [result, setResult] = useState<SearchResult | null>(null);
13
14 const handleSearch = async () => {
15 setLoading(true);
16 setResult(null);
17
18 try {
19 const response = await fetch('http://localhost:3001/api/search', {
20 method: 'POST',
21 headers: {
22 'Content-Type': 'application/json',
23 },
24 body: JSON.stringify({
25 joinYear: joinedYear
26 }),
27 });
28
29 const data = await response.json();
30 setResult(data.result);
31 } catch (error) {
32 console.error('Error:', error);
33 } finally {
34 setLoading(false);
35 }
36 };
37
38 return (
39 <div>
40 <input
41 type="text"
42 placeholder="Joined Year"
43 value={joinedYear}
44 onChange={(e) => setJoinedYear(e.target.value)}
45 />
46 <button onClick={handleSearch}>
47 {'Search'}
48 </button>
49
50 {loading ? <div><p>loading</p></div> : result && (
51 <div>
52 <p>Bonus: {result.bonus}</p>
53 <p>Days Off: {result.dateOffBonus}</p>
54 </div>
55 )}
56 </div>
57 );
58};Back-end:
For the backend, we’ll have business logic like this:
When the year is before 2020, we need to call a downstream service (a fake API) that takes a lot of time to respond.
1import express, { Request, Response, Application } from 'express';
2import cors from 'cors';
3
4const app: Application = express();
5const port = process.env.PORT || 3001;
6
7app.use(cors());
8app.use(express.json());
9
10const fakeApi = async (): Promise<number> => {
11 return new Promise(resolve => {
12 setTimeout(() => resolve(23000), 8000);
13 });
14};
15
16app.post('/api/search', async (req: Request, res: Response) => {
17 const { joinYear } = req.body;
18
19 let result = {
20 bonus: 22000,
21 dateOffBonus: 2,
22 };
23 if (+joinYear < 2020) {
24 const bonus = await fakeApi(); // Imagine if an employee joined before 2020 — in that case, we call a downstream service to get the bonus, which takes a very long time.
25 result.bonus = bonus
26 result.dateOffBonus = 3
27 }
28 res.json({
29 result
30 });
31});
32
33app.listen(port, () => {
34 console.log('🚀 Express REST Server ready');
35});Let's run the code:
When we choose 2011 and click Search, the app enters the loading state.
Before the first request finishes, we change the dropdown to 2023 and trigger another search. Suppose the user got tired of waiting or decided to try a different year.
The new API call completes quickly (since no downstream service is involved), and the result appears right away.
However, the previous flow (the one triggered when we searched for 2011) wasn’t actually canceled. It eventually finishes and triggers a state change.
As a result, after a short while, the user ends up seeing the 2011 result on the screen, even though the dropdown shows 2023.
It’s a very rare bug because, in most cases, the previous request finishes before the user changes to the next one. However, my team and I have encountered this issue several times.
2) Solution: AbortController
The AbortController interface represents a controller object that allows you to abort one or more Web requests as and when desired.
more infoThis feature isn’t exclusive to React; it can also be used to address similar issues in other UI frameworks.
We added an AbortController to our code.
First, we create an AbortController and store it in a ref (line 24). Then, we pass its signal when calling the API (line 39).
When handleSearch is called, the first thing we do is check if there’s any previous request. If there is, the signal of that request — stored in the ref — is used to abort it (line 20).
1import { useState, useRef } from 'react';
2
3interface SearchResult {
4 bonus: number;
5 dateOffBonus: number;
6 dummyValue: number;
7}
8
9export const AbortControllerTest = () => {
10 const [joinedYear, setJoinedYear] = useState('');
11 const [loading, setLoading] = useState(false);
12 const [result, setResult] = useState<SearchResult | null>(null);
13 const abortControllerRef = useRef<AbortController | null>(null);
14
15 const years = Array.from({ length: 25 }, (_, i) => 2000 + i);
16
17 const handleSearch = async () => {
18 // Cancel previous request if exists
19 if (abortControllerRef.current) {
20 abortControllerRef.current.abort();
21 }
22
23 // Create new AbortController
24 abortControllerRef.current = new AbortController();
25
26 setLoading(true);
27 setResult(null);
28
29 try {
30 const response = await fetch('http://localhost:3001/api/search', {
31 method: 'POST',
32 headers: {
33 'Content-Type': 'application/json',
34 },
35 body: JSON.stringify({
36 joinYear: joinedYear
37 }),
38 signal: abortControllerRef.current.signal
39 });
40
41 const data = await response.json();
42 setResult(data.result);
43 } catch (error: any) {
44 if (error.name === 'AbortError') {
45 console.log('Request was aborted');
46 } else {
47 console.error('Error:', error);
48 }
49 } finally {
50 setLoading(false);
51 }
52 };
53
54
55 return (
56 <div>
57 <select
58 value={joinedYear}
59 onChange={(e) => setJoinedYear(e.target.value)}
60 >
61 <option value="">Select Joined Year</option>
62 {years.map(year => (
63 <option key={year} value={year}>{year}</option>
64 ))}
65 </select>
66 <button onClick={handleSearch}>
67 {'Search'}
68 </button>
69
70
71 {loading ? <div><p>loading</p></div> : result && (
72 <div>
73 <p>Bonus: {result.bonus}</p>
74 <p>Days Off: {result.dateOffBonus}</p>
75 </div>
76 )}
77 </div>
78 );
79};
80Let's run the code:
Now let’s test it. Search for 2003, then don’t wait — search for 2024 right after. You’ll see that the request for 2003 is canceled in the Network tab.

The screen will show the correct result for 2024.
- Notes
- The AbortController only cancels the request on the frontend side, not on the backend.
- You can try a different approach: only call the next API when the previous one finishes loading. This may reduce the total number of requests, but it requires a lot of manual work and if–else logic, and can quickly become messy if your app is large or has multiple flows. Trust me — I’ve run into this before.
- If you are using Redux, it supports a much more convenient way to abort a request and the entire flow.