← All posts
October 30, 2026· 11 min read

Axios Instance & Interceptors: Handle JWT Refresh Tokens With a Request Queue

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:

  1. Why we use an Axios instance
  2. The problem with expired access tokens when multiple requests are running
  3. How I solved it using a refresh token and a request queue

Why Use an Axios Instance?

  • DRY code: Define your baseURL once 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 Authorization header
  • switches the response type to blob if 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:

  1. The app should automatically use the refresh token to get a new access token.
  2. Multiple requests that fail at the same time should not trigger multiple refresh-token requests.
  3. Once the new access token is received, the failed requests should be retried automatically.
  4. 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[] = [];
  • isRefreshing tells us whether a token refresh is already in progress.
  • failedQueue stores 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:

  1. sets isRefreshing = true, so others go into the queue
  2. gets a new access token
  3. releases the queue
  4. 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.