Event Loop no Node.js: Mecanismo de funcionamento e exemplos

1. Visão geral

Event Loop é o mecanismo central que permite que o Node.js processe milhares de requisições simultâneas usando apenas uma única thread para JavaScript. Muitos desenvolvedores escrevem código com async/await, setTimeout e Promise diariamente, mas não sabem exatamente em que ordem eles são executados.

Este artigo explica a estrutura do Event Loop através de fases específicas, diferencia microtasks de macrotasks e ilustra com código executável. Depois de ler, você será capaz de prever com precisão a ordem de log de trechos de código assíncrono complexos e evitar erros comuns como bloquear o event loop.

2. Como o Node.js funciona com single-thread

O Node.js executa JavaScript em uma única thread principal (main thread), mas as operações de I/O (leitura de arquivo, chamadas de rede, consultas de banco de dados) são delegadas ao libuv — uma biblioteca C que trata I/O assíncrono através de um pool de threads ou mecanismos do kernel (epoll, kqueue…).

┌───────────────────────────┐
│   JavaScript (single thread) │
└──────────────┬────────────┘
               │ chama fs.readFile, setTimeout, net...
               ▼
┌───────────────────────────┐
│     libuv (C/C++)          │
│  - Pool de threads         │
│  - Event loop              │
│  - APIs async do SO        │
└───────────────────────────┘

Quando uma operação de I/O é concluída, libuv coloca o callback na fila correspondente. O Event Loop verifica continuamente essas filas e executa o callback quando chega sua vez — é assim que funciona o “loop”.

Nota: Seu código JavaScript sempre executa em uma única thread. Se você escrever código síncrono pesado (por exemplo, um loop com cálculos intensivos), ele vai bloquear todo o event loop, fazendo com que todas as outras requisições tenham que esperar.

3. As fases do Event Loop

O Event Loop não executa um loop simples, mas passa por 6 fases em uma ordem fixa a cada iteração (tick):

   ┌───────────────────────┐
┌─>│        timers          │  setTimeout, setInterval
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │ callbacks pendentes    │  callback de I/O atrasado
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │    inativo, preparação │
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │       sondagem         │  eventos de I/O
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
│  │      verificação       │  setImmediate
│  └──────────┬─────────────┘
│  ┌──────────▼─────────────┐
└──┤ callbacks de fechamento│  socket.on('close')
   └─────────────────────────┘

3.1. Descrição breve de cada fase

  • timers: executa callbacks de setTimeout/setInterval que já expiraram.
  • callbacks pendentes: executa callbacks de I/O que foram adiados do ciclo anterior.
  • sondagem: aguarda e processa novos eventos de I/O (arquivo lido, resposta de rede recebida…). Esta é a fase que consome mais tempo.
  • verificação: executa callbacks de setImmediate.
  • callbacks de fechamento: processa eventos de fechamento, como socket.on('close', ...).

Cada fase tem sua própria fila (queue). O Event Loop processa todos os callbacks (ou até um limite) na fila da fase atual antes de passar para a próxima fase.

4. Fila de microtasks: Promise e process.nextTick

Além das 6 fases de macrotasks acima, o Node.js também possui uma fila de microtasks, que é processada entre cada fase — não é necessário esperar o loop completo.

Existem dois tipos de microtask:

  • process.nextTick() — fila separada, sempre processada antes de qualquer outro microtask.
  • Promise.then/catch/finally — fila de microtask padrão (conforme a especificação ECMAScript).
console.log('1 - inicio sincro');

setTimeout(() => console.log('2 - setTimeout'), 0);

Promise.resolve().then(() => console.log('3 - promise'));

process.nextTick(() => console.log('4 - nextTick'));

console.log('5 - fim sincro');

Explicação:

  • As linhas de console.log síncronas executam imediatamente (1, 5).
  • setTimeout coloca o callback na fila de timers — executado na próxima fase.
  • process.nextTick e Promise.then são ambos microtasks, mas nextTick tem prioridade mais alta.
  • Depois que o código síncrono termina, o Node.js esvazia a fila nextTick, depois esvazia a fila de microtasks Promise, antes de entrar na fase de timers.
1 - inicio sincro
5 - fim sincro
4 - nextTick
3 - promise
2 - setTimeout

Nota: Se você chamar process.nextTick() recursivamente (por exemplo, chamando nextTick dentro de outro callback), o event loop nunca passará para a próxima fase — isso se chama “I/O starvation”.

5. setTimeout vs setImmediate: qual é a ordem correta?

Esta é a pergunta mais confusa. A ordem entre setTimeout(fn, 0) e setImmediate(fn) não é fixa se chamados no nível superior, mas é fixa se chamados dentro de um callback de I/O.

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});

Como este código está dentro do callback de fs.readFile — ou seja, na fase sondagem — quando a sondagem termina, o Event Loop sempre passa para a fase verificação (setImmediate) antes do novo ciclo retornar à fase timers.

immediate
timeout

Por outro lado, se você colocar esses dois comandos no nível superior (fora de qualquer callback de I/O), a ordem depende do desempenho do processo de inicialização do Node.js naquele momento — não confie nesta ordem em código de produção.

6. Bloquear o event loop: o erro mais comum

