Introduction
As developers, there is one small piece of code that we often pay less attention to, even though it quietly handles almost every request in our frontend: the Axios instance.
An Axios instance is a custom-configured copy of Axios, created using axios.create(). It lets us share a baseline configuration, such as a base URL, custom headers or a timeout, across all our HTTP requests without repeating the same code everywhere.
Most of us create it once, add a base URL and forget about it. But once your app uses JWT authentication with short-lived access tokens, this small file becomes one of the most important parts of your frontend.
In this article, I'll walk through:
- Why we use an Axios instance
- The problem with expired access tokens when multiple requests are running
- How I solved it using a refresh token and a request queue
Why Use an Axios Instance?
- DRY code: Define your
baseURLonce instead of typing the full server address in every request. - Interceptors: Attach request and response interceptors to a specific instance (for example, to automatically attach a bearer token or handle global 401/404 errors) without changing Axios's global defaults.
- Multiple backends: Create separate instances when your app talks to different APIs or microservices (for example, one instance for an auth service and another for a data service).
A basic instance looks like this:
import axios from "axios";
const axiosInstance = axios.create({
baseURL: "https://api.your-domain.com/api/v1",
timeout: 80000,
headers: {
"Content-Type": "application/json",
},
});
export default axiosInstance;
Now, instead of writing the full URL every time:
axios.get("https://api.your-domain.com/api/v1/users");
we can simply write:
axiosInstance.get("/users");
Attaching the Token With a Request Interceptor
The most common use of an interceptor is to attach the access token to every request:
axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (config.url?.includes("export=true")) {
config.responseType = "blob";
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
Before every request leaves the browser, this interceptor:
- reads the access token and adds it to the
Authorizationheader - switches the response type to
blobif the request is a file export
Simple, right? But here is the catch.
The Catch: Expired Access Tokens
With JWT, access tokens are usually short-lived, often expiring every 10 to 15 minutes. This is good for security, but it creates a problem.
Imagine the user opens a dashboard page, and the page fires three requests at the same time:
GET /users → 401
GET /orders → 401
GET /notifications → 401
The access token expired just before these requests reached the server. So all three requests fail.
Why this is a problem
The user doesn't know anything about access tokens or refresh tokens. From their point of view, the page simply broke, or they were suddenly logged out while working.
As developers, we need to handle this behind the scenes. When an access token expires:
- The app should automatically use the refresh token to get a new access token.
- Multiple requests that fail at the same time should not trigger multiple refresh-token requests.
- Once the new access token is received, the failed requests should be retried automatically.
- The user should continue working without refreshing the page or logging in again (as long as the refresh token is still valid).
This is where response interceptors, refresh-token logic and request queuing come in. And this is the part that makes an Axios instance much more interesting than just a place to store your API's base URL.
The Solution: Queue the Failed Requests
The idea is simple:
- The first request that gets a 401 starts the token refresh.
- Any other request that gets a 401 while the refresh is in progress waits in a queue.
- Once the new token arrives, every request in the queue is retried with the new token.
GET /users → 401 → starts refresh 🔄
GET /orders → 401 → waits in queue ⏳
GET /notifications → 401 → waits in queue ⏳
↓
New access token received ✅
↓
Retry /users, /orders, /notifications with the new token
↓
User never notices anything 🎉
Only one refresh request is sent, no matter how many requests failed.
The Complete Axios Instance
Here is the full file I use in my project:
import axios, {
type AxiosError,
type AxiosInstance,
type InternalAxiosRequestConfig,
} from "axios";
import {
getAccessToken,
getRefreshToken,
refreshAccessTokenUsingRefreshToken,
removeAccessToken,
} from "../lib/token.service";
import {
ERROR_TYPES,
setStatusBarErrorMessage,
type ErrorType,
} from "./utils.helper";
interface RetryableRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
interface FailedRequest {
resolve: (token: string) => void;
reject: (error: unknown) => void;
}
export const getApiBaseUrl = () => {
if (window.location.protocol === "https:") {
return "https://api.your-domain.com";
}
return "http://localhost:8000";
};
const axiosInstance: AxiosInstance = axios.create({
baseURL: `${getApiBaseUrl()}/api/v1`,
timeout: 80000,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
let isRefreshing = false;
let failedQueue: FailedRequest[] = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else if (token) {
resolve(token);
}
});
failedQueue = [];
};
const isAuthRoute = (url?: string) => {
if (!url) return false;
return (
url.includes("/auth/login") ||
url.includes("/auth/refresh-token") ||
url.includes("/auth/auto-secure-login")
);
};
axiosInstance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (config.url?.includes("export=true")) {
config.responseType = "blob";
}
return config;
},
(error) => {
return Promise.reject(error);
},
);
axiosInstance.interceptors.response.use(
(response) => {
if (response.config.responseType === "blob") {
const contentDisposition = response.headers["content-disposition"];
const fileName = contentDisposition
? contentDisposition.split("filename=")[1] || "data.xlsx"
: "data.xlsx";
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.download = fileName.replace(/"/g, "");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
}
return response;
},
async (error: AxiosError) => {
const originalRequest = error.config as RetryableRequestConfig | undefined;
if (!originalRequest) {
return Promise.reject(error);
}
const status = error.response?.status;
const responseData = error.response?.data as
| {
message?: string;
errorType?: string;
}
| undefined;
const apiMessage = responseData?.message;
const errorType =
(responseData?.errorType as ErrorType) || ERROR_TYPES.ERROR;
if (status !== 401 && apiMessage && !isAuthRoute(originalRequest.url)) {
setStatusBarErrorMessage({
message: apiMessage,
errorType,
});
}
if (
status === 401 &&
getRefreshToken() &&
!originalRequest._retry &&
!isAuthRoute(originalRequest.url)
) {
originalRequest._retry = true;
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({
resolve,
reject,
});
}).then((newAccessToken) => {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axiosInstance(originalRequest);
});
}
isRefreshing = true;
try {
const newAccessToken = await refreshAccessTokenUsingRefreshToken();
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axiosInstance(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
removeAccessToken();
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
if (
status === 401 &&
!getRefreshToken() &&
!isAuthRoute(originalRequest.url)
) {
removeAccessToken();
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default axiosInstance;
It looks long, but each part has a clear job. Let's break it down.
Breaking It Down
1. Base URL for each environment
export const getApiBaseUrl = () => {
if (window.location.protocol === "https:") {
return "https://api.your-domain.com";
}
return "http://localhost:8000";
};
If the app is running on HTTPS (production), it uses the production API. Otherwise, it uses the local or development API. This way, we don't have to change the URL manually when deploying.
2. withCredentials: true
withCredentials: true,
This tells the browser to send cookies with cross-origin requests. It's needed if your backend stores anything (like the refresh token) in an HTTP-only cookie.
3. The two variables that make the queue work
let isRefreshing = false;
let failedQueue: FailedRequest[] = [];
isRefreshingtells us whether a token refresh is already in progress.failedQueuestores the requests that are waiting for the new token.
Each item in the queue is a resolve and reject pair from a Promise. In other words, we are storing a "pause button" for each failed request.
4. processQueue(): releasing the waiting requests
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else if (token) {
resolve(token);
}
});
failedQueue = [];
};
Once the refresh is finished, this function goes through the queue:
Refresh succeeded → resolve(newToken) → each request retries
Refresh failed → reject(error) → each request fails
Then it empties the queue.
5. isAuthRoute(): avoiding an infinite loop
const isAuthRoute = (url?: string) => {
if (!url) return false;
return (
url.includes("/auth/login") ||
url.includes("/auth/refresh-token") ||
url.includes("/auth/auto-secure-login")
);
};
This one is easy to miss, but it's very important.
If the refresh-token request itself returns 401 (because the refresh token has also expired), we must not try to refresh again. Otherwise:
refresh-token → 401 → refresh-token → 401 → refresh-token → ...
The same applies to the login route. A 401 there simply means wrong credentials, not an expired token.
6. _retry: retrying only once
interface RetryableRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
We add a custom _retry flag to the request. Once a request has been retried, it is marked as _retry = true. If it fails with 401 again, we don't keep retrying it forever.
7. Showing API error messages globally
if (status !== 401 && apiMessage && !isAuthRoute(originalRequest.url)) {
setStatusBarErrorMessage({
message: apiMessage,
errorType,
});
}
For any error that is not a 401, the message from the backend is shown in a status bar. This way, we don't have to write the same error-handling code in every component.
We skip 401 here because the user shouldn't see "Unauthorized" while we are silently refreshing the token in the background.
8. The refresh logic, step by step
This is the heart of the file.
Step 1: Check whether we should refresh
if (
status === 401 &&
getRefreshToken() &&
!originalRequest._retry &&
!isAuthRoute(originalRequest.url)
) {
We only refresh when:
- the error is a 401
- we have a refresh token
- this request hasn't been retried yet
- it's not an auth route
Step 2: If a refresh is already running, wait in the queue
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((newAccessToken) => {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axiosInstance(originalRequest);
});
}
This request doesn't start another refresh. It creates a Promise, puts it in the queue and waits. When processQueue() resolves it with the new token, the request retries itself with the new token.
Step 3: If no refresh is running, start one
isRefreshing = true;
try {
const newAccessToken = await refreshAccessTokenUsingRefreshToken();
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axiosInstance(originalRequest);
}
The first failed request:
- sets
isRefreshing = true, so others go into the queue - gets a new access token
- releases the queue
- retries itself
Step 4: If the refresh fails, log the user out
catch (refreshError) {
processQueue(refreshError, null);
removeAccessToken();
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
If the refresh token has also expired, there is nothing more we can do. We reject all the waiting requests, clear the token and send the user to the login page.
The finally block makes sure isRefreshing is reset, whether the refresh succeeded or failed.
9. No refresh token at all
if (
status === 401 &&
!getRefreshToken() &&
!isAuthRoute(originalRequest.url)
) {
removeAccessToken();
window.location.href = "/login";
}
If we get a 401 and there is no refresh token, the user has to log in again.
10. Bonus: automatic file downloads
if (response.config.responseType === "blob") {
// create a temporary link and click it
}
Remember the export=true check in the request interceptor? When the response comes back as a blob, the response interceptor reads the filename from the Content-Disposition header and triggers the download automatically. Any component can download a file just by calling an API with export=true.
How It Plays Out
Let's go back to our three requests:
1. GET /users, /orders, /notifications → all return 401
2. /users arrives first
isRefreshing = false
→ sets isRefreshing = true
→ calls refresh-token API 🔄
3. /orders and /notifications arrive
isRefreshing = true
→ pushed into failedQueue ⏳
4. New access token received ✅
→ processQueue(null, newToken)
→ /orders and /notifications retry with new token
→ /users retries with new token
5. isRefreshing = false
6. All three requests succeed
→ the user never noticed anything
And if the refresh token has also expired:
refresh-token API fails ❌
→ processQueue(error)
→ all waiting requests are rejected
→ access token removed
→ redirect to /login
Final Takeaway
For me, the biggest lesson was that axiosInstance is not just a place to store the base URL. It is the single gateway between the frontend and the backend, so it's the best place to handle things every request needs.
The mental model I keep in mind:
Request interceptor
↓
Attach access token
Response interceptor (success)
↓
Handle file downloads
Response interceptor (error)
↓
Not 401? → show API error message
↓
401 + refresh token?
↓
Refresh already running?
├── Yes → wait in failedQueue
└── No → refresh token
├── Success → processQueue(token) → retry all
└── Failed → processQueue(error) → /login
If you are using JWT with short-lived access tokens, this pattern makes token expiry invisible to your users: one refresh, no failed requests, no random logouts.
