Token refresh avec queue de requêtes : le pattern Axios qui évite les 401 en cascade

Votre JWT expire, 5 requêtes partent en même temps, vous recevez 5 erreurs 401 et 5 tentatives de refresh token. On a tous vécu ça. Voici le pattern qu'on utilise en production pour résoudre ce problème proprement.

Le problème

Dans une SPA avec authentification JWT, le token d'accès expire régulièrement. Quand ça arrive, l'intercepteur Axios doit :

  1. Détecter le 401
  2. Rafraîchir le token
  3. Rejouer la requête originale

Sauf que si 5 requêtes échouent en même temps, on se retrouve avec 5 appels de refresh en parallèle. Le deuxième refresh utilise un token déjà invalidé par le premier. Résultat : déconnexion de l'utilisateur.

La solution : une queue de requêtes

Le principe est simple : le premier 401 lance le refresh, les suivants attendent dans une file d'attente. Une fois le token renouvelé, toutes les requêtes en attente sont rejouées avec le nouveau token.

interface RetryQueueItem {
  resolve: (value?: any) => void;
  reject: (error?: any) => void;
  config: AxiosRequestConfig;
}

const refreshAndRetryQueue: RetryQueueItem[] = [];
let isRefreshing = false;

Deux variables suffisent : un booléen pour savoir si un refresh est en cours, et un tableau pour stocker les requêtes en attente.

L'intercepteur complet

httpClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status !== 401) {
      return Promise.reject(error);
    }

    if (!isRefreshing) {
      isRefreshing = true;
      try {
        const { token, refreshToken } = await refreshAccessToken();
        localStorage.setItem('token', token);
        localStorage.setItem('refreshToken', refreshToken);

        // Rejouer toutes les requêtes en attente
        refreshAndRetryQueue.forEach(({ config, resolve, reject }) => {
          config.headers['Authorization'] = `Bearer ${token}`;
          httpClient.request(config)
            .then(resolve)
            .catch(reject);
        });
        refreshAndRetryQueue.length = 0;

        // Rejouer la requête originale
        originalRequest.headers['Authorization'] = `Bearer ${token}`;
        return httpClient(originalRequest);
      } catch (refreshError) {
        // Le refresh a échoué : déconnecter
        refreshAndRetryQueue.forEach(({ reject }) => reject(refreshError));
        refreshAndRetryQueue.length = 0;
        logout();
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    // Un refresh est déjà en cours : mettre en file d'attente
    return new Promise((resolve, reject) => {
      refreshAndRetryQueue.push({
        config: originalRequest,
        resolve,
        reject,
      });
    });
  }
);

Ce qui se passe concrètement

  1. Requête A reçoit un 401 → isRefreshing = true → lance le refresh
  2. Requête B reçoit un 401 → isRefreshing est déjà true → mise en queue
  3. Requête C reçoit un 401 → idem, mise en queue
  4. Le refresh aboutit → nouveau token stocké
  5. Requêtes B et C sont rejouées avec le nouveau token
  6. Requête A est rejouée aussi
  7. isRefreshing = false

L'utilisateur ne voit rien. Pas de déconnexion, pas de rechargement de page.

Les pièges qu'on a rencontrés

1. Ne pas oublier le finally. Si le refresh échoue (réseau coupé, refresh token expiré), il faut remettre isRefreshing = false. Sinon, toutes les futures requêtes restent bloquées dans la queue pour toujours.

2. Vider la queue dans les deux cas. Succès : on rejoue. Échec : on rejette. Si on oublie de vider la queue en cas d'erreur, les promesses restent pending indéfiniment — fuite mémoire silencieuse.

3. Ne pas intercepter les requêtes de refresh. Si votre endpoint de refresh retourne aussi un 401 (token de refresh expiré), l'intercepteur va tenter un refresh du refresh. Boucle infinie. Excluez l'URL de refresh de l'intercepteur.

4. Attention aux pages de login. Si l'utilisateur est sur /login, un 401 est normal (mauvais mot de passe). Ne pas déclencher le refresh dans ce cas — vérifiez l'URL courante avant d'entrer dans la logique.

Variante : avec un flag sur la config

Une alternative au check d'URL est d'ajouter un flag sur la requête :

if (error.response?.status === 401 && !originalRequest._retry) {
  originalRequest._retry = true;
  // ... logique de refresh
}

Ça évite les boucles infinies si le même intercepteur attrape la requête rejouée.

Quand ce pattern ne suffit pas

Si votre API utilise des tokens très courts (< 1 minute) et que vos pages font beaucoup de requêtes parallèles, envisagez un refresh proactif : rafraîchir le token quelques secondes avant son expiration, sans attendre le 401. Plus complexe à implémenter, mais zéro latence côté utilisateur.

Stack concernée : Axios, React/Next.js, JWT, n'importe quel backend

Prêt à vous lancer ?

La newsletter qu'on n'ignore pas

Abonnez-vous à notre newsletter pour recevoir nos derniers articles, retours d'expérience et conseils tech directement dans votre boîte mail.

Désinscription en un clic. Vos données restent privées.