Cache-aside em Node.js: o que muda de verdade entre 1000 requisições sem e com cache
Quanto de carga eu tiro do banco quando 1000 pessoas pedem a mesma coisa ao mesmo tempo? Foi essa a pergunta que motivou esse teste.
Montei um cenário concreto pra responder isso: uma API básica em Express, MongoDB rodando em Docker, e um dataset de 100 mil produtos pra ter volume de dado real.
A rota testada
A rota é simples, com paginação:
./controller/productsController.js
const productsController = {
get: async function (req, res) {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const skip = (page - 1) * limit;
const result = await productsModels.findAll(skip, limit)
res.status(200).json({
page,
limit,
total: result.total,
count: result.data.length,
data: result.data
})
}
}
Em cima dela eu apliquei um middleware de cache usando a lib node-cache. Pra forçar um cenário realista de carga, testei com limit=100000 — na prática isso equivale a trazer a coleção inteira numa única página, o tipo de query pesada que, mesmo com paginação disponível, alguém vai acabar escrevendo assim mesmo sabendo que não deveria.
Os números
| Situação | Requisições na API | Queries no banco |
|---|---|---|
| Sem cache | 1000 | 1000 |
| Com cache | 1000 | 1 |
Em tempo: a consulta sem cache levou cerca de 40 segundos, e com cache caiu pra cerca de 22 — uma melhora real, mas longe de instantânea, porque o gargalo de trafegar ~110 mil documentos pela rede continua existindo mesmo com o dado em memória.
A coluna que importa aqui não é a de tempo, é a de queries: de 1000 requisições, só 1 encostou no banco. As outras 999 foram respondidas direto da memória do processo Node, por causa desse trecho do middleware:
./middleware/cache.js
const key = `${req.method}:${req.originalUrl}`;
const cached = cache.get(key);
if (cached) {
return res.json(cached);
}
Por que isso importa mais que velocidade
O ganho de cache aqui não é velocidade, é proteção do banco contra carga repetida. Se 1000 usuários pedem a mesma coisa dentro da janela de TTL (60 segundos, no meu stdTTL), o MongoDB nem fica sabendo que 999 dessas requisições existiram. Isso importa mais pra escalabilidade do que economizar alguns milissegundos de resposta — um banco que recebe 1 query em vez de 1000 aguenta muito mais tráfego simultâneo antes de cair.
Limitações que ficaram evidentes
Esse experimento é propositalmente simples, e isso deixou claro onde ele quebra:
- O cache vive na memória de um único processo Node. Se eu subir duas instâncias da API atrás de um load balancer, cada uma tem seu próprio cache — e o hit rate cai pela metade (ou mais).
- Não existe invalidação ativa. Se um produto for atualizado no banco durante a janela de TTL, a API vai continuar servindo o dado antigo por até 60 segundos.
- O TTL fixo é uma aposta. Curto demais e eu perco boa parte do benefício; longo demais e o risco de servir dado desatualizado cresce.
Próximo passo
O caminho óbvio daqui é trocar o node-cache (em memória, por processo) por Redis (compartilhado entre instâncias), e pensar em invalidação ativa em vez de só depender de TTL — invalidar a chave quando o dado muda, ao invés de esperar ele expirar sozinho.