O Cache Stampede no Node.js acontece quando uma chave popular expira e muitas requisições tentam recomputar o mesmo valor ao mesmo tempo. Em vez de proteger o banco ou a API externa, o cache provoca um pico repentino de carga justamente no momento da expiração.
Esse problema também é chamado de thundering herd ou dogpile effect. Ele aparece em aplicações com várias réplicas, tráfego concentrado e dados caros de gerar. A solução não depende de uma única técnica: locks por chave, stale-while-revalidate, jitter no TTL, revalidação antecipada e limites de concorrência podem ser combinados.
Neste guia, você aprenderá a detectar cache stampede, implementar request coalescing local e distribuído, servir conteúdo stale, aplicar TTL com jitter, fazer revalidação probabilística e evitar que o mecanismo de proteção crie uma nova dependência crítica.
Como o problema acontece?
Imagine uma página de produto que recebe mil requisições por segundo. O resultado fica no Redis por cinco minutos. Quando a chave expira, dezenas de réplicas consultam o banco simultaneamente:
- todas recebem cache miss;
- todas executam a mesma consulta;
- o banco fica sobrecarregado;
- a latência aumenta;
- timeouts causam retries;
- o pico se amplifica.
A análise da Cloudflare sobre cache stampede mostra como locks e revalidação probabilística reduzem requisições simultâneas à origem.
Cache-aside simples
async function getProduct(id) {
const key = `product:${id}`;
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const product = await database.findProduct(id);
await redis.set(key, JSON.stringify(product), {
EX: 300
});
return product;
}Esse padrão funciona em baixa concorrência, mas não coordena misses simultâneos.
Request coalescing em uma instância
Dentro de um único processo, compartilhe a Promise em andamento:
const inFlight = new Map();
async function coalesce(key, loader) {
const existing = inFlight.get(key);
if (existing) return existing;
const promise = loader().finally(() => {
inFlight.delete(key);
});
inFlight.set(key, promise);
return promise;
}Uso:
const product = await coalesce(key, async () => {
const value = await database.findProduct(id);
await redis.set(key, JSON.stringify(value), { EX: 300 });
return value;
});Isso resolve concorrência local, mas cada réplica ainda cria sua própria Promise.
Lock distribuído por chave
async function getWithLock(key, loader) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lock = await acquireLock(`cache:${key}`, 10_000);
if (lock) {
try {
const secondCheck = await redis.get(key);
if (secondCheck) return JSON.parse(secondCheck);
const value = await loader();
await redis.set(key, JSON.stringify(value), { EX: 300 });
return value;
} finally {
await releaseLock(lock);
}
}
return waitForCache(key);
}O segundo check é obrigatório. Enquanto a instância aguardava o lock, outra pode ter preenchido a chave.
Veja Locks Distribuídos com Redis.
Esperando o vencedor
async function waitForCache(key) {
for (let attempt = 0; attempt < 10; attempt++) {
await sleep(50 + Math.random() * 100);
const value = await redis.get(key);
if (value) return JSON.parse(value);
}
throw new Error('Cache ainda indisponível');
}Use limite e jitter. Polling infinito apenas troca sobrecarga do banco por sobrecarga do Redis.
Servindo valor stale
Uma estratégia melhor é manter o valor além do TTL lógico:
{
"value": { "id": 42, "name": "Produto" },
"freshUntil": 1789462800000,
"staleUntil": 1789463100000
}Durante o período stale, a aplicação entrega o valor antigo e apenas uma instância atualiza em segundo plano.
if (Date.now() < entry.freshUntil) {
return entry.value;
}
if (Date.now() < entry.staleUntil) {
triggerRefreshInBackground(key);
return entry.value;
}
return refreshSynchronously(key);Stale-while-revalidate
No HTTP, use stale-while-revalidate quando CDN e clientes suportam:
Cache-Control: public, max-age=60, stale-while-revalidate=300Veja ETag e Cache HTTP no Node.js.
TTL com jitter
Se milhares de chaves são criadas juntas com o mesmo TTL, todas expiram juntas. Adicione variação:
function ttlWithJitter(baseSeconds, percent = 0.2) {
const variation = baseSeconds * percent;
const jitter = Math.random() * variation * 2 - variation;
return Math.max(1, Math.round(baseSeconds + jitter));
}
await redis.set(key, value, {
EX: ttlWithJitter(300)
});O jitter espalha expirações, mas não resolve sozinho uma chave extremamente popular.
Revalidação antecipada
Atualize antes da expiração:
if (remainingTtlSeconds < 30) {
refreshInBackground(key);
}Sem coordenação, todas as requisições próximas do fim podem revalidar. Combine com lock, sampling ou probabilidade.
Revalidação probabilística
function shouldRefresh(remainingSeconds, windowSeconds = 300) {
if (remainingSeconds > windowSeconds) return false;
if (remainingSeconds <= 0) return true;
const steepness = 1 / windowSeconds;
return Math.random() < Math.exp(-steepness * remainingSeconds);
}A probabilidade cresce à medida que a expiração se aproxima. Essa estratégia evita um serviço externo de lock, mas não garante exatamente uma revalidação.
Escolhendo a estratégia
- Promise local: simples para uma instância.
- Lock distribuído: controle determinístico entre réplicas.
- Stale-while-revalidate: baixa latência e alta disponibilidade.
- TTL com jitter: evita expiração sincronizada de muitas chaves.
- Probabilística: reduz coordenação externa.
- Pré-aquecimento: útil para catálogo conhecido.
Negative caching
Ausências também podem causar stampede. Armazene um marcador por pouco tempo:
const NOT_FOUND = JSON.stringify({ found: false });
await redis.set(key, NOT_FOUND, { EX: 30 });Use TTL curto para não esconder dados recém-criados.
Falhas da origem
Quando o banco está indisponível, não substitua um valor stale válido por erro. Mantenha a última versão até o limite:
try {
return await refresh(key);
} catch (error) {
if (staleEntry) return staleEntry.value;
throw error;
}Registre que o valor está stale e alerte quando a idade ultrapassar o SLO.
Timeouts
Todo loader precisa de timeout:
const value = await fetchFromOrigin({
signal: AbortSignal.timeout(2000)
});Sem timeout, o lock pode expirar enquanto a operação continua.
Limite de concorrência
Mesmo para chaves diferentes, limite recomputações:
const limit = pLimit(20);
const value = await limit(() => loader());Isso protege o banco durante aquecimento ou perda total do Redis.
Cache warming
Pré-carregue itens mais populares após deploy ou limpeza:
for (const id of popularProductIds) {
await limit(() => refreshProduct(id));
}Não tente aquecer todo o catálogo sem medir custo.
Invalidação
Quando o dado muda, invalide ou atualize a chave. Evite apagar milhares de chaves no mesmo instante. Uma alternativa é versionar namespace:
product:v3:42O namespace antigo expira naturalmente.
Redis indisponível
Defina comportamento:
- fallback direto com concorrência limitada;
- circuit breaker;
- valor local stale;
- erro rápido para rotas não críticas;
- fila para processamento posterior.
Consulte Circuit Breaker no Node.js.
Observabilidade
Meça:
- hit, miss e stale hit;
- revalidações iniciadas;
- requisições agrupadas;
- contenção do lock;
- tempo do loader;
- idade do valor stale;
- falhas da origem;
- chaves mais recomputadas.
Evite IDs individuais como labels Prometheus.
Teste de carga
Simule uma chave popular expirando:
npx autocannon \
--connections 100 \
--duration 30 \
http://localhost:3000/products/42Conte quantas consultas realmente chegam ao banco. Veja Autocannon no Node.js.
Erros comuns
- TTL idêntico: milhares de chaves expiram juntas.
- Lock sem segundo check: recomputação duplicada.
- Polling infinito: Redis fica sobrecarregado.
- Sem stale: qualquer falha vira indisponibilidade.
- Lock global: chaves independentes ficam bloqueadas.
- Loader sem timeout: lease expira no meio.
- Revalidação em todas as requisições: stampede antecipado.
- Cache como única fonte: perda do Redis derruba o sistema.
Conclusão
O Cache Stampede no Node.js ocorre quando misses simultâneos atingem a mesma origem. A correção mais comum é agrupar requisições e permitir que apenas uma recompute cada chave.
Combine lock por chave, segundo check, stale-while-revalidate, jitter e limites de concorrência. Para tráfego muito alto, considere revalidação probabilística. O cache deve suavizar picos e falhas, não sincronizar milhares de requisições contra o banco.



