- O que é uma race condition em JS?
- Por que é tão difícil de reproduzir?
- O caso clássico: múltiplas requisições em sequência
- Race condition com
useStateno React - Cancelando requisições com AbortController
- useEffect e a armadilha do cleanup
- Outros padrões que causam race condition
- FAQ
- Próximos passos
O que é uma race condition em JS?
Você faz duas requisições. A segunda deveria sobrescrever a primeira. Mas a primeira termina depois — e o estado da sua aplicação fica errado. Silenciosamente. Sem erro no console.
Isso é uma race condition: duas operações assíncronas competindo pelo mesmo recurso, e o resultado final depende de quem termina primeiro — não de quem foi chamada primeiro.
Em linguagens com threads reais, race conditions envolvem acesso concorrente à memória. Em JavaScript, o event loop é single-thread — mas isso não te protege. A concorrência vem das Promises, e o timing de cada resolve é imprevisível.
// Sequência esperada: A → B
// Sequência real pode ser: A disparou, B disparou, B resolveu, A resolveu ← bug
fetchUserData('A')
fetchUserData('B')
Por que é tão difícil de reproduzir?
Porque depende de latência de rede. No ambiente local, ambas as requisições resolvem em milissegundos e na ordem certa quase sempre. Em produção, com conexão instável, servidor sob carga ou CDN com cache parcial, a ordem muda.
⚠️ Atenção: Esse é o tipo de bug que passa em QA, passa em staging, e só aparece quando um usuário com 4G ruim está usando seu autocomplete às 23h de uma sexta-feira.
A outra razão é que não gera exceção. O estado simplesmente fica errado. E dependendo do contexto, o usuário nem percebe — ou percebe e culpa "lentidão do sistema".
O caso clássico: múltiplas requisições em sequência
O exemplo mais comum: um campo de busca com autocomplete. O usuário digita "re", depois "rea", depois "reac". Três requisições saem quase ao mesmo tempo.
// ❌ Sem controle de ordem — race condition garantida
async function buscar(termo) {
const resultado = await fetch(`/api/busca?q=${termo}`)
const dados = await resultado.json()
setResultados(dados) // qual das três vai ganhar a corrida?
}
Isso parece inofensivo. Mas se a requisição de "re" demorar mais que "reac", o usuário vai ver resultados de "re" depois de ter digitado "reac". Confuso, mas não impossível de acontecer.
// ✓ Controlando com uma variável de versão
let versaoAtual = 0
async function buscar(termo) {
const versao = ++versaoAtual
const resultado = await fetch(`/api/busca?q=${termo}`)
const dados = await resultado.json()
if (versao !== versaoAtual) return // resposta antiga, ignora
setResultados(dados)
}
Simples e funciona. Cada chamada carrega seu próprio "ticket de versão". Se quando a resposta chegar o ticket não for mais o atual, ela é descartada.
Race condition com useState no React
No React, o problema fica mais sutil porque o setState não é síncrono e o componente pode desmontar antes da Promise resolver.
// ❌ Clássico bug de componente desmontado
useEffect(() => {
fetch('/api/dados')
.then(r => r.json())
.then(dados => {
setDados(dados) // pode rodar depois do componente desmontar
})
}, [])
Se o usuário navegar para outra rota antes da requisição terminar, você vai atualizar estado de um componente que não existe mais. React vai lançar um warning nas versões antigas — nas novas, simplesmente ignora, mas o comportamento ainda é indesejado.
💡 Dica: Em React 18+, o
useEffectem StrictMode roda duas vezes no desenvolvimento de propósito, justamente para expor esse tipo de bug. Se você viu requisições duplicadas e ficou confuso, era isso.
Cancelando requisições com AbortController
A solução mais robusta para o caso do fetch é cancelar a requisição anterior antes de disparar a nova. O AbortController existe exatamente para isso.
// ✓ Cancelando a requisição anterior
let controller = null
async function buscar(termo) {
if (controller) controller.abort() // cancela a anterior
controller = new AbortController()
try {
const resultado = await fetch(`/api/busca?q=${termo}`, {
signal: controller.signal
})
const dados = await resultado.json()
setResultados(dados)
} catch (err) {
if (err.name === 'AbortError') return // cancelamento esperado, ignora
throw err
}
}
O AbortError precisa ser tratado separadamente — ele não é um erro real, é um cancelamento intencional. Se você jogar todos os erros no mesmo catch sem checar o nome, vai acabar mostrando mensagem de erro para o usuário quando não devia.
Isso apareceu num PR meu em 2022 e o revisor só comentou: "o que acontece se o usuário digitar rápido?". Boa pergunta. Naquele dia aprendi que não existe "o usuário não vai fazer isso".
useEffect e a armadilha do cleanup
A versão idiomática no React combina AbortController com a função de cleanup do useEffect:
// ✓ Padrão correto com cleanup
useEffect(() => {
const controller = new AbortController()
fetch(`/api/busca?q=${termo}`, { signal: controller.signal })
.then(r => r.json())
.then(dados => setResultados(dados))
.catch(err => {
if (err.name !== 'AbortError') setErro(err)
})
return () => controller.abort() // cleanup: cancela se o componente desmontar ou o termo mudar
}, [termo])
O cleanup roda antes de cada nova execução do efeito e quando o componente desmonta. Ou seja: toda vez que termo mudar, a requisição anterior é cancelada automaticamente. Sem variáveis de versão externas, sem state extra.
Por que isso importa? Porque sem o cleanup, cada mudança de termo dispara uma nova requisição sem cancelar as anteriores. Se o usuário digitar dez caracteres em sequência, você tem dez requisições em voo ao mesmo tempo.
Outros padrões que causam race condition
Não é só fetch. Qualquer operação assíncrona pode criar o problema.
Promise.all com estado compartilhado:
// ❌ As duas Promises atualizam o mesmo estado sem coordenação
async function carregarTudo() {
const [usuarios, pedidos] = await Promise.all([
buscarUsuarios(),
buscarPedidos()
])
// aqui é seguro porque Promise.all espera ambas
setDados({ usuarios, pedidos })
}
Curiosamente, Promise.all é uma das formas de evitar race condition — você espera todas resolverem antes de atualizar o estado. O problema aparece quando as Promises atualizam estado cada uma por conta própria.
Timers concorrentes:
// ❌ Se chamar startPolling() duas vezes sem limpar, dois intervalos rodam juntos
function startPolling() {
setInterval(() => {
fetch('/api/status').then(r => r.json()).then(setStatus)
}, 2000)
}
// ✓ Guarda a referência e limpa antes de criar outro
let intervalId = null
function startPolling() {
if (intervalId) clearInterval(intervalId)
intervalId = setInterval(() => {
fetch('/api/status').then(r => r.json()).then(setStatus)
}, 2000)
}
Operações de escrita no banco via API:
Se o usuário clica "Salvar" duas vezes rápido, duas requisições PATCH saem. Qual vai persistir? Depende de qual chegar por último no servidor. A solução aqui é debounce + desabilitar o botão enquanto a requisição está em andamento.
| Cenário | Solução recomendada |
|---|---|
| Autocomplete / busca | AbortController + cancel anterior |
| useEffect com fetch | AbortController no cleanup |
| Múltiplas Promises independentes | Promise.all ou Promise.allSettled |
| Polling com setInterval | Guardar referência + clearInterval |
| Formulário com envio duplo | Debounce + desabilitar botão |
| Estado de versão manual | Variável de controle (let versaoAtual) |
FAQ
Race condition é o mesmo que deadlock? Não. Deadlock é quando dois processos ficam esperando um pelo outro e travam. Race condition é quando dois processos competem e o resultado depende da ordem de chegada. Em JavaScript puro com single-thread você não tem deadlock real, mas race condition acontece com frequência por causa das Promises.
async/await resolve o problema automaticamente?
Não. async/await é só sintaxe sobre Promises. Se você tem dois await em sequência sem controle, a assincronicidade ainda existe e a race condition também.
Devo usar bibliotecas como React Query ou SWR para evitar isso? Sim, e é uma boa recomendação para a maioria dos casos. Essas libs já lidam com deduplicação de requisições, cancelamento e cache de forma confiável. Mas entender o problema por baixo é importante — você vai precisar quando o bug aparecer fora desse contexto.
Como testar race conditions em desenvolvimento?
O Chrome DevTools tem a aba Network com opção de throttling (3G, 4G lento). Também dá para adicionar delay artificial no servidor durante dev: await new Promise(r => setTimeout(r, 1000 + Math.random() * 2000)) antes de responder. Instabilidade forçada expõe o bug.
useTransition do React 18 resolve race conditions?
Parcialmente. useTransition marca atualizações de estado como não urgentes, o que pode mascarar o sintoma visual — mas não cancela as requisições de rede. A race condition de dados ainda existe. Para cancelar de verdade, você ainda precisa do AbortController.
Próximos passos
Se você tem fetch dentro de useEffect sem cleanup de AbortController, esse é o primeiro lugar para corrigir. Abre o código agora e conta quantos efeitos têm essa estrutura.
Depois, olha para os campos de busca e autocomplete da sua aplicação. Se não tem debounce nem cancelamento de requisição anterior, você tem uma race condition esperando o momento certo para aparecer.
Por fim, considera adotar React Query ou SWR se ainda não usa. Não porque você não sabe resolver — você sabe agora — mas porque ter isso resolvido na infraestrutura libera energia para os problemas que realmente precisam da sua atenção.
Race condition é daqueles bugs que, quando você finalmente entende, começa a ver em todo lugar. Isso é bom.