Preventing Refresh Token Race Conditions in Frontend Applications

javascript dev.to

Race conditions are not limited to backend systems or multithreaded code.

They can also happen in frontend applications whenever multiple asynchronous operations interact with the same state or resource.

What Is a Race Condition?

A race condition happens when the result of an application depends on the timing or completion order of concurrent operations.

In frontend applications, this can happen with things like:

  • API requests
  • Promises
  • user interactions
  • timers
  • state updates

For example, imagine an autocomplete input:

Request A: search("r")
Request B: search("react")
Enter fullscreen mode Exit fullscreen mode

If Request B finishes first but Request A finishes later, the older result may overwrite the newer one.

A useful pattern to watch for is:

Multiple async operations + shared state/resource + timing matters

One simple question can help identify these situations:

What happens if this operation starts again before the previous one finishes?

For example:

async function refreshToken() {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

What happens if several API requests call refreshToken() before the first refresh has completed?

That's the race condition we're going to look at next.

The Token Refresh Race Condition

Imagine the user has an expired access token and the frontend sends several API requests at almost the same time:

GET /profile
GET /orders
GET /notifications
Enter fullscreen mode Exit fullscreen mode

All of them are sent with the same expired token, so the server responds with 401 Unauthorized.

A naive interceptor might handle every 401 like this:

if (error.response?.status === 401) {
  await refreshToken();
}
Enter fullscreen mode Exit fullscreen mode

The problem is that each failed request runs this logic independently.

So instead of one refresh request, we may end up with:

All three requests are trying to update the same authentication state at nearly the same time.

Depending on how the backend handles refresh tokens, this can lead to unnecessary requests, inconsistent token state, or even intermittent authentication failures.

What we actually want is:

The first request should start the refresh operation, while the others wait for the same result.

This is where a shared Promise becomes useful.

Solving It with a Shared Promise

The key idea is simple:

If a refresh is already in progress, don't start another one. Wait for the existing refresh instead.

We can keep the current refresh operation in a shared Promise:

let refreshPromise: Promise<RefreshSessionData> | null = null;

function refreshSession() {
  if (!refreshPromise) {
    refreshPromise = requestNewSession().finally(() => {
      refreshPromise = null;
    });
  }

  return refreshPromise;
}
Enter fullscreen mode Exit fullscreen mode

The first caller sees refreshPromise === null and starts the refresh.

While that request is still pending, every other caller receives the same Promise instead of creating another refresh request.

Once the refresh succeeds or fails, finally() resets refreshPromise back to null, so a future token expiration can start a new refresh normally.

This gives us the behavior we wanted:

One refresh operation, multiple consumers waiting for its result.

Now let's plug this into an Axios response interceptor.

Using It Inside an Axios Interceptor

Now we need to connect the shared refresh logic to our API requests.

With Axios, a response interceptor is a good place to detect 401 Unauthorized responses and recover from them.

A simplified version looks like this:

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status !== 401) {
      return Promise.reject(error);
    }

    const originalRequest = error.config;

    if (originalRequest._retry) {
      return Promise.reject(error);
    }

    originalRequest._retry = true;

    const session = await refreshSession();

    originalRequest.headers.Authorization =
      `Bearer ${session.accessToken}`;

    return apiClient(originalRequest);
  },
);
Enter fullscreen mode Exit fullscreen mode

The flow is straightforward:

Because refreshSession() uses the shared Promise from the previous section, multiple 401 responses can reach this interceptor at the same time without starting multiple refresh calls.

The _retry flag is also important. It prevents the same request from entering an infinite 401 → refresh → retry loop if the retried request is still unauthorized.

In a production implementation, we should also avoid running this logic for the refresh endpoint itself or for endpoints that should never trigger token refresh.

At this point, concurrent 401 responses are coordinated correctly.

But there is still one subtle race condition left: what if a 401 arrives after another request has already refreshed the token?

Handling a Late 401

There is one more edge case worth handling.

Imagine two requests are sent with the same expired token.

Request A fails first and refreshes the token successfully.

By the time Request B receives its 401, the application already has a newer access token.

If we blindly refresh on every 401, Request B would trigger another unnecessary refresh.

This can be more than just an unnecessary network call.

In systems that use refresh token rotation, the first successful refresh may invalidate the previous refresh token. A second refresh triggered by a stale 401 can then fail and, depending on the backend's session policy, may result in an unexpected session failure or even force the user to log in again.

Instead, we can compare the token used by the failed request with the token we currently have:

const failedAccessToken = getAccessTokenFromRequest(originalRequest);
const currentAccessToken = tokenStore.getAccessToken();

if (
  currentAccessToken &&
  currentAccessToken !== failedAccessToken
) {
  attachAccessToken(originalRequest, currentAccessToken);
  return apiClient(originalRequest);
}
Enter fullscreen mode Exit fullscreen mode

