Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
ConceitoIntermediáriocódigo testado

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.

Rodolfo Mori13 min de leitura

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:

js
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' });
Amora: R$ 180.00 file:///private/tmp/vidapet/pilha.mjs:6 return atendimento.itens.reduce((soma, item) => soma + precoDoProcedimento(item), 0); ^

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.

a pilha esvaziou uma volta do event loop timers setTimeout, setInterval pending callbacks erros de I/O adiados idle, prepare uso interno do Node poll espera e roda o I/O que chegou check setImmediate close callbacks socket fechado esvazia nextTick e microtasks esvazia nextTick e microtasks esvazia nextTick e microtasks esvazia nextTick e microtasks esvazia nextTick e microtasks próxima volta

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 setTimeout e setInterval cujo 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:

js
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');
sincrono: abre a ficha da Amora sincrono: fecha a ficha Promise.then: microtask setTimeout(0): fase timers

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:

js
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();
setTimeout(0) so disparou 71 ms depois

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:

js
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:

sincrono Promise.then process.nextTick setTimeout(0) setImmediate

O mesmo arquivo, byte por byte, salvo como corrida.cjs — CommonJS — inverte as duas do meio, também em todas as execuções:

sincrono process.nextTick Promise.then setTimeout(0) setImmediate

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:

js
import { readFile } from 'node:fs';

readFile('agenda.json', 'utf8', () => {
  setTimeout(() => console.log('setTimeout(0)'), 0);
  setImmediate(() => console.log('setImmediate'));
});
setImmediate setTimeout(0)

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.

js
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`);
  });
}
tutor 4 pronto em 59 ms tutor 2 pronto em 60 ms tutor 1 pronto em 60 ms tutor 3 pronto em 60 ms tutor 5 pronto em 120 ms tutor 7 pronto em 122 ms tutor 6 pronto em 123 ms tutor 8 pronto em 124 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ó:

bash
UV_THREADPOOL_SIZE=8 node threadpool.mjs
tutor 7 pronto em 58 ms tutor 3 pronto em 58 ms tutor 6 pronto em 58 ms tutor 8 pronto em 58 ms tutor 2 pronto em 60 ms tutor 1 pronto em 60 ms tutor 5 pronto em 67 ms tutor 4 pronto em 67 ms

Agora 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:

js
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();
});
bash
UV_THREADPOOL_SIZE=1 node rede.mjs
consulta 1 respondeu em 317 ms consulta 2 respondeu em 317 ms consulta 3 respondeu em 317 ms consulta 4 respondeu em 317 ms consulta 5 respondeu em 317 ms consulta 6 respondeu em 317 ms consulta 7 respondeu em 318 ms consulta 8 respondeu em 318 ms

Oito 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.

js
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.

bash
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/ping
ping ocioso: 0.001779s relatorio: 2.485768s ping bloqueado: 2.273310s

O 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:

js
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`);
recorrencias: 2033600 tempo: 4.4 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:

js
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:

js
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:

js
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ó:

lag do loop: media 11.8 ms | pior 15.1 ms lag do loop: media 11.7 ms | pior 13.3 ms --- relatorio rodando no loop principal --- lag do loop: media 66.6 ms | pior 2443.2 ms lag do loop: media 11.6 ms | pior 13.4 ms

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.

  • node
  • event loop
  • assincrono
  • libuv
  • performance

Perguntas frequentes

O Node é single-thread ou multi-thread?
Os dois, em camadas diferentes. O seu JavaScript roda numa thread só. O processo, porém, sobe com uma thread pool da libuv (4 por padrão) e usa o kernel para rede. Quando alguém diz "Node é single-thread", está falando do seu código, não do processo inteiro.
Toda rota lenta precisa de worker_threads?
Não. Rota lenta por espera — banco, API externa, disco — já sai do loop sozinha e não atrapalha ninguém. Worker só entra quando a lentidão é cálculo em JavaScript, ocupando a thread principal. Antes disso, olhe o algoritmo, que costuma ser o culpado mais barato de consertar.
Colocar async e await numa função pesada resolve o travamento?
Não. async e await organizam a espera, não criam paralelismo. Um laço de dois segundos dentro de uma função async continua rodando dois segundos seguidos na thread principal, porque não existe ponto nenhum onde ele devolva o controle ao loop.
Aumentar UV_THREADPOOL_SIZE deixa a API mais rápida?
Só ajuda se o gargalo for operação de thread pool (hash de senha, compressão, leitura pesada de disco) e a máquina tiver núcleos sobrando. Para rota que só espera rede ou banco, aumentar o pool não muda nada — essas operações nem passam por ele.
Rodar em cluster ou com PM2 resolve o bloqueio?
Ameniza. Com quatro processos, um relatório travado derruba um quarto da capacidade em vez da API inteira, e o balanceador ainda entrega as outras requisições. Mas o processo travado continua travado, e sob carga os quatro travam juntos. Cluster é escala horizontal, não conserto de código bloqueante.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

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

  1. Node.js — The Node.js Event Loop — nodejs.org
  2. Node.js — Don't Block the Event Loop — nodejs.org
  3. libuv — Design overview — docs.libuv.org
  4. Node.js — perf_hooks.monitorEventLoopDelay — nodejs.org

Continue por aqui