· By Wataru Ito · Coding · 8 min read
How to Handle Concurrent Requests in JavaScript
Learn practical techniques to limit and control concurrent API requests in your JavaScript apps, with queue examples for Axios and fetch.
TL;DR
Ever had your app fire off a ton of API requests at once and wondered how to keep things under control? This post shows you how to limit concurrent requests in JavaScript—no matter if you use Axios or fetch. The best part: you don’t have to mess with your UI logic at all.
Why Should You Care About Concurrent Requests?
Modern data fetching libraries make API calls feel effortless, but sometimes you need to put the brakes on. If your app loads a bunch of data at once (think: table rows, batch jobs, or bulk updates), you can easily overwhelm your server or hit rate limits. Limiting concurrent requests can reduce that burst.
This controls how many requests are in progress at once. An API limit like “100 requests per minute” is a separate rule. Even with only five requests in progress, we could send hundreds in a minute if they finish fast. For that kind of limit, we need to control how often we start requests too.
The Good Old Days of API Calls
Once upon a time, you used jQuery to handle API calls. (I am young, do not know what we did before that :P) Then React came along, and with it, Redux. After tinkering, we settled on handling async API requests inside a “thunk.” As soon as you got a response, you’d dispatch a Redux action.
Today, we have more convenient—if not downright sophisticated—ways to handle this. Libraries like SWR and TanStack Query let you treat API responses (almost) just like ordinary everyday local state. You just call a hook, and declare the JSX you want for loading and resolved states. Done.
You don’t really “call” an API anymore. You just “use” a state within a component—one that may or may not be defined yet. This is great because you don’t have to care about when or how you should call APIs. The library handles it.
The Problem: Too Many Requests at Once
However, there are scenarios where timing does matter.
For example, one of my apps deals with a lot of data in a table, where each row uses the same API but with different parameters. Normally, this is fine—users add rows manually, one at a time. The app persists those states (which are essentially the params each row uses). When the window refreshes, the persisted params (via localStorage) let each row call for the data, but at the same time—30 to 50 requests almost at once. It’d be sensible to reduce the number of concurrent requests in these situations.
The Solution: Control Your Requests
You want to control when and how many requests you make at any given time. Preferably without messing with the render logic. It would be a nightmare very challenging scenario if you had to selectively render table rows based on how many API calls you’re allowed to make concurrently. Could you do it? Sure. Should you? Absolutely not. That’s complexity you don’t need.
We can put a queue in the fetching code, whether we use Axios or fetch. The examples below show the basic approach.
Solution 1: Axios with Interceptors
For Axios, you can provide request and response interceptors to limit how many requests Axios handles at any given time.
Here’s the key insight: Axios’s request interceptor can return a Promise that resolves to the request config. If we don’t resolve that promise immediately, the request just… waits. It’s like putting someone on hold, but for HTTP requests.
import axios from "axios";
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios";
type TQueuedReq = {
request: InternalAxiosRequestConfig;
resolver: (value: InternalAxiosRequestConfig) => void;
};
const MAX_CONCURRENT = 10;
const queue: TQueuedReq[] = [];
let _runningCount = 0;
const _processQueue = () => {
while (_runningCount < MAX_CONCURRENT && queue.length > 0) {
const queued = queue.shift();
if (!queued) break;
_runningCount += 1;
queued.resolver(queued.request);
}
};
const _onComplete = () => {
_runningCount -= 1;
_processQueue();
};
const axiosRequestInterceptor = (req: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
return new Promise((resolve) => {
if (_runningCount < MAX_CONCURRENT) {
_runningCount += 1;
resolve(req);
return;
}
queue.push({ request: req, resolver: resolve });
});
};
const axiosResponseInterceptor = <T>(res: AxiosResponse<T>): AxiosResponse<T> => {
_onComplete();
return res;
};
const axiosResponseErrorInterceptor = (error: AxiosError): Promise<never> => {
_onComplete();
return Promise.reject(error);
};
export const axiosInstance = axios.create();
axiosInstance.interceptors.request.use(axiosRequestInterceptor);
axiosInstance.interceptors.response.use(axiosResponseInterceptor, axiosResponseErrorInterceptor);How It Works
Let me break this down step by step:
The Queue: We maintain a
queuearray that holds pending requests and a_runningCountto track how many requests are currently in flight.Request Interceptor (
axiosRequestInterceptor):- Every request passes through this function before being sent
- If we’re under the limit (
_runningCount < MAX_CONCURRENT), we increment the counter and immediatelyresolvethe request—it goes through - If we’re at the limit, we push the request into the queue and don’t resolve. The request just sits there, waiting patiently
Response Interceptor (
axiosResponseInterceptorandaxiosResponseErrorInterceptor):- When a request completes (success or error), we call
_onComplete() - This decrements
_runningCountand calls_processQueue()
- When a request completes (success or error), we call
Processing the Queue (
_processQueue):- Loops through queued requests as long as we’re under the limit
- For each one, increments the counter and calls
resolver()to finally let the request through
We keep the waiting requests in one place and let them through as slots become free.
Use this instance in the code that fetches each row. For example, assuming your API has a /api/rows/:id endpoint:
// In the module where you defined axiosInstance:
export async function fetchRow(id: string) {
const response = await axiosInstance.get(`/api/rows/${encodeURIComponent(id)}`);
return response.data;
}All the rows need to use the same instance to share this limit. Calls through the default axios object or a different instance won’t use this queue. This example assumes the interceptors shown above; if you add other request interceptors that can throw before a request gets a slot, you need to track whether that request incremented the counter before decrementing it.
Solution 2: Fetching JSON with a Queue
With fetch, we need to decide what counts as finished. The fetch() Promise resolves when the response headers arrive, which can happen before we receive the whole body. If we free a slot at that point, we may still be downloading the previous response when we start the next one.
For a JSON API, I want the slot to cover reading and parsing the body too. This version returns the JSON data, so it has a different return value from plain fetch.
type TQueuedFetch = {
input: RequestInfo | URL;
init?: RequestInit;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
};
const MAX_CONCURRENT = 5;
const queue: TQueuedFetch[] = [];
let runningCount = 0;
const runRequest = async (request: TQueuedFetch): Promise<void> => {
try {
const response = await globalThis.fetch(request.input, request.init);
if (!response.ok) {
await response.body?.cancel();
throw new Error(`HTTP ${response.status}`);
}
const data: unknown = await response.json();
request.resolve(data);
} catch (error) {
request.reject(error);
} finally {
runningCount -= 1;
processQueue();
}
};
const processQueue = () => {
while (runningCount < MAX_CONCURRENT && queue.length > 0) {
const next = queue.shift();
if (!next) break;
runningCount += 1;
void runRequest(next);
}
};
export const queuedFetchJson = (input: RequestInfo | URL, init?: RequestInit): Promise<unknown> => {
return new Promise((resolve, reject) => {
queue.push({ input, init, resolve, reject });
processQueue();
});
};How It Works
Queue the Call: Each call to
queuedFetchJsonadds the URL, options, and Promise handlers to the queue.Start Within the Limit:
processQueuetakes the oldest waiting request while there is a free slot. We incrementrunningCountbefore starting it.Read the Body:
runRequestwaits forfetchand thenresponse.json(). We keep the slot until we have the JSON data or encounter an error.Release the Slot: In
finally, we decrement the counter and check the queue. Network errors and JSON parsing errors reach the caller throughreject, and the next request can start.
Plain fetch does not reject for HTTP errors like 404 or 500, so we check response.ok. For an error response, this example cancels the body and rejects with the status code. It expects successful responses to contain JSON; an empty response such as 204 needs different handling.
For the same example endpoint, our row fetcher can use it like this:
// In the module where you defined queuedFetchJson:
export async function fetchRow(id: string) {
return queuedFetchJson(`/api/rows/${encodeURIComponent(id)}`);
}We get the parsed data back, so we don’t call .json() again. The return type is unknown: check the API data’s shape before using it as a row in TypeScript. All callers must import the same queue module to share the limit. Calls to plain fetch bypass it, and separate browser tabs have separate queues.
Example Scenario
Let’s say each call takes about 6 seconds, including reading the JSON, and you make 6 requests in quick succession with MAX_CONCURRENT = 5:
- Requests 1-5: Start immediately,
runningCount = 5 - Request 6: Gets queued because we’re at the limit
- After ~6 seconds, request 1 completes
- the
finallyblock runs:runningCountdrops to 4,processQueue()runs - Request 6 finally gets executed
The component waits a bit longer for the sixth response. This reduces the burst of requests, though we still need to handle API errors.
These are basic queue examples. They don’t add retries or timeouts. They also don’t remove a waiting request as soon as you abort it; it stays in the queue until its turn, when Axios or fetch checks the cancellation. If all active requests hang, the waiting requests cannot start, so add timeouts if your app needs them.
I used AI to help check the accuracy of the explanations in this article.