Ir para o conteúdo
Backend

Event Loop, Microtasks e Macrotasks: O Modelo de Execução do JavaScript

Marcos Soares
11 minutos de leitura
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:

JavaScript
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 é:

Text
1: script start
6: script end
3: promise then
5: queueMicrotask
4: promise then chained
2: setTimeout

Se 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:

  1. Executa todo o código síncrono da call stack até esvaziar.
  2. Drena a microtask queue inteira (Promises, queueMicrotask, MutationObserver no browser).
  3. Pega UMA tarefa da macrotask queue (também chamada de task queue): setTimeout, setInterval, I/O callbacks, setImmediate (Node.js).
  4. 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ísticaMicrotaskMacrotask
ExemplosPromise.then, queueMicrotask, MutationObserversetTimeout, setInterval, setImmediate, I/O callbacks
Quando executaImediatamente após a call stack esvaziar, antes da próxima macrotaskUma por iteração do loop, após drenar microtasks
Pode starvar o loop?Sim: microtasks que agendam microtasks travam o cicloNão: cada iteração pega apenas uma tarefa
PrioridadeMaiorMenor
API nativa para agendarqueueMicrotask()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:

Text
   ┌───────────────────────────┐
┌─>│        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:

JavaScript
// 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: promise

A 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:

JavaScript
// Fora de I/O: ordem INDETERMINADA
setTimeout(() => {
  console.log("timeout");
}, 0);
 
setImmediate(() => {
  console.log("immediate");
});
 
// Pode imprimir em qualquer ordem

Dentro de um callback de I/O, setImmediate sempre executa primeiro:

JavaScript
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
// timeout

O 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.

JavaScript
// 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)

JavaScript
// 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);
}
JavaScript
// 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

JavaScript
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);
JavaScript
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

JavaScript
// 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;
}
JavaScript
// 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:

JavaScript
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):

Text
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 100

A 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.

Marcos Soares

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

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.