Capa do Ebook gratuito Roteamento e Autenticação em React: Construindo SPAs Seguras com React Router e JWT

Roteamento e Autenticação em React: Construindo SPAs Seguras com React Router e JWT

Novo curso

20 páginas

Concorrência e consistência: fila de refresh, múltiplas requisições e prevenção de loops

Capítulo 14

Tempo estimado de leitura: 16 minutos

+ Exercício
Audio Icon

Ouça em áudio

0:00 / 0:00

Quando uma SPA usa JWT com refresh token, o problema mais comum não é “como renovar”, e sim “como renovar sem quebrar tudo quando várias requisições falham ao mesmo tempo”. Em aplicações reais, é normal o usuário abrir várias abas, a UI disparar múltiplas chamadas em paralelo (listas, contadores, notificações), e todas elas receberem 401 quase simultaneamente quando o access token expira. Se cada requisição tentar renovar o token por conta própria, você cria concorrência, inconsistência e, frequentemente, loops infinitos.

Neste capítulo, vamos focar em três desafios práticos: (1) concorrência: múltiplas requisições tentando fazer refresh ao mesmo tempo; (2) consistência: garantir que todas as requisições usem o mesmo token atualizado e que o estado de autenticação não “oscile”; (3) prevenção de loops: evitar que o refresh gere novos 401 em cascata e que a aplicação fique presa em tentativas repetidas.

O que significa concorrência e consistência no refresh

Concorrência: o “thundering herd” do refresh

Concorrência aqui significa que várias requisições, disparadas quase ao mesmo tempo, detectam que o token expirou e tentam renovar simultaneamente. Isso pode acontecer por:

  • Carregamento inicial de uma tela com várias chamadas em paralelo.
  • Revalidações automáticas (polling, refetch, websockets que disparam fetches).
  • Usuário navegando rapidamente entre rotas.
  • Várias abas abertas com a mesma sessão.

Se cada requisição fizer refresh, você pode ter:

  • Vários refresh tokens consumidos (alguns backends invalidam o refresh token após uso).
  • Condição de corrida: uma resposta de refresh “antiga” sobrescreve tokens “novos”.
  • Requisições repetidas e inconsistentes (umas com token A, outras com token B).

Consistência: uma única fonte de verdade para tokens

Consistência significa que, após um refresh bem-sucedido, todas as requisições subsequentes devem usar o access token mais recente, e o estado de autenticação deve refletir isso de forma determinística. Problemas típicos de inconsistência:

Continue em nosso aplicativo

Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.

ou continue lendo abaixo...
Download App

Baixar o aplicativo

  • Atualizar token em um lugar (ex.: memória) e esquecer de atualizar no armazenamento persistente (ou vice-versa).
  • Uma requisição “retentada” (retry) usando o token antigo porque capturou o valor antes do refresh.
  • Uma aba atualiza tokens e outra aba continua usando tokens antigos.

O padrão da fila de refresh (refresh queue)

A solução mais robusta no front-end é centralizar o refresh em um único “canal” e enfileirar as requisições que falharam com 401 enquanto o refresh está em andamento. Em vez de cada requisição renovar, apenas a primeira inicia o refresh; as demais aguardam o resultado. Quando o refresh termina:

  • Se deu certo: todas as requisições pendentes são reexecutadas com o novo token.
  • Se falhou: todas as pendentes falham de forma consistente (e você executa o fluxo de “sessão expirada”).

Esse padrão é implementado com três elementos:

  • Um flag global: isRefreshing.
  • Uma promessa compartilhada: refreshPromise (ou uma fila de callbacks).
  • Um mecanismo de retry que reenvia a requisição original após o refresh.

Modelo mental: “um refresh por vez”

Regras práticas:

  • Se receber 401 e não estiver refreshando: iniciar refresh e guardar a promessa.
  • Se receber 401 e já estiver refreshando: aguardar a promessa existente.
  • Após refresh: repetir a requisição original com o token atualizado.
  • Se refresh falhar: invalidar sessão e não tentar repetir indefinidamente.

Passo a passo prático com Axios: interceptors + fila

A seguir, um exemplo completo de como montar a fila de refresh com Axios. A ideia é manter o controle em um módulo de API (um único axiosInstance), sem espalhar lógica de refresh pelos componentes.

1) Criar um “token manager” em memória (fonte de verdade)

Mesmo que você persista tokens, é útil ter um gerenciador em memória para leitura rápida e para evitar que closures capturem valores antigos. O importante é que toda leitura do token para requests passe por esse manager.

type Tokens = { accessToken: string; refreshToken: string } | null; let currentTokens: Tokens = null; export function setTokens(tokens: Tokens) { currentTokens = tokens; } export function getAccessToken() { return currentTokens?.accessToken ?? null; } export function getRefreshToken() { return currentTokens?.refreshToken ?? null; }

Observação: a persistência (localStorage/cookie) pode existir, mas o request interceptor deve ler do getAccessToken() para consistência. Quando você carregar a app, você hidrata currentTokens a partir do armazenamento.

2) Criar a instância do Axios e anexar o access token

import axios from "axios"; import { getAccessToken } from "./tokenManager"; export const api = axios.create({ baseURL: "/api" }); api.interceptors.request.use((config) => { const token = getAccessToken(); if (token) { config.headers = config.headers ?? {}; config.headers.Authorization = `Bearer ${token}`; } return config; });

Esse interceptor deve ser simples: anexar token e não fazer refresh aqui. O refresh deve ficar no interceptor de resposta para lidar com 401.

3) Implementar o refresh centralizado com promessa compartilhada

Vamos criar variáveis de módulo para controlar concorrência:

import type { AxiosError, AxiosRequestConfig } from "axios"; import { api } from "./api"; import { getRefreshToken, setTokens } from "./tokenManager"; let isRefreshing = false; let refreshPromise: Promise<string> | null = null; async function performRefresh(): Promise<string> { const rt = getRefreshToken(); if (!rt) throw new Error("No refresh token"); const response = await api.post("/auth/refresh", { refreshToken: rt }, { skipAuthRefresh: true } as any); const { accessToken, refreshToken } = response.data; setTokens({ accessToken, refreshToken }); return accessToken; }

Há um detalhe importante: a chamada de refresh não pode cair no mesmo interceptor de refresh, senão você cria loop. Uma forma simples é usar uma flag no config, como skipAuthRefresh, e checar isso no interceptor de resposta.

4) Interceptor de resposta: enfileirar e repetir requisições

type RetryableConfig = AxiosRequestConfig & { _retry?: boolean; skipAuthRefresh?: boolean }; api.interceptors.response.use( (res) => res, async (error: AxiosError) => { const status = error.response?.status; const originalConfig = error.config as RetryableConfig | undefined; if (!originalConfig) throw error; if (originalConfig.skipAuthRefresh) { throw error; } if (status !== 401) { throw error; } if (originalConfig._retry) { throw error; } originalConfig._retry = true; if (!isRefreshing) { isRefreshing = true; refreshPromise = performRefresh() .catch((e) => { throw e; }) .finally(() => { isRefreshing = false; }); } try { const newAccessToken = await refreshPromise!; originalConfig.headers = originalConfig.headers ?? {}; originalConfig.headers.Authorization = `Bearer ${newAccessToken}`; return api.request(originalConfig); } catch (refreshError) { throw refreshError; } } );

O que esse código garante:

  • Um refresh por vez: isRefreshing impede múltiplos refresh simultâneos.
  • Fila implícita: requisições que chegam durante o refresh aguardam refreshPromise.
  • Retry controlado: _retry evita loop infinito na mesma requisição.
  • Bypass do refresh: skipAuthRefresh evita que a rota de refresh dispare refresh de novo.

5) Tratar falha de refresh de forma consistente

Se o refresh falhar (refresh token expirado/revogado), você precisa decidir um comportamento único: limpar tokens e redirecionar para login, ou disparar um evento global para o AuthProvider. O ponto crítico é: todas as requisições pendentes devem falhar de forma previsível, e a UI não pode ficar alternando entre “logado” e “deslogado”.

