Event Loop, Microtasks e Macrotasks: O Modelo de Execução do JavaScript
Neste artigo
O quiz que separa quem entende de quem decora
Cole este trecho no console do navegador ou no Node.js e tente prever a ordem de saída antes de executar:
console.log("1: script start");
setTimeout(() => {
console.log("2: setTimeout");
}, 0);
Promise.resolve()
.then(() => {
console.log("3: promise then");
})
.then(() => {
console.log("4: promise then chained");
});
queueMicrotask(() => {
console.log("5: queueMicrotask");
});
console.log("6: script end");A saída é:
1: script start
6: script end
3: promise then
5: queueMicrotask
4: promise then chained
2: setTimeoutSe você errou a posição do setTimeout em relação às Promises, o problema não é falta de prática: é que a maioria dos materiais mistura três conceitos distintos (call stack, microtask queue e task queue) como se fossem a mesma coisa.
O que é o event loop (sem analogia de garçom)
O event loop é um algoritmo de scheduling implementado pelo runtime, não pela linguagem. A especificação ECMAScript define a semântica de Jobs (microtasks), mas quem implementa o loop de fato é o runtime: o V8 delega para o embedder (Chromium usa libevent, Node.js usa libuv).
O ciclo simplificado funciona assim:
- Executa todo o código síncrono da call stack até esvaziar.
- Drena a microtask queue inteira (Promises,
queueMicrotask,MutationObserverno browser). - Pega UMA tarefa da macrotask queue (também chamada de task queue):
setTimeout,setInterval, I/O callbacks,setImmediate(Node.js). - Volta ao passo 2.
O ponto que gera confusão: microtasks são drenadas completamente entre cada macrotask. Isso significa que uma microtask que agenda outra microtask adia indefinidamente a próxima macrotask.
Microtasks vs Macrotasks: a tabela que importa
| Característica | Microtask | Macrotask |
|---|---|---|
| Exemplos | Promise.then, queueMicrotask, MutationObserver | setTimeout, setInterval, setImmediate, I/O callbacks |
| Quando executa | Imediatamente após a call stack esvaziar, antes da próxima macrotask | Uma por iteração do loop, após drenar microtasks |
| Pode starvar o loop? | Sim: microtasks que agendam microtasks travam o ciclo | Não: cada iteração pega apenas uma tarefa |
| Prioridade | Maior | Menor |
| API nativa para agendar | queueMicrotask() | setTimeout(fn, 0) |
As fases do event loop no Node.js (libuv)
No browser, o modelo é mais simples: uma task queue e uma microtask queue. No Node.js, a libuv divide o loop em fases explícitas:
┌───────────────────────────┐
┌─>│ timers │ ← setTimeout, setInterval
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ pending callbacks │ ← callbacks de I/O adiados
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ idle, prepare │ ← uso interno da libuv
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ poll │ ← I/O: fs, net, dns
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ check │ ← setImmediate
│ └───────────┬───────────────┘
│ ┌───────────┴───────────────┐
│ │ close callbacks │ ← socket.on('close')
│ └───────────┬───────────────┘
│ │
└──────────────┘Entre cada fase, o Node.js drena a microtask queue (incluindo process.nextTick, que tem prioridade sobre outras microtasks). Isso gera uma diferença prática entre process.nextTick e queueMicrotask:
// Node.js >= 11: nextTick executa antes de queueMicrotask
process.nextTick(() => {
console.log("1: nextTick");
});
queueMicrotask(() => {
console.log("2: queueMicrotask");
});
Promise.resolve().then(() => {
console.log("3: promise");
});
// Saída:
// 1: nextTick
// 2: queueMicrotask
// 3: promiseA fila de process.nextTick é drenada antes da fila de microtasks de Promise. A documentação oficial do Node.js recomenda preferir queueMicrotask para código novo, porque nextTick pode starvar I/O se usado recursivamente.
setTimeout vs setImmediate: a ordem depende de onde você está
Este é um caso clássico de confusão. Fora de um callback de I/O, a ordem entre setTimeout(fn, 0) e setImmediate(fn) é não-determinística:
// Fora de I/O: ordem INDETERMINADA
setTimeout(() => {
console.log("timeout");
}, 0);
setImmediate(() => {
console.log("immediate");
});
// Pode imprimir em qualquer ordemDentro de um callback de I/O, setImmediate sempre executa primeiro:
import { readFile } from "node:fs";
// Dentro de I/O: setImmediate sempre vem antes
readFile(import.meta.filename, () => {
setTimeout(() => {
console.log("timeout");
}, 0);
setImmediate(() => {
console.log("immediate");
});
});
// Saída garantida:
// immediate
// timeoutO motivo: quando o callback de I/O executa, o loop está na fase poll. A próxima fase é check (onde setImmediate vive). A fase timers só aparece na próxima iteração completa do loop.
Microtasks recursivas: como travar o event loop sem perceber
Uma microtask que agenda outra microtask cria um ciclo que impede o loop de avançar para a próxima macrotask. O runtime drena toda a microtask queue antes de seguir, e se essa fila nunca esvazia, nenhum timer, I/O ou rendering acontece.
// PROBLEMA: loop infinito de microtasks
// O setTimeout NUNCA executa
function starveMacrotasks() {
queueMicrotask(() => {
console.log("microtask executando...");
starveMacrotasks(); // agenda outra microtask eternamente
});
}
setTimeout(() => {
console.log("este log nunca aparece");
}, 0);
starveMacrotasks();No browser, isso trava a aba. No Node.js, o processo fica preso sem processar I/O. É o equivalente assíncrono de um while(true).
O que NÃO fazer
Anti-pattern 1: resolver lógica de ordenação com setTimeout(fn, 0)
// ERRADO: usar setTimeout para "garantir" que algo execute depois
function processarDados(dados) {
// Desenvolvedor quer que a validação execute "depois"
setTimeout(() => {
validar(dados);
}, 0);
// Mas o envio pode acontecer antes da validação
// porque setTimeout agenda uma macrotask,
// e qualquer microtask intermediária executa antes
enviar(dados);
}// CORRETO: usar microtask quando a intenção é "depois do sync, antes do próximo tick"
function processarDados(dados) {
queueMicrotask(() => {
validar(dados);
});
// Ou, se a ordem importa de verdade, torne explícito:
enviar(dados);
}Se a validação precisa acontecer antes do envio, a solução real é nem usar agendamento assíncrono: chame validar(dados) sincronamente antes de enviar(dados). Usar setTimeout ou queueMicrotask para controlar ordem de execução é sinal de que o fluxo está mal desenhado.
Anti-pattern 2: process.nextTick recursivo em servidor HTTP
import { createServer } from "node:http";
const server = createServer((req, res) => {
// ERRADO: nextTick recursivo impede o loop de processar novas requests
function processarFila(itens, indice) {
if (indice >= itens.length) {
res.end("done");
return;
}
process.nextTick(() => {
// processamento pesado item a item via nextTick
processarItem(itens[indice]);
processarFila(itens, indice + 1);
});
}
const itens = Array.from({ length: 10000 }, (_, i) => i);
processarFila(itens, 0);
});
server.listen(3000);import { createServer } from "node:http";
const server = createServer((req, res) => {
// CORRETO: usar setImmediate para ceder controle ao loop entre iterações
function processarFila(itens, indice) {
if (indice >= itens.length) {
res.end("done");
return;
}
// setImmediate permite que o loop processe I/O entre batches
setImmediate(() => {
processarItem(itens[indice]);
processarFila(itens, indice + 1);
});
}
const itens = Array.from({ length: 10000 }, (_, i) => i);
processarFila(itens, 0);
});
server.listen(3000);
function processarItem(item) {
// simulação de processamento
return item * 2;
}A diferença: setImmediate agenda na fase check, permitindo que o loop passe pela fase poll e processe novas conexões HTTP. process.nextTick drena antes de qualquer I/O, bloqueando o servidor para todas as outras requests enquanto os 10.000 itens são processados.
Anti-pattern 3: assumir que await "pausa" o event loop
// ERRADO: pensar que await bloqueia o loop
async function buscarTodos(urls) {
const resultados = [];
for (const url of urls) {
// Cada await cede controle, mas executa SEQUENCIALMENTE
// 10 URLs = 10x o tempo de latência
const resp = await fetch(url);
resultados.push(await resp.json());
}
return resultados;
}// CORRETO: paralelizar quando as chamadas são independentes
async function buscarTodos(urls) {
// Promise.all agenda todas as requests de uma vez
// O event loop processa os callbacks de I/O conforme chegam
const respostas = await Promise.all(
urls.map((url) => fetch(url).then((r) => r.json()))
);
return respostas;
}O await não pausa o event loop. Ele pausa a execução daquela função async e registra a continuação como microtask. O loop continua rodando normalmente. O problema do for...await sequencial é que cada request só inicia depois que a anterior resolve, desperdiçando a capacidade de I/O concorrente que o event loop oferece.
Se você precisa limitar concorrência (por exemplo, não disparar 500 requests simultâneas contra uma API com rate limit), use Promise.allSettled com batches ou uma lib como p-limit. Isso é diferente de serializar tudo.
Provando a ordem com um teste completo
Este exemplo cobre todas as APIs de agendamento do Node.js em um único script. Execute com node --experimental-vm-modules ou qualquer Node.js >= 18:
import { readFile } from "node:fs";
console.log("1: sync start");
setTimeout(() => console.log("2: setTimeout 0"), 0);
setTimeout(() => console.log("3: setTimeout 100"), 100);
setImmediate(() => console.log("4: setImmediate"));
process.nextTick(() => console.log("5: nextTick"));
Promise.resolve().then(() => console.log("6: promise"));
queueMicrotask(() => console.log("7: queueMicrotask"));
readFile(import.meta.filename, () => {
console.log("8: readFile callback");
setTimeout(() => console.log("9: setTimeout inside I/O"), 0);
setImmediate(() => console.log("10: setImmediate inside I/O"));
process.nextTick(() => console.log("11: nextTick inside I/O"));
Promise.resolve().then(() => console.log("12: promise inside I/O"));
});
console.log("13: sync end");Saída esperada (Node.js >= 11):
1: sync start
13: sync end
5: nextTick
6: promise
7: queueMicrotask
2: setTimeout 0
4: setImmediate
8: readFile callback
11: nextTick inside I/O
12: promise inside I/O
10: setImmediate inside I/O
9: setTimeout inside I/O
3: setTimeout 100A ordem entre 2: setTimeout 0 e 4: setImmediate pode variar quando executados fora de I/O, como explicado anteriormente. Dentro do callback de readFile, setImmediate sempre precede setTimeout.
Como isso afeta seu código real
Se você trabalha com processamento assíncrono em filas, entender o event loop é a diferença entre um worker que processa 5.000 jobs por segundo e um que trava com 500. O BullMQ, por exemplo, usa setImmediate internamente para ceder controle entre processamentos de jobs, exatamente para não starvar o I/O do Redis.
Em aplicações com WebSocket em escala, um handler que bloqueia a microtask queue com processamento pesado impede o event loop de processar novas mensagens de todos os outros sockets conectados. Se o gargalo é CPU, o event loop não resolve: você precisa de worker threads ou de um runtime com paralelismo real.
Para debugging de problemas assíncronos, saber que async/await transforma continuações em microtasks ajuda a rastrear por que um callback "deveria" ter executado mas não executou: provavelmente uma microtask anterior está prendendo o loop.
Em APIs com Fastify, o framework depende do event loop estar saudável para manter throughput alto. Um middleware que faz CPU-bound work síncrono dentro de um handler derruba a performance de todas as rotas, não só da rota lenta.
FAQ
queueMicrotask e Promise.resolve().then() são equivalentes?
Funcionalmente, ambos agendam microtasks. A diferença é que queueMicrotask não cria um objeto Promise, então tem overhead menor. Use queueMicrotask quando você quer agendar uma microtask sem precisar da semântica de Promise (encadeamento, catch, etc.). Na prática, a diferença de performance é irrelevante para a maioria dos casos: prefira o que torna a intenção mais clara.
O event loop do browser é igual ao do Node.js?
Não. O browser segue a especificação HTML (que define o conceito de "task source" e "microtask checkpoint") e inclui etapas de rendering (style, layout, paint) entre macrotasks. O Node.js usa a libuv com fases explícitas (timers, poll, check). O comportamento de microtasks é consistente entre os dois, mas a granularidade das macrotasks difere. setImmediate e process.nextTick não existem no browser. requestAnimationFrame não existe no Node.js.
async/await usa microtasks ou macrotasks?
Microtasks. Quando o engine encontra um await, ele suspende a execução da função async e agenda a continuação (o código após o await) como microtask vinculada à resolução da Promise. Isso significa que código após await tem a mesma prioridade de um .then().
Como identificar se o event loop está travado em produção?
No Node.js, meça o "event loop lag": agende um setTimeout(fn, 100) e meça quanto tempo realmente passou. Se o delta é consistentemente maior que o esperado, algo está bloqueando o loop. O módulo perf_hooks do Node.js expõe monitorEventLoopDelay() para isso. Em microserviços com observabilidade, essa métrica deveria estar no seu dashboard ao lado de latência e throughput.
setTimeout(fn, 0) realmente executa em 0ms?
Não. A especificação HTML define um clamping mínimo de 4ms para setTimeout com delay 0 quando aninhado mais de 4 níveis. No Node.js, o mínimo efetivo é 1ms (internamente, delay 0 é tratado como 1). Na prática, o tempo real depende de quanto trabalho o loop tem pendente. Não use setTimeout como mecanismo de temporização precisa.
A posição que defendo
O event loop não é um conceito "avançado" que você aprende depois. É o modelo de execução da linguagem. Escrever JavaScript sem entender microtasks e macrotasks é como escrever SQL sem entender o planner de queries: funciona até o dia que não funciona, e aí você não tem ferramentas para diagnosticar.
Se você usa Node.js em produção, grave estas três regras: process.nextTick starva I/O se recursivo, setImmediate é a forma correta de ceder controle entre iterações pesadas, e microtasks sempre drenam completamente antes da próxima macrotask. O resto é consequência.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do Estúdio iellou Design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando

Debugging Mental: Como Investigar Bugs em JavaScript Sem Depender do Console.log

Migrações de Banco de Dados com PostgreSQL: Prisma Migrate vs Drizzle Kit

Socket.IO do Zero: Chat Completo com Salas, Typing Indicator e Histórico de Mensagens
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.