← Todos os posts

,
10 min de leitura

Por que Promise sempre vence o setTimeout no event loop

Um loop circular drenando uma fila de itens ao longo do arco, enquanto um item externo espera conectado por uma linha tracejada até o loop terminar

Você escreve um Promise.resolve().then(fn) e um setTimeout(fn, 0) lado a lado. O primeiro sempre roda antes do segundo, mesmo com delay zero. A explicação que a maioria decorou é “promises têm prioridade maior”. Essa explicação está errada, e o motivo real revela algo mais interessante: o event loop não compara prioridade entre os dois. Ele nem chega a olhar para a fila de macrotasks antes de esvaziar completamente outra fila, a de microtasks, mesmo que novas microtasks continuem sendo criadas durante esse processo.

Neste artigo você vai entender o algoritmo real por trás dessa ordem, por que o JavaScript precisou de duas filas distintas para tarefas assíncronas, e onde essa “drenagem total” pode se tornar um problema de performance real.


O problema: a ordem que “não devia” ser assim

Considere este trecho:

JavaScript
console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

A saída é 1, 4, 3, 2. Para quem aprendeu a regra solta “promise vence setTimeout”, isso confirma a expectativa. O problema aparece em casos um pouco mais complexos, como uma cadeia de .then() que agenda mais .then() dentro de si mesma: o setTimeout pode acabar esperando muito mais do que o esperado, porque a fila de microtasks não é “só uma”, ela é reabastecida e esvaziada de novo, quantas vezes for necessário, antes do loop seguir adiante. Quem só decorou “promise primeiro” não tem como prever esse comportamento.

Contexto histórico: duas specs, duas filas

O loop de eventos e a fila de tarefas do browser (callbacks de timer, eventos de UI, I/O) são conceitos anteriores a qualquer padronização de Promises. setTimeout já existia e já operava sobre essa fila desde os primórdios do DOM.

Quando o ECMAScript 2015 introduziu Promises nativas na linguagem, o TC39 precisou de um mecanismo de agendamento que rodasse de forma mais imediata que uma nova volta completa do event loop do host, mas ainda assim de forma assíncrona. Daí nasceu o conceito abstrato de “Job” e “Job Queue” na especificação ECMA-262 (seção 9.5, Jobs and Host Operations to Enqueue Jobs), que define operações como HostEnqueuePromiseJob para o ambiente hospedeiro implementar.

O HTML Living Standard, mantido pelo WHATWG, mapeou esse conceito de “Job” da linguagem para o que a especificação do browser chama de fila de microtasks, integrando-o ao algoritmo do event loop através de um passo chamado “perform a microtask checkpoint”. Duas especificações, mantidas por comitês diferentes, tiveram que se encaixar. Essa costura é a raiz histórica de por que existem hoje duas filas conceitualmente distintas.

Primeiras tentativas: por que “prioridade” não explica tudo

A explicação mais comum que circula é “microtasks têm prioridade sobre macrotasks”. Ela funciona para prever o resultado do exemplo simples acima, mas falha como modelo mental porque sugere uma disputa entre dois itens de filas equivalentes, algo como um desempate. Não é isso que acontece.

Outra tentativa ingênua comum: achar que setTimeout(fn, 0) executa “imediatamente”, com 0ms reais de espera. Isso falha em dois níveis. Primeiro, mesmo com delay 0, o callback sempre vira uma macrotask e precisa esperar o fim da task atual e o esvaziamento completo da fila de microtasks. Segundo, a partir do quinto nível de aninhamento de timers, a especificação HTML força um delay mínimo de 4ms, então nem o número “0” é respeitado literalmente pelo browser (esse clamping existe desde antes do HTML5, documentado em um bug do Firefox de 2010, quando o valor mínimo anterior era 10ms).

Como funciona: o contrato de esvaziamento total

O algoritmo real, descrito na seção “Event loop processing model” do HTML Living Standard, não trata microtask e macrotask como duas filas concorrentes com pesos diferentes. Ele trata de forma sequencial e assimétrica:

  1. Uma única macrotask (a mais antiga da fila) é retirada e executada por iteração do event loop.
  2. Ao final dessa execução, sempre que a pilha de chamadas JavaScript fica vazia, o motor “realiza uma verificação de microtask” (microtask checkpoint).
  3. Esse checkpoint esvazia a fila de microtasks por completo, inclusive microtasks que foram enfileiradas por outras microtasks durante o próprio processo.
  4. Só depois desse esvaziamento total é que o event loop considera seguir para a próxima macrotask ou para a renderização.
Diagrama mostrando os quatro passos do algoritmo do event loop: macrotask executa, pilha esvazia, checkpoint drena a fila de microtasks, próxima macrotask segue

A documentação da MDN descreve esse comportamento recursivo de forma direta: se uma microtask adiciona mais microtasks à fila, essas novas microtasks executam antes da próxima macrotask rodar, porque o motor continua chamando microtasks até não sobrar nenhuma, mesmo que mais continuem sendo adicionadas. Não é prioridade relativa entre dois itens, é um contrato de esvaziamento incondicional de uma fila antes de tocar na outra.

Exemplo simples: rastreando o algoritmo passo a passo

JavaScript
console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve()
  .then(() => console.log('C'))
  .then(() => console.log('D'));

console.log('E');

Passo a passo pelo algoritmo:

  • A macrotask inicial (o script principal) executa até o fim, imprimindo A e E, e agenda o setTimeout (nova macrotask) e o primeiro .then() (nova microtask).
  • A pilha de chamadas esvazia. O motor faz o microtask checkpoint: executa C, o que agenda mais uma microtask (D).
  • O checkpoint não para em C. Ele continua até a fila de microtasks ficar vazia, então executa D também.
  • Só agora, com a fila de microtasks completamente vazia, o event loop segue para a próxima macrotask: o setTimeout, que imprime B.

