Voltar para o blog
Node.jsBackendConcorrênciaRedis

Race conditions no Node.js: como identificar, reproduzir e resolver

11 de maio de 202610 min de leiturapor Sávio Araújo

O que é uma race condition?

Uma race condition acontece quando dois ou mais processos acessam e modificam um estado compartilhado ao mesmo tempo, e o resultado final depende da ordem não-determinística em que essas operações ocorrem.

No Node.js, isso é especialmente traiçoeiro porque o event loop cria uma falsa sensação de segurança. "Node.js é single-threaded": você já deve ter ouvido isso como se fosse uma proteção contra concorrência. Não é.

O Node.js executa JavaScript em uma única thread, mas operações de I/O (banco de dados, rede, filesystem) são assíncronas. E é exatamente aí que as race conditions se escondem.

Um exemplo real

Imagine um endpoint de transferência bancária:

async function transfer(fromId: string, toId: string, amount: number) {
  const from = await db.findUser(fromId);
 
  if (from.balance < amount) {
    throw new Error("Saldo insuficiente");
  }
 
  await db.deductBalance(fromId, amount);
  await db.addBalance(toId, amount);
}

À primeira vista, parece correto. Mas o que acontece se dois requests chegarem ao mesmo tempo com o mesmo fromId, e o usuário tem exatamente saldo para cobrir uma transferência?

  1. Request A lê: balance = 100
  2. Request B lê: balance = 100
  3. Request A verifica: 100 >= 100 → passa
  4. Request B verifica: 100 >= 100 → passa
  5. Request A debita: balance = 0
  6. Request B debita: balance = -100 ← problema

Dois requests passaram pela verificação, dois débitos aconteceram. A conta ficou negativa.

Timeline mostrando dois requests simultâneos e a janela de race condition

Por que acontece no Node.js?

O culpado é o await. Cada await é um ponto de cessão: o event loop pode processar outro request enquanto o atual aguarda I/O. Entre o await db.findUser() e o await db.deductBalance(), há uma janela onde outro request lê o mesmo estado desatualizado.

Request A: findUser ──── [aguarda I/O] ──── deductBalance
Request B:                    └─ findUser ──── [aguarda I/O] ──── deductBalance

Como identificar no seu código

Procure por esse padrão: leia → verifique → escreva com await entre as etapas.

// Padrão perigoso:
const estado = await ler();    // ← ponto de cessão aqui
verificar(estado);
await escrever(estadoNovo);    // ← e aqui completa o problema

Exemplos comuns onde isso aparece:

  • Verificar saldo antes de debitar
  • Verificar se um slot está disponível antes de reservar
  • Verificar se um username está livre antes de criar usuário
  • Incrementar contadores com read-then-write

Solução 1: transações com FOR UPDATE no PostgreSQL

Para operações no banco, a solução mais robusta é usar transações com bloqueio pessimista:

async function transfer(fromId: string, toId: string, amount: number) {
  await db.transaction(async (trx) => {
    // FOR UPDATE bloqueia a linha até o fim da transação
    const from = await trx
      .select("*")
      .from("users")
      .where({ id: fromId })
      .forUpdate()
      .first();
 
    if (from.balance < amount) {
      throw new Error("Saldo insuficiente");
    }
 
    await trx("users").where({ id: fromId }).decrement("balance", amount);
    await trx("users").where({ id: toId }).increment("balance", amount);
  });
}

O FOR UPDATE garante que nenhuma outra transação pode ler ou modificar essa linha até a transação atual terminar (commit ou rollback).

Solução 2: operação atômica no banco

Para casos simples, elimine o read-modify-write completamente:

// Em vez de:
const user = await db.findUser(id);
await db.update({ balance: user.balance - amount });
 
// Faça:
const updated = await db("users")
  .where({ id: fromId })
  .where("balance", ">=", amount)  // condição na mesma query
  .decrement("balance", amount)
  .returning("*");
 
if (updated.length === 0) {
  throw new Error("Saldo insuficiente");
}

Essa query só executa o decrement se o saldo for suficiente, e isso acontece atomicamente no banco: sem janela de inconsistência.

Solução 3: locks distribuídos com Redis

Quando a operação envolve múltiplos sistemas ou não passa pelo banco, use locks distribuídos:

import { Redis } from "ioredis";
import crypto from "crypto";
 
const redis = new Redis();
 
async function acquireLock(key: string, ttlMs: number): Promise<string | null> {
  const token = crypto.randomUUID();
  const result = await redis.set(key, token, "PX", ttlMs, "NX");
  return result === "OK" ? token : null;
}
 
async function releaseLock(key: string, token: string): Promise<void> {
  // Script Lua garante atomicidade: só libera se o token for o mesmo
  const script = `
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else
      return 0
    end
  `;
  await redis.eval(script, 1, key, token);
}
 
async function transfer(fromId: string, toId: string, amount: number) {
  const lockKey = `lock:transfer:${fromId}`;
  const token = await acquireLock(lockKey, 5000); // TTL de 5 segundos
 
  if (!token) {
    throw new Error("Operação em andamento, tente novamente");
  }
 
  try {
    const from = await db.findUser(fromId);
 
    if (from.balance < amount) {
      throw new Error("Saldo insuficiente");
    }
 
    await db.deductBalance(fromId, amount);
    await db.addBalance(toId, amount);
  } finally {
    await releaseLock(lockKey, token);
  }
}

Pontos críticos nessa implementação:

  • NX (Not eXists): só cria a chave se não existir: garante exclusividade
  • TTL: evita deadlock se o processo morrer com o lock adquirido
  • Token único: o script Lua garante que só o processo que criou o lock pode liberá-lo
  • finally: sempre libera o lock, mesmo em caso de erro

Qual abordagem usar?

| Situação | Solução recomendada | |---|---| | Tudo no mesmo banco | Transação com FOR UPDATE | | Operação simples e atômica | Query com condição WHERE | | Múltiplos sistemas | Lock distribuído com Redis | | Baixa contenção, retry aceitável | Optimistic locking |

Optimistic locking: para baixa contenção

Se conflitos são raros, bloqueio pessimista desperdiça performance. Optimistic locking é mais eficiente:

async function updateItem(id: string, newData: object) {
  const current = await db.findById(id);
 
  const updated = await db("items")
    .where({ id, version: current.version })  // só atualiza se versão bater
    .update({ ...newData, version: current.version + 1 });
 
  if (updated === 0) {
    throw new Error("Conflito: dado foi modificado. Tente novamente.");
  }
}

O caller captura o erro e faz retry. Funciona bem quando conflitos são raros.

Conclusão

Race conditions no Node.js existem porque operações assíncronas criam janelas de inconsistência entre leitura e escrita. A proteção certa depende do contexto:

  • Banco de dados: transações com FOR UPDATE ou queries atômicas
  • Sistemas distribuídos: locks com Redis (com TTL e token único)
  • Baixa contenção: optimistic locking com campo de versão

O erro mais comum é assumir que "Node.js é single-threaded, então não preciso me preocupar com concorrência". A thread única protege a execução do seu JavaScript: não protege o estado externo que ele acessa de forma assíncrona.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech