Event Loop do JavaScript: Call Stack, Microtasks e a Ordem que Ninguém Explica
← Voltar para Codeshort

Event Loop do JavaScript: Call Stack, Microtasks e a Ordem que Ninguém Explica

O Event Loop não é conceito — é uma ordem de execução com regras fixas. Entenda a fila, o ciclo e os erros que surgem quando o mental model está errado.

DC
Dev Code Software
10 de abril de 2026·10 min de leitura

Índice


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:

  1. Executa tudo que está na Call Stack até esvaziar
  2. Drena toda a Microtask Queue (uma entrada por vez, mas sem parar até vazar)
  3. Processa uma entrada da Macrotask Queue
  4. 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 QueueMacrotask Queue
O que vai pra láPromise.then/catch/finally, queueMicrotask(), MutationObserversetTimeout, setInterval, setImmediate, I/O callbacks
PrioridadeAlta — drena completa antes da próxima macrotaskNormal — uma por ciclo
Quando é processadaApós cada script ou macrotask, antes do próximo renderUm 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 que Promise.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.