Resultado: A, E, C, D, B.

Exemplo real: quando a drenagem vira risco de bloqueio

JavaScript
function encadearMicrotasks(profundidade) {
  if (profundidade <= 0) return;
  Promise.resolve().then(() => encadearMicrotasks(profundidade - 1));
}

encadearMicrotasks(100000);

setTimeout(() => console.log('Só executo depois de TODA a cadeia'), 0);

Cada chamada de encadearMicrotasks agenda uma nova microtask antes de retornar. Como o checkpoint de microtasks não termina enquanto houver algo na fila, esse setTimeout só roda depois que as cem mil microtasks encadeadas terminarem de se resolver, uma atrás da outra. Em código de produção, esse padrão aparece de forma menos óbvia: paginação recursiva via .then(), processamento de listas grandes encadeando promises, ou bibliotecas que criam promises internas em excesso a cada operação.

Performance: o custo real de esvaziar a fila

Não encontrei, na pesquisa para este artigo, uma fonte com número exato de milissegundos ou de microtasks encadeadas a partir do qual um usuário passa a perceber travamento de UI. Essa é uma lacuna real de dados quantitativos nesse ponto específico: a MDN documenta o risco como aviso qualitativo, sem benchmark associado.

Existe, porém, um dado concreto sobre o custo de implementação de await: segundo o blog oficial da equipe do motor V8, um único await chegou a custar no mínimo três “microtask ticks” na implementação original de promises nativas, e foi otimizado para um único tick em versões mais recentes do motor, eliminando uma promise interna redundante criada a cada await. Essa é uma confiança média, porque é uma fonte primária única (o blog da equipe V8) sem um segundo benchmark independente cruzando o número exato de ticks antes e depois da otimização.

Limitações: quando esse conhecimento não resolve o problema

Entender o algoritmo do event loop não resolve automaticamente todo bug de ordem assíncrona. Duas limitações relevantes:

Primeiro, esse comportamento é definido pela especificação do host (HTML Living Standard), não pela especificação da linguagem (ECMA-262) isoladamente. Isso significa que ambientes diferentes de execução de JavaScript, como Node.js, podem ter variações no encaixe entre a fila de microtasks e outras filas específicas do runtime (como process.nextTick no Node, que roda antes até mesmo das microtasks de Promise). Um modelo mental construído só a partir do browser não cobre 100% do comportamento em outro runtime.

Segundo, o risco de bloqueio por cadeias recursivas de microtasks não tem uma solução de “ordem de execução”: a correção real é arquitetural, quebrar a cadeia introduzindo uma macrotask intencional (por exemplo, um setTimeout(fn, 0) ou MessageChannel) em pontos estratégicos do processamento, para devolver o controle ao event loop periodicamente.

Comparações: microtask, macrotask e process.nextTick

No Node.js, a hierarquia de prioridade entre filas assíncronas é mais granular do que no browser. process.nextTick roda antes de qualquer microtask de Promise, que por sua vez roda antes de qualquer macrotask (timers, I/O). Essa terceira fila não existe na especificação HTML e é uma adição específica da API do Node, o que reforça o ponto da seção de limitações: o comportamento “microtask sempre antes de macrotask” é universal no ambiente JavaScript padrão, mas os detalhes finos de quantas filas existem e em que ordem dependem do host.

Diagrama mostrando a hierarquia de prioridade entre process.nextTick, microtask e macrotask no Node.js
Diagrama mostrando a hierarquia de prioridade entre process.nextTick, microtask e macrotask no Node.js

requestAnimationFrame, por outro lado, não é nem microtask nem macrotask no sentido tradicional: ele roda em um ponto específico do ciclo de renderização do browser, depois do esvaziamento da fila de microtasks mas antes do próximo repaint, o que o torna a ferramenta certa para animações sincronizadas com o frame de tela, e não para lógica assíncrona genérica.

Conclusão: a ordem nunca foi sobre velocidade

A pergunta que abriu este artigo, por que Promise.resolve().then() sempre roda antes de setTimeout(fn, 0), tem uma resposta que não é sobre qual dos dois é “mais rápido” ou “mais prioritário”. É sobre um contrato explícito do algoritmo do event loop: a fila de microtasks é sempre drenada por completo, de forma recursiva, antes que o loop sequer considere a próxima macrotask. Uma vez visto esse algoritmo, comportamentos que pareciam “mágicos”, como uma cadeia de microtasks conseguir atrasar um timer por tempo indefinido, deixam de ser surpresa e passam a ser previsíveis.

Referências

  • HTML Living Standard, seção Event loop processing model: https://html.spec.whatwg.org/multipage/webappapis.html#event-loop-processing-model
  • ECMA-262, seção 9.5, Jobs and Host Operations to Enqueue Jobs: https://tc39.es/ecma262/#sec-jobs
  • MDN, Using microtasks in JavaScript with queueMicrotask(): https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide
  • MDN, The event loop: https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
  • MDN, Window.setTimeout(): https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout
  • V8 blog, Faster async functions and promises: https://v8.dev/blog/fast-async
  • Mozilla Bugzilla #610077, clamping de setTimeout aninhado: https://bugzilla.mozilla.org/show_bug.cgi?id=610077
  • Jake Archibald, Tasks, microtasks, queues and schedules: https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
  • javascript.info, Event loop: microtasks and macrotasks: https://javascript.info/event-loop

— Luigi Moretti