If the tokens are different, another request has already refreshed the session.

So instead of refreshing again, we simply retry the failed request with the newer token.

This small check avoids redundant refresh calls caused by stale responses.

What If the Refresh Itself Fails?

So far, we've assumed the refresh request succeeds.

But the refresh token itself may also be expired, revoked, or invalid.

Because all concurrent requests are waiting for the same refreshPromise, they will all receive the same rejection if the refresh fails.

However, there is an important detail:

Session-level side effects should happen inside the shared refresh operation — not inside every waiting request.

For example:

async function requestNewSession() {
  try {
    const response = await publicClient.post("/auth/refresh");

    tokenStore.setAccessToken(response.data.accessToken);

    return response.data;
  } catch (error) {
    if (isUnauthorizedApiError(error)) {
      tokenStore.clearAccessToken();
      tokenStore.notifyUnauthorized();
    }

    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is important because not every refresh failure necessarily means the session is invalid.

A temporary network failure or server error may be recoverable, while an unauthorized refresh response usually means the session can no longer be restored.

By keeping these session-level side effects inside the shared refresh operation, an unrecoverable authentication failure clears the session once, while every request waiting for that Promise receives the same rejection.

The refresh operation is shared, so its session-level failure handling should be shared too.

Testing the Race Condition

Race conditions are timing-dependent, so we should not rely on network timing and simply hope that concurrent requests overlap during the test.

For these tests, I used Vitest together with custom Axios adapters, which lets the test control the request lifecycle without making real network calls.

The important part is controlling exactly when the refresh request is allowed to finish.

For that, I use a small deferred() helper:

function deferred<T>() {
  let resolve!: (value: T | PromiseLike<T>) => void;
  let reject!: (reason?: unknown) => void;

  const promise = new Promise<T>((res, rej) => {
    resolve = res;
    reject = rej;
  });

  return { promise, resolve, reject };
}
Enter fullscreen mode Exit fullscreen mode

This gives the test manual control over a Promise.

Now we can intentionally keep the refresh request pending:

const refreshGate = deferred<void>();
let refreshCalls = 0;

const { apiClient } = setupClients(async (config) => {
  refreshCalls += 1;

  await refreshGate.promise;

  return successfulRefresh(config);
});
Enter fullscreen mode Exit fullscreen mode

Then we fire several requests at almost the same time:

const requests = [
  apiClient.get("/resource/1"),
  apiClient.get("/resource/2"),
  apiClient.get("/resource/3"),
];
Enter fullscreen mode Exit fullscreen mode

Because the refresh request is still blocked, all three requests can enter the 401 recovery flow while the same refresh operation is still pending.

At that point, we verify that only one refresh has started:

await vi.waitFor(() => {
  expect(refreshCalls).toBe(1);
});
Enter fullscreen mode Exit fullscreen mode

Then we allow the refresh to complete:

refreshGate.resolve();
Enter fullscreen mode Exit fullscreen mode

Finally, we verify that all original requests recover successfully and that the refresh endpoint was still called only once:

await expect(
  Promise.all(requests),
).resolves.toHaveLength(3);

expect(refreshCalls).toBe(1);
Enter fullscreen mode Exit fullscreen mode

The failure path should be tested too.

For the failure case, we configure the refresh request to reject and verify that every request waiting for the shared Promise fails while only one refresh attempt is made.

const refreshError = new Error("Session expired");
let refreshCalls = 0;

const { apiClient } = setupClients(async () => {
  refreshCalls += 1;
  throw refreshError;
});

const results = await Promise.allSettled([
  apiClient.get("/resource/1"),
  apiClient.get("/resource/2"),
]);

expect(results).toEqual([
  expect.objectContaining({ status: "rejected" }),
  expect.objectContaining({ status: "rejected" }),
]);

expect(refreshCalls).toBe(1);
Enter fullscreen mode Exit fullscreen mode

The important idea is that we are not only testing the final result.

We are deliberately controlling the timing of asynchronous operations to reproduce the concurrency scenario we want to protect against.

The late-401 case can be tested the same way by delaying one API response until another request has already completed the token refresh.

Conclusion

Race conditions in frontend applications are often hidden behind asynchronous behavior.

A useful question to keep in mind is:

What happens if this operation starts again before the previous one finishes?

In our token-refresh flow, multiple 401 responses were competing to refresh the same authentication state.

A shared Promise allowed those requests to coordinate around a single refresh operation, while comparing the failed token with the current token handled late 401 responses without triggering unnecessary refreshes.

We also made sure that refresh failures are handled inside the shared operation itself, so session-level side effects such as clearing authentication state happen only once while every waiting request receives the same failure.

The important part is not just the token-refresh implementation.

It is learning to recognize when multiple asynchronous operations are competing over the same resource because that is where race conditions often begin.

Source: dev.to

arrow_back Back to Tutorials