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/setIntervalque 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.logsíncronas executam imediatamente (1,5). setTimeoutcoloca o callback na fila de timers — executado na próxima fase.process.nextTickePromise.thensão ambos microtasks, masnextTicktem prioridade mais alta.- Depois que o código síncrono termina, o Node.js esvazia a fila
nextTick, depois esvazia a fila de microtasksPromise, 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, chamandonextTickdentro 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.nextTickrecursivo: 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
setTimeoutvssetImmediateno 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
monitorEventLoopDelayregularmente 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.