além do script
Arquitetura

Paralelismo em Node.js: do Banco ao Event Loop

Da fila simples ao prefetch, entenda concorrência, async/await, auto-scaling e bulkhead usando a analogia de um banco com atendentes e gerente.

NodeJSarquiteturaasyncperformance

Nível: Intermediário | Tempo de Leitura: 8 min

por Marcelo Macedo

Introdução

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.

  • Escopo: Concorrência, paralelismo, async/await, auto-scaling, retry, prefetch e bulkhead.
  • Stack: Node.js, RabbitMQ, Kubernetes HPA, p-limit.
  • Impacto: Cada padrão apresentado resolve um problema real de throughput, custo ou estabilidade em produção.

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.

🏦

Fila Simples

6 atendentes síncronos — event loop bloqueante

🏦 Guichês de Atendimento

😴G1Livre
😴G2Livre
😴G3Livre
😴G4Livre
😴G5Livre
😴G6Livre

📋 Fila

vazia ✓

💺 Área de Espera

sem cadeiras
🧑‍💼GerenteDisponível

Guichês livres

6 / 6

Fila

vazia

Passo 1 / 9

Banco abre. 6 guichês disponíveis, fila vazia.


Cenário 1 — Fila Simples, 6 Atendentes

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.

🏦

Fila Simples

6 atendentes síncronos — event loop bloqueante

🏦 Guichês de Atendimento

😴G1Livre
😴G2Livre
😴G3Livre
😴G4Livre
😴G5Livre
😴G6Livre

📋 Fila

vazia ✓

💺 Área de Espera

sem cadeiras
🧑‍💼GerenteDisponível

Guichês livres

6 / 6

Fila

vazia

Passo 1 / 9

Banco abre. 6 guichês disponíveis, fila vazia.

No Node.js, o equivalente é processamento síncrono no event loop:

javascript
// ❌ 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_threads para CPU-bound ou torne operações I/O verdadeiramente assíncronas.


Modificação 1 — Mais Atendentes

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.

👥

+Atendentes

Workers fixos — pico resolvido, vale desperdiçado

🏦 Guichês de Atendimento

😴G1Livre
😴G2Livre
😴G3Livre
😴G4Livre
😴G5Livre
😴G6Livre
😴G7Livre
😴G8Livre
😴G9Livre
😴G10Livre
😴G11Livre
😴G12Livre

📋 Fila

vazia ✓

💺 Área de Espera

sem cadeiras
🧑‍💼GerenteDisponível

Atendentes

12 (fixo)

Custo/mês

R$ 60k

Passo 1 / 7

Gerente decide dobrar o efetivo para 12 atendentes fixos.

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.


Cenário 2 — Dependência do Gerente

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.

⏳

Dependência Gerente

I/O bloqueante — thread presa

🏦 Guichês de Atendimento

💼G11Atendendo
😴G2Livre
😴G3Livre
😴G4Livre
😴G5Livre
😴G6Livre

📋 Fila

2
2
3

💺 Área de Espera

sem cadeiras
🧑‍💼GerenteDisponível

Tempo médio

3 min

Throughput

20/h

Passo 1 / 7

6 guichês. Pedidos simples (saldo, extrato) são processados rápido.

No Node.js, isso é uma chamada I/O bloqueante — uma thread presa esperando banco de dados ou API externa responder:

javascript
// ❌ 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.


Modificação 2 — Área de Espera (Async/Await)

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.

💺

Async / Cadeiras

await libera o guichê — Promises ilimitadas esgotam heap

🏦 Guichês de Atendimento

😴G1Livre
😴G2Livre
😴G3Livre
😴G4Livre
😴G5Livre
😴G6Livre

📋 Fila

3
1
2
3

💺 Área de Espera

0/4
—
—
—
—
🧑‍💼GerenteDisponível

Cadeiras

0 / 4

Guichês livres

6 / 6

Passo 1 / 7

Nova política: pedido complexo → cliente senta na área de espera. Guichê fica livre para o próximo.

Node.js foi projetado exatamente para isso. O await real suspende a execução e devolve o controle ao event loop:

javascript
// ✅ 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/await libera o event loop, mas memória não é infinita. Sempre limite a concorrência de operações paralelas.


Modificação 3 — Auto-Scaling

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.

📈

Auto Scaling

Kubernetes HPA — escala com a demanda

🏦 Guichês de Atendimento

😴G1Livre
😴G2Livre

📋 Fila

vazia ✓

💺 Área de Espera

sem cadeiras
🧑‍💼GerenteDisponível

☁️ Pods ativos

▣
▣
2 pods

Pods

2 (min)

CPU

12%

Custo

mínimo ✓

Passo 1 / 7

Manhã — apenas 2 pods ativos (mínimo configurado). Custo baixo.

Em Kubernetes, isso é o Horizontal Pod Autoscaler (HPA):

yaml
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.


Modificação 4 — Retry com Exponential Backoff

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.

🔁

Retry + Backoff

Exponential backoff — não martelar o serviço

🏦 Guichês de Atendimento

💼G11Atendendo
💼G22Atendendo
😴G3Livre
😴G4Livre
😴G5Livre
😴G6Livre

📋 Fila

1
3

💺 Área de Espera

sem cadeiras
🧑‍💼GerenteDisponível

Gerente

disponível

Pedido

complexo

Passo 1 / 7

6 guichês. Cliente 2 chega com pedido complexo — precisa do gerente.

javascript
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.


Cenário de Sobrecarga — Sem Limite de Concorrência

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.

💥

Sobrecarga

Sem limite de concorrência → OOM

🏦 Guichês de Atendimento

💼G11Atendendo
💼G22Atendendo
💼G33Atendendo
😴G4Livre
😴G5Livre
😴G6Livre

📋 Fila

1
4

💺 Área de Espera

1/4
5
—
—
—
Heap usado20%
🤝GerenteOcupado

Promises abertas

4

Heap

20%

Passo 1 / 6

Tudo parece normal. Clientes chegando, guichês atendendo.

javascript
// ❌ 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.


Modificação Final — Prefetch e Bulkhead

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.

🛡️

Prefetch / Bulkhead

Limite de concorrência alinhado à capacidade

🏦 Guichês de Atendimento

😴G1Livre
😴G2Livre
😴G3Livre
😴G4Livre
😴G5Livre
😴G6Livre

📋 Fila

7
1
2
3
4
5
6
7

💺 Área de Espera

sem cadeiras
Heap usado8%
🧑‍💼GerenteDisponível

Prefetch

3 (limite)

Heap

8%

Passo 1 / 7

Configuração: prefetch = 3. Atendente só aceita até 3 clientes simultâneos.

Em RabbitMQ, prefetch é nativo:

javascript
// 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:

javascript
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.


Resumo dos Padrões

Cenário do BancoPadrão em Node.jsProblema resolvido
Atendente síncronoEvent loop bloqueadoThroughput zero durante operações pesadas
Mais atendentes fixosPM2 cluster / réplicas fixasPico absorvido, mas custo fixo alto
Atendente espera gerenteI/O bloqueanteGuichê parado, fila cresce
Área de espera (cadeiras)async/await não-bloqueanteEvent loop livre durante I/O
Número de cadeiras limitadoLimite de Promises abertasControle de memória
Gerente contrata sob demandaKubernetes HPA / KEDACusto proporcional ao uso
Atendente com paciênciaRetry + Exponential BackoffResiliência a falhas temporárias
Sem limite de cadeirasConcorrência irrestritaHeap esgotado, processo morto
Máximo de cadeirasPrefetch + Bulkhead (p-limit)Throughput máximo seguro

Conclusão

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.

💡 Precisa de suporte técnico para arquitetar sistemas resilientes e escaláveis?

Sou Marcelo Alberico Macedo, Engenheiro de Software Sênior e Arquiteto. Possuo MBA pela USP/Esalq e atuo desenhando ecossistemas de microsserviços, mensageria e alta disponibilidade para plataformas enterprise.