Race Conditions em JavaScript Assíncrono: como identificar e corrigir antes que o bug vá a produção
← Voltar para Codeshort

Race Conditions em JavaScript Assíncrono: como identificar e corrigir antes que o bug vá a produção

Race conditions em JS assíncrono são silenciosas e destrutivas. Veja como identificar, reproduzir e corrigir os casos mais comuns antes que eles derrubem sua aplicação.

DC
Dev Code Software
22 de junho de 2026·6 min de leitura

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 useEffect em 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árioSolução recomendada
Autocomplete / buscaAbortController + cancel anterior
useEffect com fetchAbortController no cleanup
Múltiplas Promises independentesPromise.all ou Promise.allSettled
Polling com setIntervalGuardar referência + clearInterval
Formulário com envio duploDebounce + desabilitar botão
Estado de versão manualVariá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.