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?
- Request A lê:
balance = 100 - Request B lê:
balance = 100 - Request A verifica:
100 >= 100→ passa - Request B verifica:
100 >= 100→ passa - Request A debita:
balance = 0 - Request B debita:
balance = -100← problema
Dois requests passaram pela verificação, dois débitos aconteceram. A conta ficou negativa.
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 problemaExemplos 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 UPDATEou 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.