Índice
- O que é o Event Loop, de verdade
- Call Stack, Web APIs e as duas filas
- A ordem de execução passo a passo
- Microtasks vs Macrotasks: a regra que muda tudo
- Node.js: o que muda em relação ao browser
- Erros reais de produção causados por mental model errado
- Como medir e debugar com a Performance API
- Quiz: você sabe prever a ordem?
- FAQ
O Event Loop não é mágica, não é abstração — é um ciclo com três passos que se repete enquanto o processo estiver vivo. A maioria dos bugs "inexplicáveis" em código assíncrono tem uma causa concreta aqui. Este artigo cobre a mecânica real, os comportamentos que divergem entre browser e Node.js, e os erros que aparecem quando o mental model está errado.
O que é o Event Loop, de verdade
JavaScript é single-threaded: uma call stack, um trabalho de cada vez. O Event Loop é o mecanismo que permite que código assíncrono funcione sem bloquear essa thread.
A definição objetiva: o Event Loop verifica, continuamente, se a call stack está vazia. Se estiver, move o próximo item da fila de callbacks para a stack e executa. É isso. A complexidade está nas regras de qual fila tem prioridade e em que momento cada fila é drenada.
Entender essas regras é o que separa debugar na base da tentativa e erro de ler o comportamento do código antes de rodar.
Call Stack, Web APIs e as duas filas
O runtime JavaScript não é só o engine (V8, SpiderMonkey). É um conjunto de peças:
┌──────────────────────────────────────────────────────────┐
│ JavaScript Runtime │
│ │
│ ┌─────────────────┐ ┌────────────────────────┐ │
│ │ Call Stack │ │ Web APIs │ │
│ │ │ │ setTimeout · fetch │ │
│ │ processarItem()│ │ addEventListener · XHR │ │
│ │ main() │ └───────────┬────────────┘ │
│ └────────┬────────┘ │ │
│ │ ┌─────────▼──────────────┐ │
│ │ │ Microtask Queue │ │
│ ┌────────▼────────┐ │ Promise.then · qMT │ │
│ │ Event Loop │◄────────┤ │ │
│ └─────────────────┘ ├────────────────────────┤ │
│ │ Macrotask Queue │ │
│ │ setTimeout · setInt. │ │
│ └────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
Call Stack — onde código síncrono executa, frame por frame (LIFO). Enquanto houver frames aqui, o Event Loop não age.
Web APIs — não são JavaScript. São interfaces do ambiente (browser ou libuv no Node.js) que rodam fora da sua thread. setTimeout delega o timer para o browser. fetch delega a requisição de rede. Quando o trabalho termina, o callback entra na fila.
Microtask Queue — alta prioridade. Promise.then/catch/finally, queueMicrotask(), MutationObserver.
Macrotask Queue (Callback Queue) — prioridade padrão. setTimeout, setInterval, callbacks de I/O, eventos do DOM.
A ordem de execução passo a passo
O ciclo do Event Loop é sempre este:
- Executa tudo que está na Call Stack até esvaziar
- Drena toda a Microtask Queue (uma entrada por vez, mas sem parar até vazar)
- Processa uma entrada da Macrotask Queue
- Volta para o passo 2
Isso tem uma consequência direta: se você enfileirar microtasks recursivamente, o Event Loop nunca chega na macrotask queue. A thread não trava (não é blocking), mas timers e eventos do DOM ficam presos esperando.
let count = 0;
function drenaParaSempre() {
if (count++ < 1_000_000) {
queueMicrotask(drenaParaSempre);
}
}
setTimeout(() => console.log('nunca imprime antes das microtasks acabarem'), 0);
drenaParaSempre();
O setTimeout só vai rodar depois que um milhão de microtasks terminarem. Em um caso real com recursão infinita, o setTimeout nunca roda. Isso é starvation de macrotasks.
Microtasks vs Macrotasks: a regra que muda tudo
| Microtask Queue | Macrotask Queue | |
|---|---|---|
| O que vai pra lá | Promise.then/catch/finally, queueMicrotask(), MutationObserver | setTimeout, setInterval, setImmediate, I/O callbacks |
| Prioridade | Alta — drena completa antes da próxima macrotask | Normal — uma por ciclo |
| Quando é processada | Após cada script ou macrotask, antes do próximo render | Um item por volta do loop |
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve()
.then(() => console.log('3'))
.then(() => console.log('4'));
console.log('5');
Antes de rodar: tente prever a saída.
1
5
3
4
2
O script principal roda (1, 5). Depois, a Microtask Queue é drenada (3, 4). Só então o setTimeout (2) é processado — mesmo com zero milissegundos de delay.
⚠️ Atenção:
setTimeout(fn, 0)não significa "execute agora". Significa "coloque na Macrotask Queue assim que possível". Em browsers, o mínimo real é 4ms por limitação da spec HTML. E se houver microtasks pendentes, vai demorar mais do que isso.
Isso virou um bug em um sistema de notificações: o dev usava setTimeout(atualizar, 0) depois de resolver uma Promise, assumindo que o DOM seria atualizado antes. Não era. A Promise resolvia, outras microtasks de React disparavam, e o atualizar só rodava depois — com estado desatualizado. Trocar para queueMicrotask(atualizar) resolveu.
💡 Dica:
queueMicrotask(fn)é mais explícito quePromise.resolve().then(fn)para o mesmo efeito. Use quando precisar adiar algo para depois do código síncrono atual, mas antes do próximo timer ou evento.
Node.js: o que muda em relação ao browser
O Node.js não usa as Web APIs do browser. Usa a libuv, uma biblioteca C++ que implementa o event loop com fases distintas. O resultado é uma ordem de execução com mais nuance.
process.nextTick — prioridade acima de tudo
process.nextTick não é microtask na spec, mas tem prioridade ainda maior que a Microtask Queue. Ele roda antes de qualquer Promise.then, antes de qualquer fase do event loop.
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
console.log('síncrono');
síncrono
nextTick
Promise
setTimeout
Recursão com process.nextTick causa starvation de forma ainda mais agressiva que com Promises. Se você chamar process.nextTick dentro de um callback de nextTick, o I/O nunca acontece.
setImmediate vs setTimeout(fn, 0) no Node.js
Ambos vão para a Macrotask Queue, mas em fases diferentes da libuv:
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
Fora de um callback de I/O, a ordem não é determinística — depende do tempo que o processo levou para iniciar. Dentro de um callback de I/O, setImmediate sempre vem antes de setTimeout.
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
setImmediate
setTimeout
Isso é determinístico dentro do callback de I/O porque o setImmediate opera na fase check da libuv, que vem antes da fase de timers na próxima iteração.
Ordem completa no Node.js
nextTick Queue → Microtask Queue → timers → I/O → check (setImmediate) → close callbacks
Para a maioria das aplicações Node.js, o que importa na prática: process.nextTick > Promise.then > setTimeout > setImmediate (em I/O).
Erros reais de produção causados por mental model errado
Travar a thread com processamento síncrono pesado
app.get('/relatorio', async (req, res) => {
const dados = await buscarDados();
const resultado = dados.map(item => {
let soma = 0;
for (let i = 0; i < 5_000_000; i++) soma += item.valor * i;
return soma;
});
res.json(resultado);
});
Durante esse map, nenhuma outra requisição ao servidor é atendida. O Event Loop está bloqueado na Call Stack. Em Node.js com um servidor de produção, isso significa latência para todos os outros usuários enquanto um relatório processa.
async function processarEmChunks(dados) {
const CHUNK = 500;
const resultados = [];
for (let i = 0; i < dados.length; i += CHUNK) {
const chunk = dados.slice(i, i + CHUNK);
const parcial = chunk.map(item => calcular(item));
resultados.push(...parcial);
await new Promise(resolve => setTimeout(resolve, 0));
}
return resultados;
}
O await setTimeout(resolve, 0) libera a thread entre chunks, permitindo que o Event Loop processe outras requisições. Para casos mais pesados, a solução correta é worker_threads.
async dentro de forEach — o erro mais comum em PRs
const emails = ['a@a.com', 'b@b.com', 'c@c.com'];
emails.forEach(async (email) => {
await enviarEmail(email);
});
console.log('todos enviados');
O console.log imprime antes de qualquer email ser enviado. forEach não aguarda Promises — dispara todos os callbacks e retorna. O await suspende cada callback individualmente, mas o forEach já terminou.
for (const email of emails) {
await enviarEmail(email);
}
console.log('todos enviados');
Para execução paralela (quando a ordem não importa):
await Promise.all(emails.map(email => enviarEmail(email)));
console.log('todos enviados');
Promise resolvida não é dado disponível
let usuario;
buscarUsuario(1).then(u => { usuario = u; });
console.log(usuario.nome);
TypeError: Cannot read properties of undefined — o .then ainda não rodou quando o console.log executa. A variável usuario está undefined.
const usuario = await buscarUsuario(1);
console.log(usuario.nome);
Como medir e debugar com a Performance API
O Chrome DevTools Performance tab mostra cada task, microtask e frame de render. Mas você pode medir diretamente no código:
performance.mark('inicio');
setTimeout(() => {
performance.mark('macrotask');
const m = performance.measure('ate-macrotask', 'inicio', 'macrotask');
console.log(`macrotask: ${m.duration.toFixed(3)}ms`);
}, 0);
Promise.resolve().then(() => {
performance.mark('microtask');
const m = performance.measure('ate-microtask', 'inicio', 'microtask');
console.log(`microtask: ${m.duration.toFixed(3)}ms`);
});
Resultado típico no Chrome:
microtask: 0.100ms
macrotask: 1.200ms
A microtask roda com overhead de décimos de milissegundo. O setTimeout 0 leva mais de 1ms mesmo em ambiente ocioso — e pode chegar a 4ms+ com a stack ocupada.
Para identificar long tasks (tarefas que travam a thread por mais de 50ms):
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn(`Long task detectada: ${entry.duration.toFixed(0)}ms`);
}
});
observer.observe({ entryTypes: ['longtask'] });
Esse observer dispara sempre que o Event Loop fica bloqueado por mais de 50ms — o limiar definido pelo Google como impacto perceptível para o usuário.
Quiz: você sabe prever a ordem?
Tente prever o output antes de rodar. Anote sua resposta, depois cole no console.
Nível 1
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
Nível 2
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
Promise.resolve()
.then(() => {
console.log('4');
setTimeout(() => console.log('5'), 0);
})
.then(() => console.log('6'));
console.log('7');
Nível 3 (Node.js)
process.nextTick(() => console.log('A'));
Promise.resolve().then(() => console.log('B'));
setTimeout(() => console.log('C'), 0);
setImmediate(() => console.log('D'));
console.log('E');
Se você errou o nível 2 ou 3, releia as seções de Microtasks vs Macrotasks e Node.js. O padrão que aparece no nível 2 — uma macrotask enfileirando uma microtask — é o comportamento que mais surpreende em aplicações reais.
FAQ
O Event Loop do Node.js é o mesmo do browser?
Não. O browser implementa o event loop conforme a spec HTML (WHATWG). O Node.js usa a libuv, que tem fases distintas: timers, pending callbacks, idle/prepare, poll, check e close callbacks. A diferença mais prática é a existência de process.nextTick e setImmediate no Node.js, que não existem no browser.
await bloqueia a thread?
Não. await suspende apenas a execução da função onde está declarado e devolve o controle para o Event Loop. O thread principal continua processando outras tasks e microtasks normalmente. Só aquela função fica aguardando a Promise resolver para continuar.
Renderização de frames é macrotask? Sim, no browser. O ciclo de render (layout, paint, composite) acontece entre macrotasks. Se uma macrotask demorar mais de 16ms, o browser não consegue renderizar a 60fps. Por isso animações que processam dados em loop travam — a task ocupa a thread, o frame de render fica na fila esperando.
Promise.all paralelo é mais rápido, mas tem riscos?
Sim. Se uma das Promises rejeitar, Promise.all rejeita imediatamente — as outras continuam rodando em background mas os resultados são descartados. Use Promise.allSettled quando precisar de todos os resultados independente de falhas parciais. Use Promise.any quando quiser o resultado mais rápido entre várias opções.
Como cancelar uma operação assíncrona em andamento?
Promises nativas não têm cancelamento. A solução padrão é AbortController com a Fetch API:
const controller = new AbortController();
setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch('/api/dados', { signal: controller.signal });
} catch (e) {
if (e.name === 'AbortError') console.log('cancelado após 3s');
}
Para outras operações assíncronas, você precisa implementar a lógica de cancelamento manualmente verificando um flag antes de cada etapa.