O Event Loop, Worker Threads e Concorrência no Node.js
Event loop do Node.js, pool de threads libuv, worker threads e cluster explicados com guia claro para concorrência de I/O e CPU.
O Node.js executa seu JavaScript em uma única thread controlada pelo event loop; a concorrência vem de terceirizar a espera — o libuv delega I/O bloqueante a um pool de threads em segundo plano — e não de executar seu JS em paralelo. Quando você precisa executar JavaScript em paralelo para trabalho CPU-bound, esse é um mecanismo separado: worker threads, cada uma sendo um isolate V8 completo com seu próprio event loop. Esses três elementos — a thread JS única, o pool de threads do libuv e os worker threads — são constantemente confundidos, e essa confusão é a origem de endpoints lentos e servidores travados.
Este artigo separa as camadas com precisão. Ele explica as fases do event loop e a divisão entre microtasks e macrotasks, o que o pool de threads do libuv realmente serve (e o que não serve), quando worker threads superam o simples async/await, como o clustering difere, e uma regra de decisão para escolher entre eles. O código foi escrito para o Node.js 24 (Active LTS) com o Node.js 26 como linha Current; worker threads são estáveis, não experimentais.
Principais Conclusões
- O Node executa seu JavaScript em uma única thread; o event loop alcança concorrência delegando I/O bloqueante ao pool de threads em segundo plano do libuv, e não executando seu código em paralelo.
- O pool de threads do libuv tem padrão de 4 threads e pode ser aumentado para um máximo de 1024 via
UV_THREADPOOL_SIZE; ele serve operações de sistema de arquivos,dns.lookup, crypto e zlib — mas não sockets de rede, que usam diretamente o epoll/kqueue/IOCP do SO. - Um worker thread não é “apenas uma thread do SO” — cada um é um isolate V8 separado com seu próprio event loop e loop libuv, razão pela qual workers não podem compartilhar objetos comuns e precisam se comunicar por troca de mensagens.
- Microtasks não são uma fase do event loop: os callbacks de
process.nextTicksão drenados primeiro, depois a fila de microtasks de promises, e somente então o loop avança para a próxima macrotask. - Escolha pelo gargalo:
async/awaitpara trabalho I/O-bound, worker threads para JavaScript CPU-bound, e cluster para escalar carga I/O-bound entre núcleos.
O modelo central: uma thread JS, um event loop e o libuv
O Node.js executa seu JavaScript em uma única thread, e essa thread executa o event loop. O runtime é construído sobre o motor V8 do Google para executar JavaScript e sobre o libuv, uma biblioteca C que fornece o event loop e I/O assíncrono. O truque que permite que uma única thread gerencie milhares de conexões simultâneas é a delegação: quando seu código chama uma operação bloqueante como a leitura de um arquivo, o Node não fica esperando. Ele registra a operação com o libuv, retorna imediatamente, e seu callback é executado mais tarde quando o resultado estiver pronto.
Concorrência no Node é terceirizar a espera. Enquanto uma leitura de arquivo ou uma consulta DNS está pendente, a única thread JS está livre para executar outros callbacks. Nada no seu JavaScript é executado em paralelo — existe exatamente uma call stack — mas muitas operações podem estar em andamento ao mesmo tempo porque as partes lentas acontecem em outro lugar.
É por isso que “Node é single-threaded” é uma meia-verdade que vale a pena corrigir. A execução do JavaScript é single-threaded. O runtime não é: o libuv mantém um pool de threads em segundo plano, e o sistema operacional gerencia os sockets de rede em nome do Node. Trate “single-threaded” como uma afirmação sobre onde seu código é executado, não sobre o processo como um todo.
Mito comum: “Node é single-threaded.” Seu JavaScript é executado em uma thread; o processo Node usa várias. Essa distinção é o ponto central deste artigo.
Concorrência vs. paralelismo, definidos com precisão
Concorrência significa que múltiplas tarefas progridem no mesmo período intercalando-se em um recurso compartilhado; paralelismo significa que múltiplas tarefas são executadas no mesmo instante em núcleos separados. Uma máquina single-core executando Node é concorrente, mas não paralela: o event loop alterna rapidamente entre operações em andamento, mas apenas um trecho de JavaScript é executado em qualquer momento. Worker threads e clustering adicionam paralelismo genuíno ao introduzir contextos de execução adicionais que o SO pode escalonar em diferentes núcleos.
A consequência prática: concorrência resolve problemas de espera (I/O), paralelismo resolve problemas de computação (CPU). Recorrer à opção errada é a causa raiz da maioria dos erros de desempenho no Node.
As seis fases do event loop e a divisão de microtasks
Discover how at OpenReplay.com.
O event loop é executado em um ciclo fixo de seis fases, e cada fase tem sua própria fila de callbacks que é completamente drenada antes de avançar para a próxima. De acordo com o guia oficial do event loop do Node.js, as fases em ordem são:
- Timers — executa callbacks agendados por
setTimeout()esetInterval()cujo limite de tempo já expirou. - Pending callbacks — executa certos callbacks de nível de sistema adiados de um ciclo anterior.
- Idle, prepare — uso interno apenas.
- Poll — recupera novos eventos de I/O e executa seus callbacks; o loop bloqueará aqui aguardando I/O se não houver mais nada a fazer.
- Check — executa callbacks de
setImmediate(). - Close callbacks — executa handlers de fechamento como
socket.on('close', ...).
Observe a terceira fase: muitos tutoriais listam apenas cinco e omitem idle/prepare, que existe mas é reservada para uso interno do libuv. É real; você simplesmente nunca agenda tarefas nela diretamente.
Microtasks não são uma fase
Microtasks não são uma fase do event loop. Os callbacks de process.nextTick são drenados primeiro, depois a fila de microtasks de promises é drenada, e somente então o loop avança para a próxima macrotask — portanto, process.nextTick tem prioridade sobre Promise.then, que tem prioridade sobre setTimeout. O guia do Node é explícito ao afirmar que process.nextTick tecnicamente não faz parte do event loop; sua fila é processada após a conclusão da operação atual, independentemente da fase atual, e a fila de promises a segue — ambas antes do loop avançar.
Isso estabelece uma prioridade clara em três níveis: process.nextTick → microtasks de promises → macrotasks (timers, I/O, setImmediate).
Uma demonstração concreta de ordenação
A confusão clássica é setImmediate vs. setTimeout(0). Dentro de um callback de I/O, a ordem é determinística; no nível superior, não é.
// Executar no Node.js 24.16.0
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('1: setTimeout(0)'), 0);
setImmediate(() => console.log('2: setImmediate'));
Promise.resolve().then(() => console.log('3: promise'));
process.nextTick(() => console.log('4: nextTick'));
});
Imprime:
4: nextTick
3: promise
2: setImmediate
1: setTimeout(0)
nextTick e a promise são drenados antes do loop avançar, com nextTick primeiro. Em seguida, como os callbacks foram agendados de dentro de um ciclo de I/O (a fase poll), o loop atinge a fase check a seguir, então setImmediate dispara antes do loop voltar à fase timers. O guia do Node confirma que setImmediate() sempre é executado antes de um timer quando ambos são agendados dentro de um ciclo de I/O. Agende os mesmos dois no nível superior, e a ordem é não-determinística — não dependa disso fora de um callback de I/O.
O pool de threads do libuv: padrão 4, máximo 1024 — e o que realmente o utiliza
O pool de threads do libuv é um conjunto fixo de threads em segundo plano — 4 por padrão, expansível até um máximo de 1024 — que o libuv usa para executar operações que não possuem primitiva de SO não-bloqueante. De acordo com a documentação do pool de threads do libuv, esse máximo é definido via a variável de ambiente UV_THREADPOOL_SIZE. (O limite máximo subiu de 128 para 1024 no libuv 1.30.0 — artigos mais antigos que citam “128” estão desatualizados.) O pool é compartilhado entre todos os event loops em um processo.
O que é executado nele é uma lista específica e finita. A documentação da CLI do Node.js para UV_THREADPOOL_SIZE nomeia os consumidores: as APIs fs (exceto watchers e as variantes explicitamente síncronas), dns.lookup(), e as operações assíncronas de crypto e zlib como crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.generateKeyPair(), e compressão zlib.
O ponto mais importante aqui é o que não o utiliza. I/O de rede não toca o pool de threads. Como explica o guia oficial Don’t Block the Event Loop, os sockets de rede são gerenciados pelo mecanismo de polling do sistema operacional — epoll no Linux, kqueue no macOS/BSD, IOCP no Windows — e surgem diretamente na fase poll. Há também uma sutileza com DNS: dns.lookup() (que chama getaddrinfo) usa o pool, mas a família dns.resolve*() (que usa c-ares) não usa, portanto a afirmação genérica de que “DNS usa o pool” está incorreta.
# Definir antes de o Node iniciar — o pool é pré-alocado no primeiro uso.
UV_THREADPOOL_SIZE=8 node server.js
Um ponto operacional importante: o libuv pré-aloca o número máximo de threads no primeiro uso do pool, portanto UV_THREADPOOL_SIZE deve ser definido antes desse momento — na prática, antes de o Node iniciar. Modificar process.env.UV_THREADPOOL_SIZE após o pool ter sido utilizado não tem efeito.
Mito comum: “Aumentar
UV_THREADPOOL_SIZEacelera o trabalho de CPU.” AumentarUV_THREADPOOL_SIZEacelera I/O concorrente, nunca JavaScript CPU-bound — para trabalho de CPU você precisa de um worker thread, porque o pool não executa seu JavaScript. Ele executa operações de nível C do libuv, não suas funções JS.
Worker threads: paralelismo real para JavaScript CPU-bound
Worker threads executam JavaScript em paralelo em threads separadas por uma razão — trabalho CPU-bound que de outra forma bloquearia a única thread JS — e eles não ajudam com trabalho I/O-bound, que o I/O assíncrono nativo do Node já gerencia de forma mais eficiente. A documentação de worker_threads afirma que workers são úteis para realizar operações JavaScript CPU-intensivas e não ajudam muito com trabalho I/O-intensivo, porque o I/O assíncrono nativo do Node é mais eficiente do que workers conseguem ser.
O ponto de precisão que a maioria dos artigos perde: um worker thread não é “apenas uma thread do SO.” Cada um é um isolate V8 separado com seu próprio event loop e seu próprio loop libuv. Esse isolamento é exatamente o motivo pelo qual workers não podem compartilhar objetos JavaScript comuns e por que tudo que você envia via postMessage é copiado.
Mito comum: “Workers são apenas threads do SO.” Cada worker é um isolate V8 completo mais seu próprio event loop e loop libuv sendo executados em uma thread — é por isso que não há globais compartilhados nem escopo de closure compartilhado entre a thread principal e o worker.
Isolamento e troca de mensagens
Workers se comunicam por troca de mensagens, e o payload é copiado profundamente. Os dados que você passa por workerData ou postMessage() são clonados de acordo com o algoritmo de clone estruturado HTML — funções, protótipos de classes e referências vivas não sobrevivem à transferência. A única forma de escapar da cópia é a memória compartilhada: worker threads podem compartilhar memória apenas através de um SharedArrayBuffer (ou transferindo um ArrayBuffer, que move a propriedade em vez de copiar). Todo o restante é clonado de forma estruturada.
// main.js — Executar no Node.js 24.16.0
const { Worker } = require('node:worker_threads');
const worker = new Worker('./fib-worker.js', { workerData: { n: 42 } });
worker.on('message', (result) => console.log('fib(42) =', result));
worker.on('error', (err) => console.error(err));
// fib-worker.js
const { parentPort, workerData } = require('node:worker_threads');
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
parentPort.postMessage(fib(workerData.n));
isMainThread permite que um único arquivo se ramifique entre os dois papéis quando você prefere não dividir os arquivos. Para a superfície completa da API — MessageChannel, MessagePort, listas de transferência, receiveMessageOnPort — consulte a documentação de worker_threads. Comparado com threads em C++ ou Java, o modelo troca memória compartilhada por padrão (e os locks, mutexes e condições de corrida que vêm com isso) por isolamento por padrão: mais seguro, com cópia como custo.
Use um pool, não um worker por tarefa
Criar um Worker para cada requisição é ineficiente. A documentação do Node é explícita ao afirmar que você deve usar um pool de workers na prática, porque o overhead de criar workers provavelmente superaria seus benefícios caso contrário. O pool padrão da comunidade é o piscina. O Piscina é uma implementação rápida e eficiente de pool de worker threads para Node.js; sua versão mais recente é a 5.2.0. Escreva 5.x no seu range do package.json e fixe o release específico no CI.
Há um corolário que pega muita gente: não mova trabalho já-assíncrono para um worker. Crypto assíncrono, fs e zlib já estão sendo executados nas threads em segundo plano do libuv, portanto envolvê-los em um worker apenas agenda uma thread para agendar outra thread, sem nenhum ganho. Workers valem a pena apenas para JavaScript síncrono e CPU-bound.
Exemplo prático: uma rota CPU-bound que bloqueia todos
Uma tarefa de CPU síncrona na thread principal congela o event loop para todas as requisições em andamento, não apenas a que a disparou. Aqui está o cenário de falha em um handler do Express:
// server-blocking.js — Executar no Node.js 24.16.0, express 5.x
const express = require('express');
const app = express();
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
app.get('/report', (req, res) => {
res.json({ value: fib(45) }); // bloqueia o event loop por segundos
});
app.get('/health', (req, res) => res.send('ok'));
app.listen(3000);
Enquanto fib(45) é executado, /health não retorna nada — a única thread está ocupada computando, e toda requisição concorrente fica na fila atrás dela. A solução é descarregar o trabalho de CPU para um pool e usar await no resultado, liberando o event loop para continuar atendendo:
// server-pooled.js — Executar no Node.js 24.16.0, express 5.x, piscina 5.2.0
const express = require('express');
const Piscina = require('piscina');
const path = require('node:path');
const pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });
const app = express();
app.get('/report', async (req, res) => {
const value = await pool.run({ n: 45 }); // executado em um worker; o loop permanece livre
res.json({ value });
});
app.get('/health', (req, res) => res.send('ok'));
app.listen(3000);
// fib-task.js
module.exports = ({ n }) => {
const fib = (x) => (x < 2 ? x : fib(x - 1) + fib(x - 2));
return fib(n);
};
Agora fib(45) é executado em um worker, o event loop permanece responsivo, e /health responde imediatamente enquanto a rota pesada computa em segundo plano.
Um event loop bloqueado tem uma assinatura de produção característica: como uma tarefa de CPU síncrona congela a única thread JS para todas as requisições em andamento, ela aparece como muitos usuários simultâneos travando no mesmo instante — não como a rede ruim de um único usuário. Esse padrão de congelamento correlacionado é exatamente o que a reprodução de sessão evidencia em sessões simultâneas, e você pode confirmá-lo em tempo de execução com monitorEventLoopDelay de node:perf_hooks, onde um p99 elevado indica que o loop (ou o pool de threads) está saturado.
Clustering e múltiplos processos: escalando I/O entre núcleos
O clustering escala uma aplicação Node entre núcleos de CPU ao fazer fork de múltiplos processos, cada um executando a aplicação completa com seu próprio event loop e memória, compartilhando um socket de escuta. O módulo cluster é a forma nativa de fazer isso; é a ferramenta certa quando seu gargalo é throughput I/O-bound e você quer usar todos os núcleos da máquina em vez de apenas um.
A distinção em relação aos worker threads é importante. Workers de cluster são processos separados com memória totalmente isolada que se comunicam via IPC; worker threads são threads separadas dentro de um processo que podem compartilhar memória via SharedArrayBuffer. Cluster é sobre lidar com mais requisições concorrentes entre núcleos; worker threads são sobre tirar JavaScript CPU-bound da thread de requisição. Na prática, um serviço de alto throughput frequentemente usa ambos: cluster (ou um gerenciador de processos / réplicas de container) para abranger núcleos, e um pool de workers dentro de cada processo para absorver picos ocasionais de CPU.
Um guia de decisão: async, pool, worker ou cluster
Escolha pelo seu gargalo: async/await para trabalho I/O-bound, worker threads para JavaScript CPU-bound, cluster (ou múltiplos processos) para escalar carga I/O-bound entre núcleos, e um pool de workers como o piscina quando você pagaria o custo de inicialização de um worker a cada requisição.
| Ferramenta | Executa JS em paralelo? | Compartilha memória? | Melhor para | Custo principal |
|---|---|---|---|---|
async/await + event loop | Não | N/A (uma thread) | Trabalho I/O-bound (rede, BD, arquivos) | Bloqueia se você fizer trabalho de CPU |
| Pool de threads libuv | Não (executa C, não seu JS) | N/A | fs/dns.lookup/crypto/zlib concorrentes | Tamanho fixo; não para seu JS |
| Worker threads (+ pool) | Sim | Apenas via SharedArrayBuffer | JavaScript CPU-bound | Inicialização + cópia por clone estruturado |
| Cluster / múltiplos processos | Sim | Não (apenas IPC) | Escalar carga I/O-bound entre núcleos | Overhead de processo; sem estado compartilhado |
Regras rápidas:
- I/O-bound e ainda não paralelizado? Use
async/await. O pool de threads e o SO já fornecem concorrência gratuitamente. - Uma rota faz computação síncrona pesada? Delegue para um worker thread, atrás de um pool para reutilização.
- Saturando um núcleo sob tráfego concorrente? Faça cluster entre núcleos (ou execute múltiplas réplicas de container).
- Tentado a aumentar
UV_THREADPOOL_SIZEpara ganhar velocidade? Apenas se você estiver com gargalo de I/O em operações respaldadas pelo pool (muitosfs/crypto concorrentes). Isso nunca vai acelerar JavaScript CPU-bound.
Uma observação prospectiva para o código que você escreve hoje: a partir do Node.js 27 em outubro de 2026, o projeto migra para um major release por ano, encerrando a cadência par/ímpar. Acompanhe o calendário de releases do Node.js e desenvolva contra o LTS atual.
O modelo mental que mantém tudo isso claro é parar de perguntar “o Node é single-threaded?” e começar a perguntar “qual é o meu gargalo?” Esperar é o trabalho do event loop; computar em paralelo é do pool de workers; distribuir carga entre núcleos é do cluster. Faça o profile do endpoint lento, identifique se ele está preso esperando ou preso computando, e a ferramenta certa segue diretamente — então verifique a correção com monitorEventLoopDelay antes de fazer o deploy.
Perguntas Frequentes
Qual é a diferença entre worker threads e o módulo cluster no Node.js?
Worker threads são threads separadas dentro de um processo, cada uma sendo um isolate V8 com seu próprio event loop, que podem compartilhar memória através de um SharedArrayBuffer; o cluster faz fork de processos separados com memória totalmente isolada que se comunicam via IPC e compartilham um socket de escuta. Use worker threads para mover JavaScript CPU-bound para fora da thread de requisição, e cluster para escalar carga I/O-bound entre núcleos de CPU. Serviços de alto throughput frequentemente combinam ambos.
Por que aumentar UV_THREADPOOL_SIZE não torna meu código CPU-bound mais rápido?
O pool de threads do libuv executa operações de nível C como chamadas ao sistema de arquivos, dns.lookup, crypto assíncrono e zlib, não seu JavaScript. Aumentar UV_THREADPOOL_SIZE apenas aumenta quantas dessas operações de estilo I/O são executadas concorrentemente; isso nunca acelera JavaScript CPU-bound porque esse código ainda é executado na única thread JS. Para trabalho de CPU você precisa de um worker thread, que executa JavaScript em paralelo em seu próprio isolate V8.
O Node.js gerencia requisições de rede no pool de threads do libuv?
Não. Os sockets de rede são gerenciados pelo mecanismo de polling do sistema operacional, epoll no Linux, kqueue no macOS e BSD, e IOCP no Windows, e surgem diretamente na fase poll do event loop. O pool de threads do libuv serve operações de sistema de arquivos, dns.lookup via getaddrinfo, crypto assíncrono e zlib, não I/O de rede. Observe que as funções dns.resolve usam a biblioteca c-ares e também ignoram o pool, portanto 'DNS usa o pool' só é verdadeiro para dns.lookup.
Worker threads podem compartilhar objetos JavaScript com a thread principal?
Não. Workers se comunicam por troca de mensagens, e os dados passados por workerData ou postMessage são copiados profundamente usando o algoritmo de clone estruturado HTML, portanto funções, protótipos de classes e referências vivas não sobrevivem à transferência. A única forma de compartilhar memória é um SharedArrayBuffer, ou transferindo um ArrayBuffer, que move a propriedade em vez de copiar. Esse isolamento por padrão evita os locks e condições de corrida do threading com memória compartilhada em linguagens como C++ ou Java.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k