Uma estratégia comum é expor um callback global (ou event emitter) que o interceptor chama quando o refresh falha:

let onAuthExpired: (() => void) | null = null; export function setOnAuthExpired(cb: () => void) { onAuthExpired = cb; } function handleAuthExpired() { setTokens(null); onAuthExpired?.(); }

Então, no catch do refresh, chamar handleAuthExpired() antes de propagar o erro. Assim, a aplicação entra em um estado consistente: sem tokens e com UI redirecionada.

Múltiplas requisições e consistência de headers: armadilhas comuns

Closure com token antigo

Um erro sutil acontece quando você cria funções de API que capturam o token em variáveis locais e depois reutiliza essas funções. Exemplo de antipadrão:

const token = getAccessToken(); export function getOrders() { return api.get("/orders", { headers: { Authorization: `Bearer ${token}` } }); }

Se o token mudar após refresh, getOrders continua usando o token antigo. A correção é sempre ler o token no momento da requisição (via interceptor) ou dentro da função, sem capturar valor fora.

Repetir request com config “mutado”

Ao fazer retry, você frequentemente modifica originalConfig.headers. Isso é OK, mas evite acumular headers duplicados ou manter flags que causem efeitos colaterais. Boas práticas:

  • Use _retry apenas para controle interno.
  • Não reaproveite o mesmo objeto config para múltiplos retries diferentes.
  • Garanta que o header Authorization seja sobrescrito, não concatenado.

Concorrência com refresh token rotativo

Se o backend usa refresh token rotativo (cada refresh retorna um novo refresh token e invalida o anterior), concorrência é ainda mais perigosa: dois refresh simultâneos fazem com que um deles use um refresh token que acabou de ser invalidado pelo outro. A fila de refresh resolve isso porque garante apenas uma chamada de refresh ativa.

Prevenção de loops: onde eles nascem e como bloquear

Loop 1: refresh endpoint retornando 401 e disparando refresh de novo

Se a chamada /auth/refresh retornar 401 e passar pelo mesmo interceptor, você cria recursão. A prevenção é obrigatória:

  • Marcar a requisição de refresh com skipAuthRefresh.
  • Ou usar uma instância Axios separada, sem interceptors, apenas para refresh.

Exemplo com instância separada:

export const refreshClient = axios.create({ baseURL: "/api" }); async function performRefresh(): Promise<string> { const rt = getRefreshToken(); if (!rt) throw new Error("No refresh token"); const { data } = await refreshClient.post("/auth/refresh", { refreshToken: rt }); setTokens({ accessToken: data.accessToken, refreshToken: data.refreshToken }); return data.accessToken; }

Loop 2: retry infinito da mesma requisição

Se você não marcar a requisição original como já retentada, ela pode cair em um ciclo: 401 → refresh → retry → 401 → refresh → retry… Isso pode acontecer quando:

  • O backend está com problema e continua retornando 401 mesmo com token novo.
  • O token novo não foi aplicado corretamente no retry.
  • A rota exige permissões que o usuário não tem (e o backend responde 401/403).

Use _retry e, após uma tentativa, pare e propague o erro. Em casos de permissões, o correto é tratar 403 separadamente (não tentar refresh).

Loop 3: refresh em endpoints públicos

Se você aplica interceptors globalmente, endpoints públicos (ex.: catálogo, landing) podem receber 401 por motivos diversos (rate limit, credenciais inválidas em outro header, etc.). Se você tentar refresh para tudo, pode gerar refresh desnecessário e instabilidade. Estratégias:

  • Somente tentar refresh para requests que realmente exigem autenticação (ex.: config requiresAuth: true).
  • Ou tentar refresh apenas quando havia Authorization no request original.

Exemplo de checagem:

const hadAuthHeader = !!(originalConfig.headers as any)?.Authorization; if (!hadAuthHeader) throw error;

Fila explícita (callbacks) vs promessa compartilhada

