search

Event Loop no Node.js: fases, microtasks e como o runtime processa I/O

Event Loop no Node.js: fases, microtasks e como o runtime processa I/O

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:

  1. Executa o código JavaScript (sincrono) na thread principal.
  2. Coordena a execução de callbacks associados a eventos assíncronos (timers, sockets, file system, etc.).
  3. 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 fs e 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:

  1. Timers
  2. Pending Callbacks
  3. Idle, Prepare (internas)
  4. Poll
  5. Check
  6. 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á setImmediate agendado, 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:

  • setImmediate executando antes de setTimeout(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 nextTick pode “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:

  1. Executa o código síncrono atual.
  2. Ao terminar o trecho atual, drena:
    • process.nextTick()
    • microtasks (Promises / queueMicrotask)
  3. Avança no event loop por fases (timers → … → poll → check → close), executando callbacks.
  4. Ao fim de cada callback, volta ao passo 2 (drain de nextTick e 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

Processamento de IO

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 setImmediate ou filas internas
  • Usar Worker Threads para CPU-bound
  • Usar bibliotecas nativas otimizadas quando apropriado

Passo a passo: como investigar comportamento e ordem no seu código

  1. Reproduza com um script mínimo
    Remova frameworks e deixe apenas timers, Promises e I/O que você suspeita.

  2. Marque logs com origem clara
    Identifique qual log vem de timer, microtask, nextTick, callback de I/O.

  3. Observe diferenças entre setTimeout(0) e setImmediate
    Especialmente dentro de callbacks de I/O.

  4. Meça atraso do event loop
    Em produção, use métricas como event loop delay (APM/telemetria) para enxergar bloqueios.

  5. Verifique uso do thread pool
    Se o gargalo for fs/crypto, considere ajustar UV_THREADPOOL_SIZE e, 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.

Compartilhar este artigo: