
Refresh Tokens: The Race Condition You Didn't Know You Had
Implementing refresh token logic seems straightforward until your frontend makes multiple parallel API calls, all hitting an expired JWT at the exact same moment. Suddenly, your users are logged out, or worse, your server is hit with a thundering herd of refresh requests. There's a subtle but critic
by Sunil Band
The Silent Killer: When All Your API Calls Expire At Once
We've all been there: you've got a perfectly good JWT-based authentication system. Users log in, get an access token, and a refresh token. The access token is short-lived, the refresh token is long-lived. Your frontend dutifully attaches the access token to every request. When it expires, you catch the 401 Unauthorized, use the refresh token to get a new access/refresh pair, update your stored tokens, and retry the original failed request. It's standard practice, right?
But what happens when a user opens your app and it immediately fires off five or ten API calls simultaneously? And what if, at that exact moment, their access token just expired? Each of those requests will independently hit the server, get a 401, and each will trigger its own attempt to refresh the token. This isn't just inefficient; it's a race condition waiting to happen, potentially leading to unnecessary server load, compromised refresh token validity, and a broken user experience.
The Problem: A Thundering Herd of Refreshes
Imagine this sequence of events:
- User opens app. Frontend fires
GET /data1,GET /data2,POST /update3. - Access token has just expired. All three requests return
401 Unauthorized. - Each request's error handler independently tries to refresh the token.
-
refresh_data1sends refresh tokenR1to/refresh. -
refresh_data2sends refresh tokenR1to/refresh. -
refresh_data3sends refresh tokenR1to/refresh.
At this point, if your refresh token rotation strategy is robust (which it should be!), the first successful refresh request (refresh_data1) will invalidate R1 and return R2 (new access and refresh tokens). The subsequent refresh attempts (refresh_data2, refresh_data3) using the now-invalidated R1 will fail. This leads to a cascade of 401 errors, potentially logging the user out, even though a valid refresh did occur.
The Solution: A Single Source of Truth for Refreshing
The core idea is to ensure that only one refresh request is in flight at any given time. If other requests encounter an expired token while a refresh is pending, they should queue up and wait for the new tokens before retrying. If no refresh is in progress, they should initiate one.
I usually implement this using a simple queueing mechanism and a Promise to manage the in-flight refresh request. This ensures that all concurrent 401s funnel through a single point of token acquisition.
Let's look at a basic Axios interceptor setup, as that's a common way to handle this in React applications. I'm assuming you have localStorage or similar for token storage, but the principle applies regardless of storage mechanism.
import axios from 'axios';
// A global variable to track the in-flight refresh request
// Using a Promise here is key to queueing subsequent requests
let isRefreshing = false;
let failedQueue: { resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = [];
const processQueue = (error: Error | null, token: string | null = null) => {
while (failedQueue.length) {
const { resolve, reject } = failedQueue.shift()!;
if (error) {
reject(error);
} else {
resolve(token);
}
}
};
const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
apient.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
apient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If it's a 401 and we haven't tried to refresh yet (or it's not the refresh endpoint itself)
if (error.response.status === 401 && !originalRequest._retry && originalRequest.url !== '/auth/refresh-token') {
originalRequest._retry = true;
if (!isRefreshing) {
isRefreshing = true;
console.log('Initiating token refresh...');
try {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available. User must log in.');
}
// Make the actual refresh token request
const response = await axios.post('/api/auth/refresh-token', { refreshToken });
const { accessToken, refreshToken: newRefreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
apiClient.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
processQueue(null, accessToken); // Resolve all queued requests
return apiClient(originalRequest); // Retry the original request with new token
} catch (refreshError: any) {
console.error('Token refresh failed:', refreshError);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
processQueue(refreshError); // Reject all queued requests
window.location.href = '/login'; // Redirect to login page
return Promise.reject(refreshError); // Reject the original request
} finally {
isRefreshing = false;
}
} else {
// A refresh is already in progress, queue the current request
console.log('Refresh already in progress, queuing request...');
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return apiClient(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
}
return Promise.reject(error);
}
);
export default apiClient;Let's break down what's happening:
-
isRefreshing: This boolean flag prevents multiple refresh requests from being initiated. Only the first401that hits an expired token (and findsisRefreshingto befalse) will kick off the refresh process. -
failedQueue: This array stores theresolveandrejectfunctions of all subsequent API calls that also received a401while a refresh was in progress. These requests are essentially paused. -
processQueue: Once the refresh is complete (either successful or failed), this function iterates throughfailedQueueand resolves or rejects each waiting request. If successful, it provides the new access token. -
originalRequest._retry: This flag prevents an infinite loop if the retried original request also fails for some reason other than token expiration, or if the refresh endpoint itself returns a401. -
originalRequest.url !== '/auth/refresh-token': Crucially, the refresh endpoint itself should not trigger this logic. If it fails with a401, that's a genuine problem (e.g., refresh token invalid or expired) and the user should be logged out directly.
This setup ensures that:
- Only one network request to
/auth/refresh-tokenhappens when multiple401s occur concurrently. - All pending requests are held until new tokens are available.
- Upon successful refresh, all pending requests are retried with the new token.
- Upon failed refresh, all pending requests are rejected, and the user is typically logged out.
Server-Side Considerations: Refresh Token Rotation
While this client-side logic is crucial, it's equally important to have a robust server-side strategy for refresh tokens, specifically refresh token rotation with single-use tokens. When a client uses a refresh token to get a new access token, the server should:
- Invalidate the old refresh token. This is paramount. If the old token is compromised, it can only be used once.
- Issue a new refresh token (and access token).
If the server receives an already used refresh token, it should consider it suspicious. A good security measure here is to invalidate all refresh tokens for that user and force a re-login. This drastically limits the damage of a stolen refresh token, as it effectively becomes a one-time pad.
Without refresh token rotation, an attacker with a stolen refresh token could perpetually generate new access tokens. With rotation, if they use it, the legitimate user's subsequent attempt to refresh will fail, alerting them (or at least requiring a re-login) and invalidating the attacker's token.
Trade-offs and Gotchas
This pattern significantly improves the user experience and reduces server load. However, there are still things to consider:
- Complexity: It adds a layer of complexity to your API client. Debugging token-related issues can become slightly harder as requests are now queued and retried.
- Global State: The
isRefreshingandfailedQueuevariables are essentially global state within the module. While necessary for this pattern, be mindful of how this interacts with server-side rendering environments if you're not careful (though typically this logic is client-side only). - User Experience on Failure: If the refresh token itself expires or is invalidated (e.g., revoked by an admin, or due to the rotation policy detecting suspicious activity), the user will be logged out. This is a security feature, not a bug, but it's important that your UI handles this gracefully, e.g., redirecting to a login page with a clear message.
- Testing: Thoroughly test concurrent
401scenarios. Use tools like Cypress or Playwright to simulate multiple parallel network requests and verify your interceptor behaves as expected.
I've seen many authentication implementations that miss this critical race condition. It's one of those subtle issues that doesn't show up in basic tests but rears its ugly head under real-world usage, especially in applications with rich UIs that make many API calls on load or during complex interactions.
Wrapping up
Don't let concurrent 401s lead to frustrated users or vulnerable refresh tokens. Implement a robust client-side refresh token queueing mechanism in your API client and pair it with strong server-side refresh token rotation. If you're using Axios, grab the interceptor code provided above and adapt it to your project. Run it, test it, and ensure your authentication layer is as solid as it can be. This isn't just about convenience; it's about security and system stability.

When Your UI Needs to Break the Flat Screen: React and 3D with React Three Fiber
We've been building UIs on flat screens for decades. But what happens when you need something more? When data visualization demands depth, or an interactive product showcase needs a real sense of presence? React Three Fiber is the tool that lets you bring the full power of Three.js into your React a

When Your State Management Needs to Stop Thinking in Actions and Start Mutating
Many state management libraries force you into an 'actions and reducers' pattern. While powerful, it often adds unnecessary boilerplate for simple updates. Sometimes, you just need to directly modify state, and mutators offer a more ergonomic and intuitive approach, especially for deeply nested data

When Next.js Cache Components Refuse to Build Your App
Next.js 16.3 introduced 'Cache Components' to optimize server-side rendering, but getting them to work can be a headache. I spent a frustrating afternoon debugging why a simple page wouldn't build, only to uncover some subtle yet critical design considerations. It turns out, this feature forces you


















