Da fila simples ao prefetch, entenda concorrência, async/await, auto-scaling e bulkhead usando a analogia de um banco com atendentes e gerente.
Imagine um banco. Há atendentes nos guichês, uma fila de clientes e um gerente ao fundo que precisa ser consultado nos casos mais complexos.
Esse cenário simples é uma das melhores analogias para entender como Node.js lida com concorrência — e por que as decisões arquiteturais que parecem óbvias no mundo físico se traduzem diretamente em padrões de engenharia que fazem a diferença entre um sistema que escala e um que colapsa sob carga.
Vamos percorrer esse caminho do cenário mais simples ao mais complexo, sempre relacionando o banco com o que acontece no código.
p-limit.Use as animações de cada seção para visualizar o que acontece em cada etapa. Ou explore todos os cenários de uma vez com a navegação completa abaixo.
bank-parallelism
O banco abre com 6 guichês. Cada atendente recebe um cliente, processa o pedido do início ao fim e só então chama o próximo. Fluxo linear e previsível.
Fora do horário de pico funciona bem. Mas às 12h, o volume de clientes explode. A fila cresce mais rápido do que os atendentes conseguem absorver. Clientes esperando 35 minutos ficam insatisfeitos.
bank-parallelism/basic_queue
No Node.js, o equivalente é processamento síncrono no event loop:
// ❌ Atendimento síncrono — bloqueia o event loop
app.get('/saldo', (req, res) => {
const result = calcularSaldo(req.params.id); // CPU-bound, bloqueia tudo
res.json(result);
});
Uma função síncrona pesada impede que todas as outras requisições sejam processadas enquanto ela roda. Node.js é single-threaded: um atendente bloqueado bloqueia o banco inteiro.
Lição: Nunca execute operações CPU-bound ou bloqueantes no event loop. Use
worker_threadspara CPU-bound ou torne operações I/O verdadeiramente assíncronas.
A solução óbvia: contratar mais atendentes. O gerente dobra o efetivo para 12.
O pico do almoço é absorvido. Mas às 15h, o movimento cai e 10 dos 12 atendentes ficam ociosos — com custo fixo correndo.
bank-parallelism/more_tellers
Na prática de Node.js, isso é escalar horizontalmente com um número fixo de workers. Funciona no pico, mas desperdiça recursos — e dinheiro — nos outros momentos.
Lição: Escala horizontal fixa resolve pico mas cria ociosidade no vale. A solução está no auto-scaling, que veremos adiante.
Voltando a 6 atendentes. A maioria dos pedidos (consulta de saldo, extrato) é rápida. Mas alguns pedidos — análise de crédito, aplicações em renda variável — exigem autorização do gerente.
O atendente vai até o escritório do gerente, aguarda ele terminar a reunião e só então volta para o cliente. Durante todo esse tempo, o guichê está parado e a fila cresce.
bank-parallelism/manager_dep
No Node.js, isso é uma chamada I/O bloqueante — uma thread presa esperando banco de dados ou API externa responder:
// ❌ Chamada bloqueante
app.get('/credito', (req, res) => {
const result = http.get(creditServiceUrl); // bloqueia!
res.json(result);
});
Lição: Identifique quais rotas fazem chamadas lentas. Essas são suas dependências do gerente.
Solução elegante: quando um pedido precisa do gerente, o atendente orienta o cliente a sentar na área de espera e já chama o próximo da fila. Quando o gerente terminar, ele mesmo chama o cliente de volta.
O guichê fica livre. O throughput aumenta.
bank-parallelism/async_chairs
Node.js foi projetado exatamente para isso. O await real suspende a execução e devolve o controle ao event loop:
// ✅ async/await — event loop livre
app.get('/credito', async (req, res) => {
// Suspende essa fn, libera o loop p/ outras reqs
const result = await creditService.analyze(req.body);
res.json(result);
});
Mas há um limite: a área de espera tem apenas 4 cadeiras. Promises acumuladas sem controle consomem heap. O custo de manter cada "cliente nas cadeiras" é real.
Lição:
async/awaitlibera o event loop, mas memória não é infinita. Sempre limite a concorrência de operações paralelas.
O gerente decide não mais contratar número fixo. Em vez disso, ele monitora a fila: quando ultrapassa X clientes, contrata atendentes temporários. Quando a fila esvazia e há ociosidade, dispensa o excedente mantendo um mínimo.
bank-parallelism/auto_scaling
Em Kubernetes, isso é o Horizontal Pod Autoscaler (HPA):
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
O custo agora é proporcional ao uso real: sobe no pico, desce no vale.
Lição: Auto-scaling é a combinação de eficiência de custo e capacidade de absorver pico. É a resposta correta para cargas variáveis.
O gerente está sobrecarregado. Às vezes, quando o atendente vai consultá-lo, ele simplesmente não está disponível. Um atendente ingênuo iria de volta imediatamente — e ficaria perturbando o gerente a cada segundo. Isso piora a situação.
O atendente experiente sabe esperar: tenta uma vez, aguarda 2s, tenta de novo, aguarda 4s, tenta mais uma vez.
bank-parallelism/retry_backoff
async function withBackoff(fn, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxRetries - 1) throw err;
// 1s, 2s, 4s — com jitter para evitar sincronismo
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
await new Promise(r => setTimeout(r, delay));
}
}
}
O exponential backoff dá tempo para o serviço se recuperar de uma sobrecarga temporária e evita que múltiplos clientes retroalimentem o problema ao martelar o serviço ao mesmo tempo (thundering herd).
Lição: Retry sem backoff piora falhas em cascata. Exponential backoff + jitter é o padrão de resiliência para dependências externas.
Sem nenhum controle, o que acontece quando 500 clientes chegam ao mesmo tempo e todos precisam do gerente? O atendente coloca todos na área de espera sem verificar quantas cadeiras existem. O gerente pergunta ao atendente quantos estão esperando — o atendente entra em colapso nervoso.
bank-parallelism/overflow
// ❌ Sem backpressure — heap explode
app.post('/operacao', async (req, res) => {
// 500 reqs simultâneas = 500 Promises no heap
const result = await servicoExterno(req.body);
res.json(result);
});
// Terminal:
// FATAL ERROR: Reached heap limit
// Allocation failed - JavaScript heap out of memory
Lição: Concorrência irrestrita é tão destrutiva quanto processamento síncrono. O sistema precisa de backpressure.
A solução é o prefetch: o atendente só aceita, no máximo, 3 clientes na área de espera de uma vez. Quando chega um 4º que precisa das cadeiras, ele fica na fila principal até uma vaga abrir.
bank-parallelism/prefetch
Em RabbitMQ, prefetch é nativo:
// RabbitMQ — prefetch alinhado à capacidade
channel.prefetch(3); // máx 3 mensagens não-acked ao mesmo tempo
channel.consume('operacoes', async (msg) => {
await processarOperacao(JSON.parse(msg.content));
channel.ack(msg); // libera slot p/ próxima
});
Para HTTP ou qualquer fluxo de Promises, o padrão Bulkhead com p-limit:
import pLimit from 'p-limit';
const limit = pLimit(3); // máx 3 Promises simultâneas
const results = await Promise.all(
requests.map(req => limit(() => servicoExterno(req)))
);
O p-limit mantém uma fila interna: quando há slot livre, despacha a próxima tarefa. O heap permanece estável porque nunca há mais de 3 Promises abertas simultaneamente.
Lição: Prefetch e Bulkhead limitam a concorrência ao que o sistema aguenta, criando backpressure automático e prevenindo esgotamento de recursos.
| Cenário do Banco | Padrão em Node.js | Problema resolvido |
|---|---|---|
| Atendente síncrono | Event loop bloqueado | Throughput zero durante operações pesadas |
| Mais atendentes fixos | PM2 cluster / réplicas fixas | Pico absorvido, mas custo fixo alto |
| Atendente espera gerente | I/O bloqueante | Guichê parado, fila cresce |
| Área de espera (cadeiras) | async/await não-bloqueante | Event loop livre durante I/O |
| Número de cadeiras limitado | Limite de Promises abertas | Controle de memória |
| Gerente contrata sob demanda | Kubernetes HPA / KEDA | Custo proporcional ao uso |
| Atendente com paciência | Retry + Exponential Backoff | Resiliência a falhas temporárias |
| Sem limite de cadeiras | Concorrência irrestrita | Heap esgotado, processo morto |
| Máximo de cadeiras | Prefetch + Bulkhead (p-limit) | Throughput máximo seguro |
Concorrência em Node.js não é sobre fazer tudo ao mesmo tempo — é sobre fazer o certo ao mesmo tempo, com limites bem definidos. O banco funciona bem não porque tem atendentes infinitos, mas porque tem processos claros: quem espera onde, por quanto tempo, e o que acontece quando a capacidade é atingida.
async/await libera o event loop mas não resolve tudo sozinho. Auto-scaling garante capacidade sem desperdício. Retry com backoff evita tempestades. Prefetch e Bulkhead protegem contra colapso.
Esses padrões juntos formam a base de qualquer sistema Node.js que precisa ser confiável sob carga real.