- Por que isso importa mais do que parece
- O que é o event loop (definição direta)
- A call stack: onde o código de fato roda
- Web APIs e a delegação de tarefas
- Microtasks vs Macrotasks: a diferença que derruba dev em entrevista
- Async/await por baixo do capô
- Três armadilhas que já derrubaram API em produção
- Event loop no Node.js: mesma ideia, outras fases
- Checklist: seu código está bloqueando o event loop?
- FAQ
- Próximos passos
Por que isso importa mais do que parece
Você já debugou um bug assíncrono por uma hora e a solução foi mover duas linhas de lugar? Ou viu uma API Node.js travar todas as requisições porque uma rota fazia um processamento pesado? Esses dois cenários têm a mesma causa raiz: o dev não tinha um modelo mental claro de como o JavaScript executa código.
O event loop não é detalhe de implementação. É a fundação de tudo que é assíncrono em JS — e entender ele de verdade muda como você escreve código, como você debugga e como você explica bugs para o restante do time.
O que é o event loop (definição direta)
JavaScript é single-threaded: existe uma única thread de execução. O event loop é o mecanismo que permite essa thread única parecer fazer várias coisas ao mesmo tempo — sem travar, sem criar threads paralelas.
Ele funciona como um gerente de fila: olha se a call stack está vazia, e se estiver, pega a próxima tarefa da fila e empurra pra execução. Simples assim. O que confunde é que existem duas filas com prioridades diferentes, e entender isso muda completamente como você raciocina sobre código assíncrono.
A call stack: onde o código de fato roda
A call stack é uma estrutura LIFO (last in, first out) — o último frame que entra é o primeiro que sai. Toda chamada de função empurra um frame; quando a função retorna, o frame sai.
function soma(a, b) {
return a + b;
}
function calcular() {
const resultado = soma(2, 3);
console.log(resultado);
}
calcular();
A stack nesse exemplo:
→ calcular() entra
→ soma() entra
← soma() retorna 5, sai
→ console.log() entra e sai
← calcular() sai
Stack vazia.
Enquanto a stack tem frames, nada mais acontece. Nenhum callback executa, nenhum evento é processado. A stack tem prioridade absoluta — e é exatamente isso que causa os problemas de performance que vamos ver mais adiante.
Web APIs e a delegação de tarefas
Quando você chama setTimeout, fetch, addEventListener ou qualquer outra operação assíncrona, você não está chamando JavaScript puro. Está entregando trabalho para o ambiente — no browser são as Web APIs; no Node.js são as APIs do libuv.
O fluxo completo de um setTimeout:
console.log("A");
setTimeout(() => console.log("B"), 1000);
console.log("C");
console.log("A")executa na stack — imprimeAsetTimeouté chamado — JS passa o callback e o timer pro browser e continua imediatamenteconsole.log("C")executa na stack — imprimeC- Stack fica vazia
- Após 1 segundo, o browser coloca o callback na macrotask queue
- O event loop vê a stack vazia, pega o callback da fila, empurra na stack
- Imprime
B
Output: A, C, B. Exatamente o que você esperava — mas agora você sabe por quê.
⚠️ Atenção:
setTimeout(fn, 0)não significa "execute agora". Significa "coloque na fila assim que possível". O mínimo real em browsers modernos é 4ms, e em produção pode ser muito mais dependendo da carga da fila.
Microtasks vs Macrotasks: a diferença que derruba dev em entrevista
Existem duas filas, não uma. E a diferença entre elas explica por que Promises se comportam diferente de setTimeout.
| Microtask Queue | Macrotask Queue | |
|---|---|---|
| O que vai pra lá | Promise.then, queueMicrotask, MutationObserver | setTimeout, setInterval, I/O, setImmediate |
| Quando processa | Esvazia completamente após cada task | Uma por ciclo do event loop |
| Prioridade | Alta | Normal |
A regra que você precisa gravar: após cada macrotask (ou após o script inicial), o event loop esvazia toda a microtask queue antes de pegar a próxima macrotask.
console.log("1 - síncrono");
setTimeout(() => console.log("2 - macrotask"), 0);
Promise.resolve()
.then(() => console.log("3 - microtask"))
.then(() => console.log("4 - microtask encadeada"));
console.log("5 - síncrono");
1 - síncrono
5 - síncrono
3 - microtask
4 - microtask encadeada
2 - macrotask
Por quê? O script inicial roda na stack. Quando termina, o event loop esvazia as microtasks (3 e 4). Só então pega a macrotask do setTimeout (2).
Num code review anos atrás, um dev tinha colocado lógica de validação num .then() depois de um setTimeout, achando que o timeout seria o primeiro a executar. A validação rodava antes do timeout, os dados chegavam inválidos e a UI ficava num estado inconsistente. O bug levou três horas para isolar porque ninguém no time tinha esse modelo mental claro.
Async/await por baixo do capô
async/await é açúcar sintático sobre Promises. Quando o JavaScript encontra um await, ele pausa aquela função, devolve o controle pra stack, e enfileira a continuação como microtask assim que a Promise resolve.
async function buscarDados() {
console.log("buscando...");
const dados = await fetch("/api/dados");
console.log("dados recebidos");
return dados;
}
console.log("antes");
buscarDados();
console.log("depois");
antes
buscando...
depois
dados recebidos
"depois" imprime antes de "dados recebidos" porque o await pausa buscarDados, devolve o controle, e o restante do script síncrono termina. Quando o fetch resolve, a continuação da função volta como microtask.
💡 Dica: Uma
async functionsemawaitna chamada não espera terminar. O código seguinte executa imediatamente. Isso é especialmente traiçoeiro com operações de escrita.
async function salvar(dados) {
await db.save(dados);
console.log("salvo!");
}
salvar(payload);
redirect("/sucesso");
redirect vai executar antes de "salvo!" imprimir — e antes de db.save terminar. Dependendo do banco, você pode ter um redirect com dado ainda não confirmado.
await salvar(payload);
redirect("/sucesso");
Três armadilhas que já derrubaram API em produção
1. Bloqueio síncrono em rota crítica
Se você colocar operação síncrona pesada na stack, o event loop para completamente — nenhuma outra requisição é processada enquanto ela roda.
app.get("/relatorio", (req, res) => {
const csv = gerarRelatorioCSV(dados);
res.send(csv);
});
Se gerarRelatorioCSV levar 2 segundos de CPU síncrona num servidor Node.js com 50 requisições simultâneas, todas as 49 outras ficam bloqueadas esperando. Já vi isso derrubar endpoint em horário de pico porque alguém fez JSON.parse em payload de 40MB de forma síncrona — o GC mal dava conta.
app.get("/relatorio", async (req, res) => {
const csv = await gerarRelatorioCSVAsync(dados);
res.send(csv);
});
Para trabalho CPU-intensivo que não tem versão async natural, use Worker Threads.
2. Awaits sequenciais onde deveriam ser paralelos
const usuario = await buscarUsuario(id);
const pedidos = await buscarPedidos(id);
const endereco = await buscarEndereco(id);
Esse código espera cada requisição terminar antes de iniciar a próxima. Se cada uma leva 300ms, o total é ~900ms. Mas as três são independentes — podem rodar em paralelo:
const [usuario, pedidos, endereco] = await Promise.all([
buscarUsuario(id),
buscarPedidos(id),
buscarEndereco(id),
]);
Total: ~300ms. Três vezes mais rápido. await dentro de for...of tem o mesmo problema — as iterações rodam em série.
3. Promises que rejeitam em silêncio
async function processar() {
const resultado = await operacaoArriscada();
salvar(resultado);
}
salvar retorna uma Promise, mas ninguém está esperando ela. Se ela rejeitar, o erro some. No Node.js antes da versão 15, era um warning no log e continuava rodando. Na 15+, derruba o processo inteiro com UnhandledPromiseRejection.
async function processar() {
const resultado = await operacaoArriscada();
await salvar(resultado);
}
Se você realmente não quer esperar, pelo menos trate: salvar(resultado).catch(logger.error).
Event loop no Node.js: mesma ideia, outras fases
O Node.js usa o mesmo conceito, mas implementado via libuv — uma biblioteca C que abstrai I/O assíncrono em diferentes SOs. Em vez de Web APIs, você tem o filesystem, rede, crypto, etc.
O event loop do Node tem fases bem definidas:
┌─────────────────────────────┐
│ timers │ ← setTimeout, setInterval
├─────────────────────────────┤
│ pending callbacks │ ← erros de I/O do ciclo anterior
├─────────────────────────────┤
│ idle, prepare │ ← uso interno
├─────────────────────────────┤
│ poll │ ← busca novos eventos I/O
├─────────────────────────────┤
│ check │ ← setImmediate
├─────────────────────────────┤
│ close callbacks │ ← socket.on('close', ...)
└─────────────────────────────┘
setImmediate executa na fase check, depois do poll. setTimeout(fn, 0) executa na fase timers, antes do poll. Qual dos dois roda primeiro depende de onde você está no ciclo — é um detalhe que só importa quando você está debugando ordem de execução em código muito específico.
O que importa saber: process.nextTick tem prioridade ainda maior que as Promises no Node.js. Ele esvazia antes de qualquer microtask do ciclo atual.
⚠️ Atenção: Abusar de
process.nextTickem loops recursivos pode starvar o event loop de processar I/O completamente. Já vi servidor Node.js parar de responder a requisições por exatamente esse motivo.
Checklist: seu código está bloqueando o event loop?
Antes de colocar em produção qualquer rota ou worker, passe por isso:
Operações síncronas perigosas:
-
JSON.parseouJSON.stringifyem payloads acima de 1MB numa rota de API -
fs.readFileSyncoufs.writeFileSyncem qualquer contexto de servidor - Loop
forcom mais de ~10k iterações de CPU pura sem pausa - Regex complexa em strings longas (pode ser catastroficamente lenta)
-
crypto.pbkdf2Sync,bcrypt.hashSyncou similar em rota síncrona
Problemas com Promises:
-
awaitdentro deforEach— não funciona como você pensa, usefor...of -
awaitsequencial em chamadas independentes — usePromise.all - Promise retornada por função não está sendo
awaitada nem tendo.catch
Para medir: No Node.js, --inspect + Chrome DevTools ou o clinic.js mostram qual função está ocupando a stack por mais tempo. No browser, a aba Performance do DevTools marca vermelho qualquer Long Task acima de 50ms.
FAQ
JavaScript é realmente single-threaded? Web Workers não contradizem isso?
A thread principal é single-threaded. Web Workers e Worker Threads no Node.js criam threads separadas, cada uma com seu próprio event loop isolado. Elas se comunicam com a thread principal via postMessage — não compartilham memória diretamente (exceto SharedArrayBuffer com atomics). Então sim, você pode ter paralelismo, mas não na thread principal.
async/await é mais rápido que .then() em cadeia?
Em termos de performance, são equivalentes — async/await é transpilado para Promises pelo motor. A diferença real está em legibilidade, facilidade de debugar e stack traces mais claros. Em versões modernas do V8 (Node 12+), async/await é até ligeiramente mais otimizado em alguns casos.
Por que minha Promise às vezes parece executar antes do que eu esperava?
Porque Promises são microtasks — prioridade mais alta que macrotasks como setTimeout. Se você tem um Promise.resolve().then(fn) e um setTimeout(fn, 0) na mesma execução, a Promise sempre vai na frente, independente de qual foi registrado primeiro.
Como saber exatamente qual linha está bloqueando o event loop em produção?
No Node.js: clinic.js (especialmente clinic flame) gera um flamegraph das funções que ocupam mais CPU. O --inspect com Chrome DevTools também funciona para capturar CPU profiles. No browser: Performance > Record > interaja com a página > pare. Qualquer barra amarela longa na thread principal é uma Long Task — clique para ver o stack trace.
Promise.all vs Promise.allSettled — quando uso cada um?
Promise.all falha rápido: se qualquer Promise rejeitar, a combinação toda rejeita imediatamente (fail-fast). Use quando você precisa de todos os resultados para prosseguir. Promise.allSettled espera todas terminarem, seja qual for o resultado, e retorna um array com {status: 'fulfilled', value} ou {status: 'rejected', reason}. Use quando precisa processar resultados parciais ou registrar quais falharam sem abortar as demais.
Próximos passos
Se você quer solidificar esse entendimento na prática:
O Loupe ainda é a melhor ferramenta para visualizar o event loop em tempo real. Cola seu código e assiste a call stack, a callback queue e o event loop se movendo. Vale 10 minutos antes de continuar.
Os próximos tópicos que fazem sentido estudar a partir daqui:
- Worker Threads no Node.js — como mover trabalho CPU-intensivo sem bloquear a thread principal
- Scheduler API — a API nativa do browser para priorizar tarefas e evitar jank de renderização
- Streams no Node.js — processar I/O grande sem carregar tudo na memória de uma vez
O event loop não é complexo. É uma fila com regras de prioridade. Depois que o modelo mental fica claro, você para de debugar comportamentos assíncronos no escuro — e começa a prever a ordem de execução antes de rodar o código.