Existem duas formas comuns de “fila”:

  • Promessa compartilhada: simples, como no exemplo. Todas aguardam refreshPromise.
  • Fila de callbacks: você armazena resolve/reject de cada request e libera todos quando o refresh termina.

A promessa compartilhada costuma ser suficiente. A fila explícita é útil quando você quer controlar melhor o que acontece com cada request (por exemplo, cancelar algumas, ou aplicar lógica diferente por prioridade).

Exemplo de fila explícita:

let queue: Array<{ resolve: (token: string) => void; reject: (err: any) => void }> = []; function enqueue() { return new Promise<string>((resolve, reject) => { queue.push({ resolve, reject }); }); } function flushQueue(error: any, token: string | null) { queue.forEach(({ resolve, reject }) => { if (error) reject(error); else resolve(token!); }); queue = []; }

Você inicia refresh uma vez e, ao terminar, chama flushQueue(null, newToken) ou flushQueue(err, null). Cada request aguarda seu próprio enqueue().

Cancelamento e “requests zumbis” durante refresh

Enquanto o refresh acontece, o usuário pode navegar para outra rota, fechar modal, ou a tela pode desmontar. Se você retentar requests automaticamente, pode atualizar estado em componentes desmontados (ou disparar efeitos inesperados). Para reduzir isso:

  • Use AbortController (fetch) ou cancelamento do Axios (via signal) para requests que não fazem mais sentido.
  • Ao retentar, respeite o mesmo signal original.
  • Evite que o retry dispare atualizações globais de UI sem checar se a tela ainda está ativa.

Em Axios moderno, você pode passar signal no config e reaproveitar no retry. Se o usuário abortar durante o refresh, o retry deve falhar com erro de cancelamento, não insistir.

Consistência entre abas: evitando refresh duplicado e estado divergente

Mesmo com fila dentro de uma aba, múltiplas abas podem iniciar refresh ao mesmo tempo. Se isso for um problema (especialmente com refresh token rotativo), você pode coordenar via:

  • BroadcastChannel: uma aba avisa as outras que fez refresh e compartilha o novo access token.
  • storage events: ao atualizar tokens no localStorage, outras abas recebem evento e podem atualizar o token em memória.

Exemplo com BroadcastChannel:

const authChannel = new BroadcastChannel("auth"); authChannel.onmessage = (ev) => { if (ev.data?.type === "TOKENS_UPDATED") { setTokens(ev.data.tokens); } if (ev.data?.type === "AUTH_EXPIRED") { setTokens(null); } }; function broadcastTokens(tokens: any) { authChannel.postMessage({ type: "TOKENS_UPDATED", tokens }); }

Após refresh bem-sucedido, além de setTokens, chame broadcastTokens. Assim, outras abas passam a usar o token novo e reduzem 401 em cascata.

Checklist de robustez para concorrência, consistência e loops

  • Centralize o refresh em um único módulo (não em componentes).
  • Garanta “um refresh por vez” com isRefreshing + refreshPromise (ou fila explícita).
  • Marque requests para evitar retry infinito (_retry).
  • Exclua o endpoint de refresh do interceptor (flag ou cliente separado).
  • Não tente refresh para erros que não são 401 (e trate 403 separadamente).
  • Reaplique o Authorization no retry com o token novo.
  • Evite closures com token antigo; sempre leia token no momento da requisição.
  • Defina um caminho único quando refresh falha (limpar tokens + notificar AuthProvider).
  • Considere coordenação entre abas (BroadcastChannel/storage event) se refresh token for rotativo.

Agora responda o exercício sobre o conteúdo:

Em uma SPA com JWT, quando várias requisições recebem 401 quase ao mesmo tempo por expiração do access token, qual abordagem é mais robusta para evitar concorrência, inconsistência e loops?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

A fila de refresh centraliza a renovação (um refresh por vez) e faz as demais requisições aguardarem o resultado, garantindo uso do token atualizado. Marcadores como _retry e a exclusão do endpoint de refresh evitam loops e recursão.

Próximo capitúlo

Páginas de erro e estados de acesso: 404, 403 e fallbacks de carregamento

Arrow Right Icon
Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.