Voltar para o blog
Node.jsPerformanceBackend

Event loop do Node.js: o que bloqueia e por que isso destrói sua API

14 de maio de 20269 min de leiturapor Sávio Araújo

O que é o event loop?

O event loop é o mecanismo que permite ao Node.js processar múltiplas operações de I/O de forma não-bloqueante, mesmo rodando em uma única thread JavaScript.

A ideia central: o event loop processa um callback por vez, mas não precisa esperar I/O terminar para começar o próximo.

Quando você faz uma query ao banco, o Node.js registra um callback, devolve o controle ao event loop, e só executa o callback quando o banco responde. Nesse meio tempo, outros requests são atendidos normalmente.

Node.js tem mais threads do que você imagina

O que é single-threaded é a thread do event loop: onde seu JavaScript executa. Por baixo, a libuv (biblioteca C responsável pelo I/O) usa um thread pool para operações de filesystem, DNS, e criptografia.

Seu código JS ──→ Event Loop (1 thread JavaScript)
                       │
                       ├── Rede (epoll/kqueue: I/O assíncrono do kernel)
                       ├── Filesystem (thread pool libuv, padrão: 4 threads)
                       ├── DNS (thread pool libuv)
                       └── Crypto (thread pool libuv)

Quando você chama fs.readFile, a leitura acontece numa thread da libuv. Quando termina, o callback é colocado na fila do event loop para execução. O event loop em si nunca bloqueou.

As fases do event loop

Diagrama das fases do event loop do Node.js

O event loop não é uma fila simples: tem fases distintas, executadas em ordem:

timers          → setTimeout, setInterval
pending I/O     → callbacks de I/O com erro do tick anterior
idle/prepare    → uso interno do Node.js
poll            → aguarda I/O; executa callbacks de I/O prontos
check           → setImmediate
close           → socket.on('close', ...)

Entre cada fase, o Node.js drena a fila de microtasks: process.nextTick e Promises resolvidas executam antes de passar para a próxima fase.

Isso explica comportamentos como:

Promise.resolve().then(() => console.log("A"));
process.nextTick(() => console.log("B"));
setImmediate(() => console.log("C"));
setTimeout(() => console.log("D"), 0);
 
// Saída: B → A → D → C
// nextTick > Promises > setTimeout ≈ setImmediate

O que significa "bloquear o event loop"

Bloquear o event loop significa executar código JavaScript síncrono por tempo suficiente para atrasar todos os outros callbacks na fila.

Durante uma operação síncrona, o event loop não pode processar nada mais. Nenhum request entra, nenhuma resposta sai, nenhum callback de I/O executa.

// Isso bloqueia por ~200ms em produção:
app.get("/report", (req, res) => {
  const data = loadMillionsOfRows();  // síncrono
  const result = processData(data);   // CPU-intensivo
  res.json(result);
});

Durante esses 200ms, todos os outros clients ficam na fila, esperando. Mesmo que a request deles fosse trivial.

Como medir o lag do event loop

import { performance } from "perf_hooks";
 
function measureEventLoopLag() {
  const start = performance.now();
  setImmediate(() => {
    const lag = performance.now() - start;
    if (lag > 10) {
      // Mais de 10ms de lag é um sinal de atenção
      console.warn(`Event loop lag: ${lag.toFixed(2)}ms`);
    }
  });
}
 
setInterval(measureEventLoopLag, 1000);

A lógica: setImmediate deveria executar quase imediatamente após ser registrado. Se demorar 100ms, significa que o event loop ficou ocupado por esses 100ms.

Para análise profunda, use clinic:

npx clinic doctor -- node server.js
npx clinic flame -- node server.js

As causas mais comuns de bloqueio

1. JSON pesado

// 10MB de JSON → ~50ms de bloqueio
const obj = JSON.parse(largeJsonString);
const str = JSON.stringify(largeObject);

