FrontendPro

108Implement a single-flight token refresh mechanism: 10 failed requests trigger only one refresh, followed by safe retries

hardAbout 60 minutesCodingFreeauthpromisesrace-conditionssingle-flight

Problem

A dashboard fires 10 requests at once just as the access token expires. All 10 get 401 Unauthorized. A naive client refreshes the token 10 times. Refresh tokens are often single-use, so the second refresh fails and the user is logged out.

Implement createAuthClient({ request, refreshToken, initialToken, onAuthFailure = () => {} }) returning { fetch(url), token }:

  • request(url, token) performs the call and resolves to { status, data }.
  • refreshToken() resolves to a new access token (or rejects).

Rules for client.fetch(url):

  • Send the current token. If the status is not 401, return the response.
  • On 401, refresh at most once at a time (single-flight): concurrent 401s all wait for the same refresh, then retry with the new token.
  • If a request failed with an old token that has already been replaced (the refresh finished while it was in flight), retry with the current token without refreshing again.
  • Retry each request only once. A second 401 rejects with an Error whose status is 401.
  • If the refresh fails, every waiting request rejects with the refresh error, and onAuthFailure(error) is called once for that failed refresh.
  • After a refresh settles, a later expiry can trigger a new refresh.

Try it yourself

Write your solution, run it, then submit to check it against the tests.

solution.jsJavaScript

Press Run or ⌘ Enter to execute your code.

Step by step

Build the solution one idea at a time.

  1. 1

    Remember which token each request used

    Read the current token into a local variable before sending. When a 401 comes back, compare it with the current token: if they differ, someone already refreshed, so just retry.

  2. 2

    Single-flight the refresh

    Keep refreshing, a promise or null. refreshOnce() returns the existing promise if a refresh is in flight; otherwise it starts one, stores the new token on success, and in finally sets refreshing back to null, so a future expiry can refresh again.

  3. 3

    Report a failed refresh once

    Attach onAuthFailure inside refreshOnce, on the shared promise, so it runs once per failed refresh no matter how many requests were waiting. Each waiting request still rejects with the error.

  4. 4

    Retry exactly once

    After waiting for the refresh, send the request again with the new token. If that also returns 401, give up with an error that has status = 401. Without this limit, a server that keeps returning 401 (for example a revoked session) causes an infinite loop.

Approach

Cache the in-flight refresh promise so every concurrent 401 awaits the same refresh, compare the token each request used with the current one to avoid needless refreshes, and cap retries at one.

Solution

function createAuthClient({ request, refreshToken, initialToken, onAuthFailure = () => {} }) {
  let token = initialToken;
  let refreshing = null;

  function refreshOnce() {
    if (!refreshing) {
      refreshing = Promise.resolve()
        .then(() => refreshToken())
        .then(
          (newToken) => {
            token = newToken;
            return newToken;
          },
          (error) => {
            onAuthFailure(error);
            throw error;
          },
        )
        .finally(() => {
          refreshing = null;
        });
    }
    return refreshing;
  }

  async function authFetch(url) {
    const usedToken = token;
    const response = await request(url, usedToken);
    if (response.status !== 401) return response;

    // Only refresh if nobody replaced the token since this request was sent.
    if (usedToken === token) await refreshOnce();

    const retry = await request(url, token);
    if (retry.status === 401) {
      const error = new Error(`Unauthorized: ${url}`);
      error.status = 401;
      throw error;
    }
    return retry;
  }

  return {
    fetch: authFetch,
    get token() {
      return token;
    },
  };
}

Complexity

Time: at most one refresh per expiry, and at most two requests per call. Space: O(1).

Edge cases

  • Ten concurrent 401s cause exactly one refresh and ten retries.
  • A slow request that returns 401 after the refresh completed retries with the new token and does not refresh again.
  • A refresh failure rejects every waiting call and calls onAuthFailure once.
  • A request that is still 401 after refreshing is rejected instead of looping.
  • Once a refresh has finished, the next expiry starts a new one.

Follow-up questions

Answer each one out loud first, then reveal the model answer.