Event loop do Node: uma thread e mil requisições
Call stack, as seis fases do loop, microtasks e por que um for pesado trava a API inteira — com uma API Express medindo o bloqueio em milissegundos.
O Node roda o seu JavaScript numa thread só. Ele atende mil requisições ao mesmo tempo porque quase tudo numa API é espera — rede, disco, banco — e essa espera acontece fora do seu código. O event loop é o mecanismo que decide qual função sua entra em execução quando cada espera dessas termina.
Os exemplos aqui são a API da VidaPet, uma clínica veterinária: ficha de atendimento, cadastro de tutores e um relatório de retorno que, escrito do jeito errado, derruba a clínica inteira por dois segundos e meio. Todos os números saíram de execução real no Node 24.16.0, num MacBook com Apple M4 Pro.
Em palavras simples, uma coisa roda por vez no seu JavaScript, enquanto várias esperas podem avançar fora dele. O event loop não executa tudo junto; ele coordena quando cada callback pronto pode voltar para a thread.
A única mesa de atendimento da clínica
Imagine a recepção da VidaPet com uma única mesa. A recepcionista registra uma ficha, envia o pedido de exame ao laboratório e, em vez de ficar parada olhando o resultado, atende a próxima pessoa. Quando o laboratório avisa que terminou, a ficha volta para a fila da mesa. A recepcionista é a thread JavaScript; o laboratório representa as operações delegadas; o event loop coordena o retorno.
Antes de rodar o primeiro exemplo, numere no papel a ordem em que cada
console.log deve aparecer. Depois compare com a saída real e justifique cada
linha: ela estava na pilha, aguardando fora do JavaScript ou pronta em uma fila
do loop?
Um processo Node tem três lugares onde trabalho acontece, e só o primeiro é seu.
A thread principal executa o seu JavaScript: o corpo dos módulos, os handlers de rota, os callbacks. Uma de cada vez, sem exceção.
A thread pool da libuv — quatro threads por padrão — cuida de um punhado de
operações que o sistema operacional não sabe fazer de forma assíncrona: hash de
senha, compressão, algumas operações de arquivo, dns.lookup.
O kernel cuida de rede. Socket, HTTP, conexão de banco: nada disso ocupa thread nenhuma enquanto espera resposta. O processo só é avisado quando os bytes chegam.
O event loop é o funcionário que fica indo e voltando entre esses três lugares. Ele pergunta ao sistema operacional o que ficou pronto, pega os callbacks correspondentes e chama um por um — sempre na thread principal. Se você quiser entender o que mais mora dentro do processo além do loop, vale ler primeiro o que é o Node.js.
Repare na consequência: enquanto um callback seu estiver executando, o funcionário está parado esperando você terminar. Ele não tem como interromper.
A pilha de chamadas, e o instante em que ela precisa esvaziar
A call stack (pilha de chamadas) é a lista do que está em execução agora.
Cada chamada empilha um quadro; cada return desempilha. Você consegue ver a
pilha inteira provocando um erro:
function precoDoProcedimento(item) {
return item.valor * item.quantidade;
}
function totalDoAtendimento(atendimento) {
return atendimento.itens.reduce((soma, item) => soma + precoDoProcedimento(item), 0);
}
function emitirRecibo(atendimento) {
console.log(`${atendimento.pet}: R$ ${totalDoAtendimento(atendimento).toFixed(2)}`);
}
emitirRecibo({ pet: 'Amora', itens: [{ valor: 180, quantidade: 1 }] });
emitirRecibo({ pet: 'Tobias' });TypeError: Cannot read properties of undefined (reading ‘reduce’) at totalDoAtendimento (file:///private/tmp/vidapet/pilha.mjs:6:28) at emitirRecibo (file:///private/tmp/vidapet/pilha.mjs:10:41) at file:///private/tmp/vidapet/pilha.mjs:14:1 at ModuleJob.run (node:internal/modules/esm/module_job:439:25) at async node:internal/modules/esm/loader:633:26 at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:101:5)
Node.js v24.16.0
O stack trace é a pilha impressa de baixo para cima: o módulo chamou
emitirRecibo, que chamou totalDoAtendimento, que estourou. Três quadros seus
— os de baixo, com node:internal, são a maquinaria que carregou o módulo.
O detalhe que importa para este artigo é outro: o event loop só volta a girar quando a pilha chega a zero. Não existe “pausar no meio”. Enquanto houver qualquer quadro empilhado, nenhum timer dispara, nenhuma resposta de banco é processada, nenhuma requisição nova é aceita. Esse é o mesmo mecanismo que congela a página no navegador, descrito em JavaScript assíncrono.
As seis fases de uma volta do loop
Quando a pilha esvazia, a libuv começa uma volta. Cada volta atravessa seis fases, sempre na mesma ordem, e cada fase tem a própria fila de callbacks.
Três dessas fases você quase nunca vai encostar. As que aparecem no dia a dia são timers, poll e check:
- timers roda os
setTimeoutesetIntervalcujo prazo já venceu; - poll é onde a API passa a vida: é a fase que pergunta ao sistema operacional o que chegou de rede e de disco, executa esses callbacks e, se não há mais nada para fazer, dorme ali até chegar alguma coisa;
- check roda o que você agendou com
setImmediate.
Entre uma fase e a seguinte, o Node esvazia duas filas extras: a de
process.nextTick e a de microtasks (as continuações de Promise). E não é só na
virada de fase — desde o Node 11 essas duas filas são drenadas depois de cada
callback: um nextTick agendado dentro do primeiro setTimeout roda antes do
segundo setTimeout, ainda dentro da fase de timers. É por isso que uma Promise
resolvida nunca espera a próxima volta.
Microtask fura a fila: a Promise sai antes do setTimeout
O caso mais simples já mostra a hierarquia. Este arquivo agenda um timer de zero milissegundo e uma Promise já resolvida:
console.log('sincrono: abre a ficha da Amora');
setTimeout(() => console.log('setTimeout(0): fase timers'), 0);
Promise.resolve().then(() => console.log('Promise.then: microtask'));
console.log('sincrono: fecha a ficha');Primeiro sai todo o código síncrono — a pilha precisa esvaziar. Depois a
microtask. Só então o loop entra na fase de timers. O 0 do setTimeout nunca
foi promessa de zero milissegundo: é “o mais cedo possível a partir da próxima
volta”, como detalha a lição de
setTimeout e setInterval.
A fila de microtasks tem uma característica perigosa: ela é esvaziada até o
fim, e uma microtask que agenda outra microtask entra na mesma rodada. O mesmo
vale para process.nextTick. Dá para deixar os timers passando fome:
const inicio = performance.now();
setTimeout(() => {
console.log(`setTimeout(0) so disparou ${(performance.now() - inicio).toFixed(0)} ms depois`);
}, 0);
// uma fila de nextTick que se realimenta 2 milhões de vezes
let restantes = 2000000;
function proximaFicha() {
if (--restantes > 0) process.nextTick(proximaFicha);
}
proximaFicha();Setenta e um milissegundos de atraso sem nenhum laço bloqueante à vista. O loop
não chegou nem à primeira fase: ficou preso drenando a fila de nextTick.
A corrida completa, e a ordem que muda entre ESM e CommonJS
Agora as quatro formas de adiar disputando a mesma linha de largada:
setTimeout(() => console.log('setTimeout(0)'), 0);
setImmediate(() => console.log('setImmediate'));
Promise.resolve().then(() => console.log('Promise.then'));
process.nextTick(() => console.log('process.nextTick'));
console.log('sincrono');Salvo como corrida.mjs — módulo ES, o padrão de projeto novo — a ordem foi
esta em 40 execuções seguidas:
O mesmo arquivo, byte por byte, salvo como corrida.cjs — CommonJS — inverte as
duas do meio, também em todas as execuções:
Esse detalhe contraria o que quase toda explicação de event loop afirma
(“nextTick sempre vem antes das Promises”). A regra vale dentro de uma volta
do loop; ela não vale para o corpo do módulo. Em ESM, a avaliação do módulo já
acontece dentro de um job de Promise, e o checkpoint de microtasks daquele job
roda antes de o Node visitar a fila de nextTick.
setTimeout(0) ou setImmediate: quem ganha depende de onde você está
No topo do módulo, setTimeout(0) chegou antes de setImmediate nas 40
execuções acima — mas isso é sorte do relógio, não garantia. Se a preparação do
módulo levar menos de um milissegundo, o timer ainda não venceu quando o loop
passa pela fase de timers, e o setImmediate ganha.
Dentro de um callback de I/O, porém, a ordem é determinística. O callback do
readFile roda na fase de poll, e a próxima fase é check — o agenda.json
aqui é um arquivo qualquer da pasta, o callback nem olha o conteúdo dele:
import { readFile } from 'node:fs';
readFile('agenda.json', 'utf8', () => {
setTimeout(() => console.log('setTimeout(0)'), 0);
setImmediate(() => console.log('setImmediate'));
});Vinte execuções, vinte vezes o mesmo resultado. Regra de bolso: dentro de um
callback de I/O, setImmediate é sempre mais cedo que setTimeout(0) — e é
por isso que ele é a ferramenta certa para “continue isso na próxima volta”.
O que a libuv manda para a thread pool, e o que ela não manda
A thread pool tem quatro threads por padrão. Dá para ver a fila acontecendo: oito tutores criando conta ao mesmo tempo, oito hashes de senha disparados de uma vez.
import { pbkdf2 } from 'node:crypto';
const inicio = performance.now();
// 8 tutores criando conta ao mesmo tempo: 8 hashes de senha de uma vez
for (let i = 1; i <= 8; i++) {
pbkdf2(`senha-tutor-${i}`, 'sal-da-vidapet', 300000, 64, 'sha512', () => {
console.log(`tutor ${i} pronto em ${(performance.now() - inicio).toFixed(0)} ms`);
});
}Duas ondas de quatro. Os tutores 5 a 8 esperaram uma thread vagar. Subindo o
mesmo arquivo com UV_THREADPOOL_SIZE=8, as ondas viram uma só:
UV_THREADPOOL_SIZE=8 node threadpool.mjsAgora a parte que quase ninguém testa: rede não passa pela thread pool. Este arquivo sobe um laboratório falso que demora 300 ms para responder e dispara oito consultas ao mesmo tempo, com a pool reduzida a uma thread:
import { createServer } from 'node:http';
const lab = createServer((req, res) => {
setTimeout(() => res.end('{"lote":"ok"}'), 300);
});
lab.listen(4186, async () => {
const inicio = performance.now();
await Promise.all(
[1, 2, 3, 4, 5, 6, 7, 8].map(async (n) => {
await fetch('http://localhost:4186/lote');
console.log(`consulta ${n} respondeu em ${(performance.now() - inicio).toFixed(0)} ms`);
}),
);
lab.close();
});UV_THREADPOOL_SIZE=1 node rede.mjsOito consultas, uma thread na pool, 317 ms para todas. Nenhuma esperou a outra,
porque nenhuma usou a pool: quem esperou foi o kernel. O único pedaço de uma
chamada de rede que ainda passa pela pool é o dns.lookup que traduz o nome do
host em IP — e ele acontece uma vez, antes da conexão, não durante a espera.
É exatamente por isso que o Node aguenta milhares de conexões abertas — e por
que aumentar UV_THREADPOOL_SIZE não faz nada por uma API que só conversa com
banco. Onde esse ajuste importa mesmo é em rota de login com
hash de senha, que é trabalho de pool.
Travando a clínica de propósito: dois segundos e meio de for
Agora o dano. Esta é a API da VidaPet com duas rotas: um /ping de healthcheck
e um relatório que cruza os 50 mil atendimentos do ano para contar retornos por
tutor — com um laço dentro do outro, do jeito que sai quando ninguém pensou no
volume.
import express from 'express';
const app = express();
// 50.000 atendimentos do ano, já carregados na memória
const atendimentos = [];
for (let i = 0; i < 50000; i++) {
atendimentos.push({ tutorId: i % 1200, valor: 120 + (i % 90) });
}
app.get('/ping', (req, res) => {
res.json({ clinica: 'VidaPet', ok: true });
});
app.get('/relatorio/retorno', (req, res) => {
let recorrencias = 0;
for (let i = 0; i < atendimentos.length; i++) {
for (let j = 0; j < atendimentos.length; j++) {
if (i !== j && atendimentos[i].tutorId === atendimentos[j].tutorId) recorrencias++;
}
}
res.json({ atendimentos: atendimentos.length, recorrencias });
});
app.listen(4177, () => console.log('VidaPet no ar na porta 4177'));Com o servidor no ar, três medições no terminal: um /ping com a API ociosa, o
relatório em segundo plano, e outro /ping 200 ms depois de o relatório começar.
curl -s -o /dev/null -w "ping ocioso: %{time_total}s\n" http://localhost:4177/ping
curl -s -o /dev/null -w "relatorio: %{time_total}s\n" http://localhost:4177/relatorio/retorno &
sleep 0.2
curl -s -o /dev/null -w "ping bloqueado: %{time_total}s\n" http://localhost:4177/pingO healthcheck saiu de 1,8 ms para 2.273 ms. Ele não ficou lento: ele ficou parado na fila. A requisição chegou ao kernel, o socket foi aceito, e aí morreu esperando a thread principal terminar o laço. É o mesmo destino de todas as outras requisições que chegassem nesses dois segundos e meio.
Vale gravar isto: toda função que você escreve dentro de uma rota do Express roda na mesma thread de todas as outras rotas. Não existe “essa requisição aqui é lenta, mas é problema dela”.
Três consertos para o mesmo relatório, medidos lado a lado
Existem três saídas, e elas não são intercambiáveis.
1. Consertar o algoritmo. É a primeira coisa a olhar, e a mais barata. O laço
dentro do laço faz 2,5 bilhões de comparações para responder uma pergunta que um
Map responde em uma passada:
const t = performance.now();
const porTutor = new Map();
for (const a of atendimentos) {
porTutor.set(a.tutorId, (porTutor.get(a.tutorId) ?? 0) + 1);
}
let recorrencias = 0;
for (const quantas of porTutor.values()) {
recorrencias += quantas * (quantas - 1);
}
console.log(`recorrencias: ${recorrencias}`);
console.log(`tempo: ${(performance.now() - t).toFixed(1)} ms`);Mesma resposta — 2.033.600 —, 4,4 ms em vez de 2.490 ms. Se o seu bloqueio tem essa cara, pare aqui: não é caso de arquitetura.
2. Fatiar com setImmediate. Quando o trabalho é grande de verdade e não dá
para reduzir, divida em pedaços e devolva o controle ao loop entre eles:
app.get('/relatorio/retorno', (req, res) => {
let i = 0;
let recorrencias = 0;
function fatia() {
const fim = Math.min(i + 200, atendimentos.length);
for (; i < fim; i++) {
for (let j = 0; j < atendimentos.length; j++) {
if (i !== j && atendimentos[i].tutorId === atendimentos[j].tutorId) recorrencias++;
}
}
if (i < atendimentos.length) setImmediate(fatia);
else res.json({ atendimentos: atendimentos.length, recorrencias });
}
fatia();
});3. Mandar para outra thread. worker_threads tira o cálculo da thread
principal de vez:
import { Worker } from 'node:worker_threads';
app.get('/relatorio/retorno', (req, res) => {
const worker = new Worker(new URL('./relatorio-worker.js', import.meta.url), {
workerData: atendimentos,
});
worker.on('message', (resultado) => res.json(resultado));
worker.on('error', (erro) => res.status(500).json({ erro: erro.message }));
});As três versões subiram em portas diferentes e passaram pela mesma bateria de
curl, uma atrás da outra:
| versão do relatório | /ping ocioso |
/ping durante o relatório |
relatório |
|---|---|---|---|
| laço direto na rota | 1,8 ms | 2.273 ms | 2,49 s |
em fatias com setImmediate |
1,3 ms | 14,7 ms | 2,53 s |
em worker_threads |
1,4 ms | 0,6 ms | 3,14 s |
A leitura é direta. Fatiar derrubou o bloqueio de 2.273 ms para 14,7 ms — o
tamanho de uma fatia — sem custo nenhum no total. O worker zerou o impacto no
/ping, e cobrou por isso: o relatório ficou 26% mais lento, porque subir a
thread e copiar os 50 mil atendimentos para ela custa tempo.
Medindo o lag do loop antes do cliente reclamar
Você não precisa esperar reclamação para saber que o loop está travando. O Node
tem um medidor embutido, monitorEventLoopDelay, que cronometra o atraso entre
o instante em que um trabalho deveria rodar e o instante em que rodou:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const relogio = monitorEventLoopDelay({ resolution: 10 });
relogio.enable();
setInterval(() => {
const media = (relogio.mean / 1e6).toFixed(1);
const pior = (relogio.max / 1e6).toFixed(1);
console.log(`lag do loop: media ${media} ms | pior ${pior} ms`);
relogio.reset();
}, 1000);
// dois segundos e meio depois, o laço aninhado do relatório entra em cena
setTimeout(() => {
console.log('--- relatorio rodando no loop principal ---');
let recorrencias = 0;
for (let i = 0; i < atendimentos.length; i++) {
for (let j = 0; j < atendimentos.length; j++) {
if (i !== j && atendimentos[i].tutorId === atendimentos[j].tutorId) recorrencias++;
}
}
}, 2500);Com o medidor ligado e o relatório entrando em cena no meio do caminho, o antes e o depois aparecem numa lista só:
Duas observações sobre como ler esses números. A média em repouso fica perto da
resolution que você escolheu (aqui, 10 ms) — o medidor amostra nesse intervalo,
então 11 ms parado é o normal, não um problema. O número que denuncia é o
max: passou de 15 ms para 2.443 ms no segundo em que o laço rodou.
Na prática, exponha relogio.max numa rota de métricas e alarme quando ele
passar de algumas centenas de milissegundos. É um sinal muito mais honesto que
uso de CPU: um processo Node com o loop travado aparece como “CPU alta e
saudável” em qualquer painel, enquanto derruba o healthcheck.
O que fazer com isso amanhã
Abra a sua API e procure três coisas: laço dentro de laço sobre lista que cresce,
JSON.parse de payload grande em rota quente e qualquer função *Sync de
node:fs ou node:crypto dentro de um handler. Essas três respondem pela
maioria dos travamentos reais.
Depois ligue o medidor de lag em desenvolvimento e navegue pela aplicação
olhando o max. Ele vai apontar o handler culpado em minutos — e, na maioria dos
casos, o conserto vai ser um Map, não uma thread nova. Para seguir montando a
API por cima dessa base, o
guia de Node.js mostra a ordem do resto do caminho.
Perguntas frequentes
O Node é single-thread ou multi-thread?
Toda rota lenta precisa de worker_threads?
Colocar async e await numa função pesada resolve o travamento?
Aumentar UV_THREADPOOL_SIZE deixa a API mais rápida?
Rodar em cluster ou com PM2 resolve o bloqueio?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 (Express 5.2.1, macOS 27 em Apple M4 Pro), e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- Node.js — The Node.js Event Loop — nodejs.org
- Node.js — Don't Block the Event Loop — nodejs.org
- libuv — Design overview — docs.libuv.org
- Node.js — perf_hooks.monitorEventLoopDelay — nodejs.org


