Visão geral: por que o event loop nodejs importa
O Node.js é amplamente usado para servidores, APIs e aplicações que lidam com muitas conexões simultâneas. O motivo é sua arquitetura orientada a eventos: em vez de criar uma thread por requisição, o runtime executa JavaScript em uma única thread (no contexto do event loop) e delega operações de entrada e saída (I/O) para o sistema operacional e para uma biblioteca de baixo nível chamada libuv.
Para escrever sistemas performáticos e previsíveis, é essencial entender:
- O que é o event loop e quais são suas fases
- Como timers, I/O, callbacks, microtasks e promises se encaixam na ordem de execução
- O que acontece quando você “bloqueia” a thread com trabalho CPU-bound
- Por que certos códigos “parecem” executar fora de ordem
Este texto foca no comportamento prático do event loop nodejs, com exemplos e um guia mental para depuração e otimização.
O que é o Event Loop no Node.js (em termos práticos)
O event loop é um mecanismo que:
- Executa o código JavaScript (sincrono) na thread principal.
- Coordena a execução de callbacks associados a eventos assíncronos (timers, sockets, file system, etc.).
- Interage com a libuv, que integra filas de eventos, polling de I/O e um pool de threads para certas operações.
Pontos importantes:
- JavaScript roda em uma thread por processo (ignorando Worker Threads e Cluster).
- I/O é assíncrono: o Node inicia a operação e recebe um callback/resultado quando ela termina.
- A libuv usa:
- Mecanismos do SO (epoll, kqueue, IOCP, etc.) para muitos tipos de I/O.
- Um thread pool (por padrão 4 threads) para tarefas específicas, como várias operações de
fse algumas operações criptográficas.
As fases do Event Loop (modelo de referência)
O event loop do Node é geralmente descrito em fases. Cada iteração completa é um “tick” do loop. As fases principais (na ordem) são:
- Timers
- Pending Callbacks
- Idle, Prepare (internas)
- Poll
- Check
- Close Callbacks
Além disso, existem filas que rodam “entre” momentos importantes:
- Microtasks (Promises e
queueMicrotask) process.nextTick()(fila especial do Node, com prioridade ainda maior)
A seguir, o que cada fase faz.
1) Timers: setTimeout e setInterval
Nesta fase, o Node executa callbacks de timers cujo tempo expirou.
Observações relevantes:
setTimeout(fn, 0)não significa “execute imediatamente”; significa “execute assim que possível após pelo menos 0 ms”, respeitando o loop e o estado do poll.- Timers são verificados com base no relógio e em estruturas internas; há nuances de precisão e atraso (timer drift), especialmente sob carga.
Exemplo simples:
setTimeout(() => console.log("timer"), 0);
console.log("sync");
Saída típica:
sync
timer
2) Pending Callbacks: callbacks “adiados” de algumas operações
Aqui rodam callbacks de certos tipos de operações que foram postergados para esta fase (ex.: alguns erros de I/O). É uma fase menos citada no dia a dia, mas existe e explica por que alguns callbacks não entram diretamente no poll.
3) Idle/Prepare: uso interno
Fases internas da libuv, geralmente irrelevantes para a maioria das aplicações. Servem para preparar o loop antes de entrar em poll.
4) Poll: o coração do processamento de I/O
A fase poll é onde o Node:
- Verifica eventos de I/O prontos (rede, sockets, algumas operações do sistema, etc.)
- Executa callbacks relacionados a esses eventos
- Pode bloquear esperando novos eventos (por um tempo controlado), se não houver nada imediato para fazer
Regras práticas:
- Se houver callbacks de I/O na fila, o Node tende a processá-los.
- Se não houver I/O pronto:
- Se há timers vencendo “logo”, o poll pode retornar mais cedo para permitir a fase de timers.
- Se há
setImmediateagendado, o loop pode avançar para check. - Caso contrário, ele pode aguardar por I/O (economia de CPU).
5) Check: setImmediate
A fase check executa callbacks agendados por setImmediate.
Diferença prática entre setTimeout(fn, 0) e setImmediate(fn):
setImmediateé projetado para rodar após o poll, na fase check.setTimeout(0)depende da fase timers e de tempo mínimo transcorrido.
Em I/O callbacks, é comum observar:
setImmediateexecutando antes desetTimeout(0)quando agendados dentro de um callback de I/O, porque o loop tende a ir de poll para check.
Exemplo típico (comportamento comum, ainda que detalhes possam variar):
const fs = require("node:fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});
Frequentemente imprime:
immediate
timeout
6) Close Callbacks: fechamento de recursos
Aqui entram callbacks do tipo “close”, por exemplo quando um socket é fechado e o evento de encerramento precisa ser entregue.
Microtasks: Promises e queueMicrotask
Além das fases acima, existe a fila de microtasks, que inclui:
- Reações de Promises (
.then,.catch,.finally) queueMicrotask(...)
A regra mais útil:
- Microtasks rodam antes de voltar ao event loop para avançar para a próxima fase.
- Em termos práticos: ao final de um trecho de execução de JavaScript (um callback, um timer, um I/O callback), o Node drena a fila de microtasks.
Exemplo:
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
Saída:
sync
promise
process.nextTick(): prioridade ainda maior (e risco de starvation)
O Node mantém uma fila especial para process.nextTick(), que costuma rodar antes das microtasks de Promise, e antes de retornar ao loop.
Isso tem implicações:
- Pode ser útil para ajustes imediatos após uma operação atual.
- Pode ser perigoso: encadear muitos
nextTickpode “fomear” (starve) o event loop, atrasando timers e I/O.
Exemplo ilustrativo:
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("sync");
Saída típica:
sync
nextTick
promise
Ordem de execução: um mapa mental confiável
Uma forma prática de pensar a ordem:
- Executa o código síncrono atual.
- Ao terminar o trecho atual, drena:
process.nextTick()- microtasks (Promises /
queueMicrotask)
- Avança no event loop por fases (timers → … → poll → check → close), executando callbacks.
- Ao fim de cada callback, volta ao passo 2 (drain de
nextTicke microtasks).
Esse “drain após cada callback” explica por que Promises podem intercalar de forma surpreendente entre timers e I/O.
Como o Node.js processa I/O: libuv, SO e thread pool
Quando você chama algo como fs.readFile, o que acontece não é “magia JavaScript”: o Node delega para a libuv, que escolhe o melhor mecanismo disponível.
I/O de rede (sockets)
Em geral:
- Usa mecanismos do sistema operacional (epoll/kqueue/IOCP).
- Escala bem sem precisar de uma thread por conexão.
- Callbacks de eventos de rede costumam aparecer como trabalho a ser processado no loop, frequentemente na fase poll.
I/O de filesystem e tarefas “bloqueantes”
Para muitas operações de fs (especialmente em sistemas onde certas chamadas são bloqueantes), a libuv utiliza um thread pool:
- Padrão: 4 threads
- Ajustável via
UV_THREADPOOL_SIZE(com limites e impacto de memória/CPU)
Isso significa:
- O JavaScript não bloqueia esperando o disco.
- Mas se você disparar muitas operações que usam o pool, você pode criar fila e aumentar latência.
Exemplo de sintoma: picos de latência ao fazer muitas leituras de arquivo + operações de crypto que também usam o thread pool.
O maior inimigo: bloquear a thread com CPU-bound
Mesmo que I/O seja assíncrono, o callback que processa o resultado roda na thread do event loop. Se você faz trabalho pesado (parse gigantesco, compressão pura em JS, loops intensos), você:
- Atrasa timers
- Atrasa resposta de requisições
- Aumenta tempo de event loop “ocupado”
Sinais típicos:
- “Timeouts” em endpoints sob carga
- “Lag” em WebSocket
- Métricas de event loop delay elevadas
Mitigações comuns:
- Quebrar tarefas longas em fatias (yield) usando
setImmediateou filas internas - Usar
Worker Threadspara CPU-bound - Usar bibliotecas nativas otimizadas quando apropriado
Passo a passo: como investigar comportamento e ordem no seu código
-
Reproduza com um script mínimo
Remova frameworks e deixe apenas timers, Promises e I/O que você suspeita. -
Marque logs com origem clara
Identifique qual log vem de timer, microtask, nextTick, callback de I/O. -
Observe diferenças entre
setTimeout(0)esetImmediate
Especialmente dentro de callbacks de I/O. -
Meça atraso do event loop
Em produção, use métricas como event loop delay (APM/telemetria) para enxergar bloqueios. -
Verifique uso do thread pool
Se o gargalo forfs/crypto, considere ajustarUV_THREADPOOL_SIZEe, principalmente, reduzir concorrência ou mover CPU-bound para workers.
Conclusão
Entender o event loop nodejs passa por dominar três camadas:
- Fases do event loop (timers, poll, check, etc.)
- Filas de alta prioridade (microtasks e
process.nextTick) - Modelo de I/O do runtime (SO + libuv + thread pool)
Com esse mapa, fica mais fácil prever ordem de execução, explicar “comportamentos estranhos” em produção e tomar decisões de arquitetura (por exemplo, quando usar setImmediate, quando evitar nextTick, quando mover carga para workers e quando o gargalo está no thread pool).
Se você quiser, posso gerar um conjunto de exemplos comparando setTimeout(0), setImmediate, Promises e nextTick em diferentes contextos (top-level, dentro de I/O e dentro de timers), com uma tabela de ordem esperada e observada por versão do Node.