Como há apenas uma thread processando JavaScript, qualquer código síncrono que leve muito tempo vai bloquear todo o servidor.

const express = require('express');
const app = express();

app.get('/fibonacci', (req, res) => {
  function fib(n) {
    return n <= 1 ? n : fib(n - 1) + fib(n - 2);
  }
  res.send(`Resultado: ${fib(40)}`); // bloqueando!
});

app.listen(3000);

Quando uma requisição chama /fibonacci, a função fib(40) executa de forma síncrona, gastando vários segundos. Nesse tempo, todas as outras requisições (mesmo um ping simples) têm que esperar, porque o event loop está travado em uma tarefa.

6.1. Solução: delegar tarefas pesadas para Worker Thread

const { Worker } = require('worker_threads');

app.get('/fibonacci', (req, res) => {
  const worker = new Worker('./fib-worker.js', {
    workerData: { n: 40 }
  });

  worker.on('message', (result) => res.send(`Resultado: ${result}`));
  worker.on('error', (err) => res.status(500).send(err.message));
});
// fib-worker.js
const { workerData, parentPort } = require('worker_threads');

function fib(n) {
  return n <= 1 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));

Worker executa em uma thread separada, não ocupando a thread principal. O event loop da requisição principal permanece livre para processar outras requisições enquanto o worker realiza os cálculos.

Quando NÃO usar Worker Thread: para operações de I/O (leitura de DB, chamadas de API, leitura de arquivo) — estas já são assíncronas, não precisam de worker. Worker Thread é útil apenas para tarefas CPU-bound (cálculos intensivos, criptografia, processamento de imagens…).

7. Medindo o atraso do event loop com perf_hooks

Para detectar o event loop bloqueado em ambiente de produção, você pode usar o módulo perf_hooks integrado.

const { monitorEventLoopDelay } = require('perf_hooks');

const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

setInterval(() => {
  console.log(`Atraso do event loop (média): ${histogram.mean / 1e6} ms`);
  histogram.reset();
}, 5000);

monitorEventLoopDelay cria um histograma que mede o tempo que o event loop fica “atrasado” em relação ao cronograma ideal. Se o valor de mean aumentar anormalmente (por exemplo, acima de 100ms), é um sinal de que há código bloqueando em algum lugar.

8. Escrevendo testes para verificar a ordem de execução

Para confirmar que você entendeu corretamente a ordem de microtask/macrotask, você pode escrever testes que registram logs em um array e depois afirmam a ordem.

// order.js
function recordOrder(log) {
  log.push('sync-start');

  setTimeout(() => log.push('timeout'), 0);

  Promise.resolve().then(() => log.push('promise'));

  process.nextTick(() => log.push('next-tick'));

  log.push('sync-end');
}

module.exports = { recordOrder };
// order.test.js
const { recordOrder } = require('./order');

test('ordem de execução: sync -> nextTick -> promise -> timeout', (done) => {
  const log = [];
  recordOrder(log);

  // código síncrono termina imediatamente
  expect(log).toEqual(['sync-start', 'sync-end']);

  setImmediate(() => {
    expect(log).toEqual([
      'sync-start',
      'sync-end',
      'next-tick',
      'promise',
      'timeout',
    ]);
    done();
  });
});

Este teste usa Jest com setImmediate para esperar até que todos os microtasks e a fase de timers terminem, depois afirma a ordem final do log. Esta é a forma certa de validar o entendimento sobre event loop em vez de apenas ler logs com os olhos.

npx jest order.test.js
PASS  ./order.test.js
  ✓ ordem de execução: sync -> nextTick -> promise -> timeout (5 ms)

9. Erros comuns

  • Abuso de process.nextTick recursivo: causa I/O starvation, servidor não consegue processar novas requisições.
  • Imaginar que setTimeout(fn, 0) executa imediatamente: na verdade, ele sempre aguarda pelo menos um tick do event loop e espera após todos os microtasks.
  • Executar código CPU-bound na thread principal: por exemplo, fazer parsing de JSON gigante, criptografia bcrypt com custo alto, processamento de imagem síncrono — tudo isso deve ser delegado para Worker Thread ou processo filho.
  • Assumir a ordem de setTimeout vs setImmediate no nível superior: não é confiável, evite escrever lógica que dependa disso.

Quando ser cuidadoso: se sua aplicação tem endpoints que processam dados grandes (exportar Excel, cálculos estatísticos, redimensionamento de imagem), meça monitorEventLoopDelay regularmente para detectar problemas cedo antes de afetar todo o servidor.

10. Conclusão

O Event Loop é a razão pela qual o Node.js consegue lidar com um grande número de conexões com baixos recursos, mas vem com uma limitação: todo código JavaScript síncrono executa em uma única thread. Entender bem as 6 fases (timers, callbacks pendentes, sondagem, verificação, callbacks de fechamento) e o posicionamento da fila de microtasks (process.nextTick, Promise) ajuda você a prever corretamente a ordem de execução e evitar erros difíceis de debugar.

Quando encontrar tarefas CPU-bound, não hesite em usar worker_threads ou delegar para um processo separado — o event loop só é poderoso quando você não o sufoca.

nodejsevent-loopjavascriptasynchronousbackend