Se você processa payloads grandes com frequência, considere streaming JSON (stream-json) ou mover o processamento para um Worker Thread.

2. Criptografia síncrona

// NÃO FAÇA:
const hash = crypto.pbkdf2Sync(password, salt, 100000, 64, "sha512");
// pbkdf2Sync com 100k iterações → 200-500ms de bloqueio
 
// FAÇA:
const hash = await new Promise<Buffer>((resolve, reject) => {
  crypto.pbkdf2(password, salt, 100000, 64, "sha512", (err, key) => {
    err ? reject(err) : resolve(key);
  });
});

A versão assíncrona usa o thread pool da libuv: o event loop fica livre.

3. Expressões regulares complexas (ReDoS)

// Perigoso com input não validado:
const emailRegex = /^([a-zA-Z0-9])+([a-zA-Z0-9._-])*@([a-zA-Z0-9_-])+([a-zA-Z0-9._-]+)+$/;
emailRegex.test(maliciousInput); // pode travar por segundos

Sempre valide o tamanho do input antes de aplicar regex complexa. Use bibliotecas como Zod para validação de email: elas têm implementações seguras.

4. Loops síncronos sobre grandes coleções

// Se items tem 500k elementos, isso bloqueia por segundos:
const results = items.map((item) => heavyTransform(item));

Quebre em chunks com setImmediate para ceder o event loop entre eles, ou mova para Worker Threads.

5. Variantes *Sync de I/O

// NUNCA em produção:
const content = fs.readFileSync("./config.json", "utf-8");
const result = execSync("ls -la");

As versões *Sync bloqueiam o event loop inteiro durante a operação de I/O.

Worker Threads: quando e como usar

Para trabalho CPU-bound que não pode ser evitado, Worker Threads permitem rodar JavaScript em threads separadas:

import { Worker, isMainThread, parentPort, workerData } from "worker_threads";
 
if (isMainThread) {
  function runInWorker(data: unknown): Promise<unknown> {
    return new Promise((resolve, reject) => {
      const worker = new Worker(__filename, { workerData: data });
      worker.on("message", resolve);
      worker.on("error", reject);
    });
  }
 
  app.get("/process", async (req, res) => {
    // Processamento pesado não bloqueia o event loop principal
    const result = await runInWorker(req.body);
    res.json(result);
  });
} else {
  // Executa na Worker Thread
  const result = heavyCpuWork(workerData);
  parentPort?.postMessage(result);
}

Worker Threads têm overhead de criação: em produção, use um pool (biblioteca piscina).

I/O assíncrono: não é o problema

É importante entender: operações de I/O não bloqueiam o event loop:

// Isso NÃO bloqueia outros requests:
app.get("/users", async (req, res) => {
  const users = await db.findMany(); // event loop fica livre durante a query
  res.json(users);
});

O await aqui não significa "esperar bloqueado". Significa "suspender esse callback, deixar o event loop processar outros callbacks, e retomar quando o resultado estiver pronto".

O event loop só é bloqueado por código JavaScript síncrono em execução.

Checklist para não bloquear

  • Nunca use variantes *Sync em produção (exceto na inicialização da aplicação)
  • Nunca use pbkdf2Sync, scryptSync, randomFillSync no caminho de requests
  • JSON de mais de 1-2MB merece atenção especial
  • Sempre valide o tamanho do input antes de aplicar regex
  • Loops sobre coleções grandes: meça, considere Worker Threads
  • Use clinic ou 0x periodicamente para identificar hotspots de CPU

Conclusão

O event loop é elegante e altamente eficiente para workloads I/O-bound. O problema aparece quando você mistura trabalho CPU-bound: processar JSON pesado, criptografia síncrona, algoritmos complexos: com o servidor de requests.

A regra prática: se uma operação ocupa mais de ~1ms de CPU no caminho de um request, ela provavelmente não deveria estar no event loop principal. Meça primeiro, otimize onde importa.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech