Event Loop no Node.js: o DJ que nunca para de tocar
Node.js é single-threaded. Ou seja, ele só faz uma coisa por vez. Mesmo assim, aguenta milhares de conexões simultâneas sem suar. Como?
A resposta é o Event Loop, e entender como ele funciona muda o jeito como você pensa em código assíncrono.
Para explicar, vou usar a analogia de um DJ numa festa.
A mesa do DJ (Call Stack)
Imagine um DJ numa festa. A mesa do DJ (a Call Stack) só consegue tocar uma música por vez. Não importa quantos pedidos cheguem: o DJ atende um por um, em ordem.
Em termos de JavaScript, essa é a call stack, uma estrutura LIFO (Last In, First Out) onde funções são empilhadas quando chamadas e removidas quando retornam.
function receberConvidado(nome) {
console.log(`Bem-vindo, ${nome}!`);
}
function abrirFesta() {
receberConvidado("Alice");
receberConvidado("Bob");
}
abrirFesta();
// Saída:
// Bem-vindo, Alice!
// Bem-vindo, Bob!
Cada chamada de função é empilhada, executada e removida antes de a próxima rodar. É simples, sequencial e previsível.
Os roadies (internals do Node / libuv)
O DJ não pode sair da mesa para configurar a máquina de fumaça. Esse é o trabalho dos roadies.
No Node.js, os roadies são os internals do Node: a libuv e a camada assíncrona do sistema operacional, o equivalente ao que os navegadores chamam de Web APIs. É por ali que passam setTimeout, fetch, leitura de arquivos e requisições de rede. Quando você chama uma dessas funções, o Node entrega a tarefa para o ambiente e segue em frente na hora. O DJ continua tocando enquanto os roadies fazem o trabalho deles.
console.log("A festa começa");
setTimeout(() => {
console.log("Máquina de fumaça pronta!");
}, 2000);
console.log("O DJ já está tocando");
// Saída:
// A festa começa
// O DJ já está tocando
// Máquina de fumaça pronta! (2 segundos depois)
O Node não ficou parado esperando 2 segundos. Passou a tarefa para o roadie e seguiu em frente. Isso é I/O não bloqueante.
A fila de pedidos (Macrotask Queue)
Quando um roadie termina o trabalho, o resultado não vai direto para a mesa do DJ. Ele vai para a fila de pedidos, a Macrotask Queue.
O Event Loop (a disciplina do DJ) tem uma regra: só pegar o próximo pedido quando a mesa estiver completamente livre.
console.log("1 - DJ abre o set");
setTimeout(() => console.log("3 - Primeiro pedido da fila"), 0);
setTimeout(() => console.log("4 - Segundo pedido da fila"), 0);
console.log("2 - DJ termina a abertura");
// Saída:
// 1 - DJ abre o set
// 2 - DJ termina a abertura
// 3 - Primeiro pedido da fila
// 4 - Segundo pedido da fila
Mesmo com setTimeout(..., 0), os callbacks passam pela fila e só rodam depois que a call stack atual esvazia.
Exemplos de macrotasks: setTimeout, setInterval, callbacks de I/O, setImmediate (Node.js).
A fila VIP (Microtask Queue)
Alguns convidados da festa são VIPs. Eles têm uma fila separada, e o DJ segue uma política rígida: depois de cada música (macrotask), ele esvazia toda a fila VIP antes de tocar a próxima música da fila normal. A fila VIP também é atendida assim que a call stack inicial esvazia, antes mesmo de tocar a primeira música da fila normal.
A Microtask Queue funciona assim: ela é esvaziada por completo depois de cada macrotask, antes de a próxima macrotask começar.
console.log("1 - Início");
setTimeout(() => console.log("5 - Macrotask (setTimeout)"), 0);
Promise.resolve()
.then(() => console.log("3 - Microtask 1 (Promise)"))
.then(() => console.log("4 - Microtask 2 (Promise encadeada)"));
console.log("2 - Fim do código síncrono");
// Saída:
// 1 - Início
// 2 - Fim do código síncrono
// 3 - Microtask 1 (Promise)
// 4 - Microtask 2 (Promise encadeada)
// 5 - Macrotask (setTimeout)
Os callbacks das Promises (microtasks) sempre rodam antes do callback do setTimeout (macrotask), mesmo que o setTimeout tenha sido registrado primeiro.
Exemplos de microtasks: Promise.then, Promise.catch, Promise.finally, queueMicrotask, continuações de async/await.
O set completo
Juntando tudo num exemplo só:
console.log("1 - Síncrono: a festa começa");
setTimeout(() => console.log("6 - Macrotask: trabalho do roadie concluído"), 0);
Promise.resolve()
.then(() => {
console.log("4 - Microtask: convidado VIP chega");
})
.then(() => console.log("5 - Microtask: VIP recebe sua bebida"));
setTimeout(() => console.log("7 - Macrotask: segundo roadie concluído"), 0);
console.log("2 - Síncrono: DJ continua tocando");
console.log("3 - Síncrono: call stack esvazia");
// Saída:
// 1 - Síncrono: a festa começa
// 2 - Síncrono: DJ continua tocando
// 3 - Síncrono: call stack esvazia
// 4 - Microtask: convidado VIP chega
// 5 - Microtask: VIP recebe sua bebida
// 6 - Macrotask: trabalho do roadie concluído
// 7 - Macrotask: segundo roadie concluído
Ordem de execução:
- Todo o código síncrono (call stack esvazia completamente)
- Todas as microtasks (a fila VIP é esvaziada por completo)
- Uma macrotask (fila normal)
- Todas as microtasks de novo (a fila VIP é esvaziada outra vez)
- Próxima macrotask... e assim por diante
3 regras de ouro
- Uma coisa por vez. A call stack roda em uma única thread, sem paralelismo. Ela dá conta de muitas tarefas porque delega, e não porque faz tudo ao mesmo tempo.
- Microtasks sempre vêm antes das macrotasks. Depois de cada macrotask, a fila de microtasks inteira é esvaziada primeiro.
- Operações assíncronas não bloqueiam. Elas são delegadas ao ambiente (os roadies), e a call stack fica livre para continuar trabalhando.
Essas três regras explicam 90% do comportamento "estranho" que você vai encontrar em JavaScript assíncrono no Node.js.