O Node.js consegue atender muitas conexões simultâneas sem criar uma thread JavaScript para cada requisição. A peça central desse modelo é o Event Loop no Node.js, responsável por coordenar callbacks, timers, operações de entrada e saída e tarefas que retornam de recursos do sistema operacional.
Entender o event loop ajuda a explicar por que uma API pode ter ótimo desempenho com milhares de conexões e, ao mesmo tempo, ficar completamente travada por um único cálculo síncrono. Também esclarece a ordem entre Promises, process.nextTick(), timers e setImmediate().
Neste guia, você aprenderá as fases do event loop, a diferença entre tarefas e microtarefas, como a libuv participa do processamento, quais operações bloqueiam a thread principal e como medir atrasos em produção.
O que é o Event Loop?
O event loop é um mecanismo que verifica continuamente se existem callbacks prontos para execução. O JavaScript de uma aplicação Node.js normalmente roda em uma única thread. Operações de rede, sistema de arquivos, DNS e outras tarefas podem ser delegadas ao sistema operacional ou ao pool de threads da libuv.
Quando uma operação termina, seu callback é colocado na fila adequada. A thread principal executa esse callback quando termina o trabalho atual e alcança a fase correspondente.
A documentação oficial sobre o event loop apresenta o fluxo geral. Para revisar a plataforma, veja o que é Node.js e o que é JavaScript.
JavaScript síncrono continua sendo bloqueante
O modelo orientado a eventos não torna todo código automaticamente assíncrono. Um loop longo ocupa a thread principal:
const startedAt = Date.now();
while (Date.now() - startedAt < 5000) {
// Trabalho síncrono intenso
}
console.log('Terminou');Durante cinco segundos, nenhum callback de requisição, timer ou Promise pode executar. O processo continua ativo, mas deixa de responder de forma útil.
As principais fases do event loop
De forma simplificada, o ciclo percorre fases como:
- timers: processa callbacks de
setTimeout()esetInterval()prontos; - pending callbacks: executa determinados callbacks de operações do sistema;
- idle e prepare: usadas internamente;
- poll: recebe novos eventos de I/O e processa callbacks relacionados;
- check: executa callbacks de
setImmediate(); - close callbacks: processa encerramentos, como o evento close de sockets.
Essa descrição é uma visão conceitual. Detalhes podem mudar entre versões do runtime, portanto não dependa de ordens que não fazem parte do contrato público.
Call stack
A call stack representa as funções JavaScript em execução. Enquanto existe uma função ativa na pilha, o event loop não pode executar outro callback:
function third() {
console.log('terceira');
}
function second() {
third();
}
function first() {
second();
}
first();As funções entram e saem da pilha em ordem. Apenas depois que a pilha fica vazia o runtime pode avançar para callbacks assíncronos.
Timers definem atraso mínimo
setTimeout(() => {
console.log('timer');
}, 100);O callback não é garantido exatamente aos 100 milissegundos. Ele se torna elegível depois desse prazo. Se a thread estiver ocupada, será executado mais tarde. O guia de Timers no Node.js aprofunda cancelamento, intervalos e APIs de Promises.
Microtarefas e Promises
Callbacks registrados com Promise.then(), catch(), finally() e queueMicrotask() entram na fila de microtarefas:
console.log('A');
Promise.resolve().then(() => {
console.log('B');
});
console.log('C');A saída será A, C e B. O callback da Promise executa depois que a pilha atual fica vazia, antes de o event loop avançar normalmente para outra fase.
process.nextTick()
process.nextTick() agenda uma continuação com prioridade especial no Node.js:
process.nextTick(() => {
console.log('next tick');
});Ele é útil para manter uma API assíncrona de forma consistente, mas pode causar starvation. Se cada callback registrar outro nextTick, timers e I/O podem ficar sem oportunidade de executar.
function repeatForever() {
process.nextTick(repeatForever);
}
repeatForever();Esse padrão deve ser evitado.
setImmediate() e setTimeout(0)
setImmediate() executa na fase check, enquanto um timeout de zero passa pela fase timers:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));A ordem pode variar quando o código é executado no escopo principal. Dentro de um callback de I/O, setImmediate() normalmente é processado antes do próximo ciclo de timers. Use cada API por sua finalidade, não para forçar uma corrida de ordem.
O papel da libuv
A libuv fornece a implementação multiplataforma do event loop, comunicação com o sistema operacional e um pool de threads. Nem toda operação assíncrona utiliza o pool. Sockets de rede normalmente dependem de mecanismos eficientes do sistema operacional, enquanto algumas operações de arquivos, criptografia e DNS podem usar threads auxiliares.
O tamanho padrão do pool é limitado. Muitas operações pesadas que dependem dele podem criar fila mesmo quando a thread JavaScript está livre.
Thread pool e UV_THREADPOOL_SIZE
A variável UV_THREADPOOL_SIZE pode ajustar a quantidade de threads para operações compatíveis:
UV_THREADPOOL_SIZE=8 node server.jsAumentar o valor não garante melhoria. Mais threads consomem memória, disputam CPU e podem apenas transferir o gargalo. Meça com uma carga representativa antes de alterar.
Operações que bloqueiam
Alguns exemplos comuns:
- loops grandes e transformações intensivas em JavaScript;
- expressões regulares vulneráveis a backtracking excessivo;
- serialização de objetos enormes;
- métodos síncronos de arquivo, criptografia ou compressão;
- parsing de payloads sem limite;
- callbacks que processam milhares de itens de uma vez.
Evite métodos com sufixo Sync durante o atendimento de requisições. Eles podem ser aceitáveis na inicialização ou em scripts curtos, desde que o bloqueio seja consciente.
Dividindo um trabalho grande
Uma tarefa pode ser dividida em pequenos lotes:
function processItems(items, index = 0) {
const end = Math.min(index + 100, items.length);
for (let current = index; current < end; current += 1) {
transform(items[current]);
}
if (end < items.length) {
setImmediate(() => processItems(items, end));
}
}Esse padrão permite que I/O e outros callbacks progridam entre os lotes. Para cálculo realmente intensivo, considere Worker Threads no Node.js.
Monitorando o event loop
O módulo node:perf_hooks oferece recursos para medir atrasos:
const { monitorEventLoopDelay } = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
console.log({
meanMs: histogram.mean / 1e6,
maxMs: histogram.max / 1e6
});
histogram.reset();
}, 10000);Monitore percentis e picos, não apenas a média. Um pequeno grupo de atrasos extremos pode prejudicar requisições sensíveis.
Event loop utilization
A utilização indica quanto tempo o loop permaneceu ativo em relação ao período observado. Valores altos durante muito tempo podem revelar saturação da thread principal. Compare essa métrica com CPU, latência, throughput, memória e duração das operações externas.
Backpressure
Mesmo um callback rápido pode causar problemas se dados chegarem mais depressa do que são processados. Streams oferecem backpressure para limitar a produção. O artigo sobre Streams no Node.js explica como respeitar write(), drain e pipeline().
APIs e limites
Em servidores HTTP, limite corpo de requisição, complexidade de filtros, quantidade de itens e tempo de processamento. Um atacante não precisa explorar uma falha sofisticada se puder enviar uma entrada que monopoliza a thread.
Para operações lentas, use timeout e cancelamento real com AbortSignal. Rejeitar uma Promise sem parar o trabalho não libera necessariamente o recurso.
Como testar bloqueios
Execute testes de carga enquanto coleta event loop lag, utilização, CPU e latência. Inclua payloads grandes, conexões simultâneas e cenários de falha. Um teste com apenas uma requisição não revela contenção.
Também crie testes unitários para funções de transformação e benchmarks separados para caminhos críticos. Isso facilita identificar quando uma alteração aumenta o custo síncrono.
Erros comuns
- Achar que Node.js nunca bloqueia: JavaScript síncrono ocupa a thread principal.
- Usar nextTick em recursão: I/O e timers podem sofrer starvation.
- Confiar na ordem de timeout e immediate: o contexto influencia.
- Aumentar o thread pool sem medir: a disputa por CPU pode piorar.
- Processar payload ilimitado: parsing e serialização bloqueiam.
- Observar apenas CPU média: atrasos curtos podem causar alta latência.
- Usar métodos Sync em endpoints: todas as conexões aguardam.
Boas práticas para produção
- Mantenha callbacks pequenos e previsíveis.
- Evite operações síncronas no caminho de requisições.
- Divida lotes grandes ou utilize workers.
- Limite payloads e complexidade.
- Monitore atraso e utilização do event loop.
- Respeite backpressure.
- Use timeouts com cancelamento real.
- Faça testes de carga representativos.
- Observe o pool de threads e operações externas.
- Registre duração dos caminhos críticos.
Conclusão
O Event Loop no Node.js coordena callbacks e permite que uma única thread JavaScript gerencie muitas operações assíncronas. Esse modelo funciona muito bem quando o código síncrono permanece curto e o trabalho pesado é delegado corretamente.
Ao entender fases, microtarefas, nextTick, thread pool e bloqueios, você consegue diagnosticar latência com mais precisão. Monitoramento, limites e divisão de trabalho transformam o event loop de um conceito abstrato em uma ferramenta prática para construir serviços estáveis.




