Microtasks: nextTick, queueMicrotask, Promise.then

TL;DR

Microtasks rodam entre fases do event loop, antes que a próxima fase comece — código síncrono → nextTick → microtasks → event loop phase. A hierarquia de prioridade é estrita: process.nextTick drena sua fila inteira primeiro (incluindo novos nextTick adicionados durante a drenagem), depois queueMicrotask e Promise.then são processados na fila padrão de microtasks em ordem FIFO. Recursão em nextTick bloqueia o loop indefinidamente — o fenômeno chamado de queue starvation; use setImmediate para trabalho fatiado. nextTick é Node-specific; queueMicrotask é portável para browsers, Deno e Bun. A diferença entre as três APIs não é só de prioridade: erros em nextTick/queueMicrotask viram uncaughtException, enquanto erros em Promise.then viram unhandledRejection — categorias de falha distintas com handlers distintos.

Por que process.nextTick às vezes dispara antes das suas Promises?

Você tem código async com Promise.then e process.nextTick e a ordem de execução não é o que você esperava. Ou pior — um servidor em produção trava misteriosamente sem consumir CPU, e nenhum log aparece. As duas situações têm a mesma raiz: existem três filas diferentes que parecem iguais mas têm prioridades estritamente ordenadas.

O que é

Uma microtask é uma unidade de trabalho que o runtime executa assim que o código síncrono atual termina, mas antes de avançar para a próxima fase do event loop (antes de processar timers, I/O, setImmediate, etc.). O conceito existe tanto no browser quanto no Node.js, mas o Node adiciona uma camada extra: a fila do process.nextTick.

No Node.js, existem três APIs principais que agendam trabalho nesse espaço “entre fases”:

process.nextTick(callback[, ...args])

A API mais antiga e de maior prioridade. Callbacks registrados com nextTick são colocados em uma fila separada — a nextTickQueue — que é drenada completamente antes que qualquer microtask convencional seja processada, e antes que o event loop avance de fase. É uma API Node-specific: não existe nos browsers nem no Deno com a mesma semântica.

process.nextTick(() => {
  console.log('nextTick callback');
});

O segundo argumento em diante é repassado como argumento ao callback — útil para evitar closures desnecessárias:

process.nextTick((usuario, acao) => {
  console.log(`${usuario} fez ${acao}`);
}, 'alice', 'login');

queueMicrotask(callback)

Introduzida no Node.js 11 (globalizada no Node 12), queueMicrotask agenda uma função na fila padrão de microtasks — a mesma fila usada pelos callbacks de Promise. É a API portável: existe no browser, no Node.js, no Deno e no Bun com comportamento idêntico. Não aceita argumentos extras (use uma arrow function se precisar de closure).

queueMicrotask(() => {
  console.log('microtask via queueMicrotask');
});

Promise.resolve().then(callback)

O mecanismo mais familiar. Callbacks de .then(), .catch() e .finally() também são enfileirados na fila padrão de microtasks, na mesma posição que queueMicrotask. A diferença principal está no tratamento de erros: erros em callbacks de Promise viram unhandledRejection; erros em queueMicrotask viram uncaughtException.

Promise.resolve().then(() => {
  console.log('microtask via Promise.then');
});

Hierarquia dentro de uma iteração

Em cada ponto de drenagem (ao final de cada fase do event loop, e ao final de cada callback de macrotask):

  1. nextTickQueue — drenada completamente (todos os nextTick pendentes, incluindo os novos que forem adicionados durante a drenagem)
  2. Microtask queue — drenada completamente (queueMicrotask + Promise.then, intercalados na ordem em que foram enfileirados)
  3. Próxima fase do event loop começa

Diagrama — prioridade das filas

flowchart LR
    CS["Código síncrono\n(Call Stack)"]
    CS -->|"stack vazia"| NT

    subgraph MICRO["Espaço entre fases do event loop"]
        direction TB
        NT["nextTickQueue\n(process.nextTick)\n🔴 maior prioridade"]
        MT["Microtask Queue\n(queueMicrotask + Promise.then)\n🟡 prioridade padrão"]
        NT -->|"esgota completamente"| MT
    end

    MT -->|"esgota completamente"| EL

    EL["Próxima fase do\nEvent Loop\n(timers / poll / check…)"]

Regra crítica: nextTick registrado dentro de outra microtask drena antes das demais microtasks já enfileiradas — a nextTickQueue é verificada após cada microtask individual.

Por que importa

Controlar a ordem de execução em nível fino é essencial em dois contextos principais:

Correção de APIs assíncronas: uma função que às vezes retorna sincronamente e às vezes assincronamente cria bugs difíceis de rastrear. Deferir o callback com nextTick garante que o caller sempre recebe o resultado de forma assíncrona, mesmo quando o trabalho é síncrono — permitindo que o caller registre listeners ou configure estado antes que o callback rode.

Debugging de ordem de execução: microtasks são a causa mais comum de “por que esse callback rodou antes do que eu esperava?“. Entender a hierarquia — nextTick > microtasks > macrotasks (timers, I/O, setImmediate) — é requisito para diagnosticar corridas e comportamentos inesperados em código assíncrono.

Como funciona

Exemplo 1 — Ordem de execução com timer, Promise e nextTick

setTimeout(() => console.log('1: timer'), 0);
 
Promise.resolve().then(() => console.log('2: promise'));
 
process.nextTick(() => console.log('3: nextTick'));
 
console.log('4: síncrono');
 
// Saída:
// 4: síncrono      (código síncrono roda primeiro, esvaziando a call stack)
// 3: nextTick      (nextTickQueue drenada antes das microtasks)
// 2: promise       (microtask queue drenada antes de avançar de fase)
// 1: timer         (macrotask — fase timers do event loop)

A call stack deve estar vazia antes de qualquer microtask rodar. Código síncrono tem prioridade absoluta. Em seguida vem a nextTickQueue, depois as microtasks convencionais, e só então o event loop avança para a próxima fase (onde timers, I/O e setImmediate vivem).

Exemplo 2 — Recursão em nextTick bloqueia o event loop (starvation)

function loop() {
  process.nextTick(loop);
}
 
loop();
 
// O programa nunca avança.
// Nenhum timer, I/O, ou setImmediate jamais executa.
// A nextTickQueue é drenada antes de qualquer avanço de fase,
// mas cada drenagem adiciona mais um item — loop infinito na fila.

O mesmo problema ocorre com queueMicrotask recursivo: a microtask queue também é completamente drenada antes de avançar de fase, então recursão na microtask queue também trava o event loop.

Exemplo 3 — queueMicrotask vs Promise.resolve().then

queueMicrotask(() => console.log('A: queueMicrotask'));
Promise.resolve().then(() => console.log('B: Promise.then'));
queueMicrotask(() => console.log('C: queueMicrotask 2'));
 
// Saída:
// A: queueMicrotask
// B: Promise.then
// C: queueMicrotask 2
 
// Ambas as APIs alimentam a mesma fila de microtasks.
// A ordem é a ordem de enfileiramento — FIFO.

A diferença não é de prioridade, mas de comportamento em caso de erro:

// Erro em queueMicrotask → uncaughtException
queueMicrotask(() => {
  throw new Error('erro síncrono na microtask');
});
 
// Erro em Promise.then → unhandledRejection
Promise.resolve().then(() => {
  throw new Error('erro vira rejeição de Promise');
});

Tabela comparativa

APIFilaPrioridadePadrãoAceita args extrasErro não capturado
process.nextTicknextTickQueueMais altaNode-specificSimuncaughtException
queueMicrotaskMicrotask queuePadrãoWeb/Node/Deno/BunNãouncaughtException
Promise.thenMicrotask queuePadrãoWeb/Node/Deno/BunNãounhandledRejection

Na prática

Pattern: EventEmitter no constructor

O uso canônico do process.nextTick em bibliotecas é garantir que o evento emitido no construtor possa ser observado por listeners registrados depois da instanciação:

const { EventEmitter } = require('node:events');
 
class MyEmitter extends EventEmitter {
  constructor() {
    super();
    // SEM nextTick: o emit roda antes do caller registrar .on('event', ...)
    // COM nextTick: o emit é diferido, caller tem chance de registrar o listener
    process.nextTick(() => {
      this.emit('ready');
    });
  }
}
 
const emitter = new MyEmitter();
 
// Este listener é registrado antes do nextTick rodar
emitter.on('ready', () => {
  console.log('emitter pronto'); // funciona corretamente
});

Sem o nextTick, o emit('ready') rodaria durante a execução do construtor, antes que a linha emitter.on('ready', ...) fosse alcançada — e o listener nunca seria disparado.

Pattern: validação assíncrona consistente

function processarDados(dados, callback) {
  if (!Array.isArray(dados)) {
    // Retornar erro sincronamente quebraria o contrato assíncrono da API
    return process.nextTick(callback, new TypeError('dados deve ser array'));
  }
 
  // Caminho assíncrono real
  setImmediate(() => {
    callback(null, dados.map(d => d * 2));
  });
}
 
// O caller sempre recebe o callback de forma assíncrona — comportamento previsível
processarDados('erro', (err) => {
  console.error(err.message); // 'dados deve ser array'
});

Quando preferir queueMicrotask

Use queueMicrotask quando:

  • O código precisa rodar em múltiplos runtimes (browser + Node.js)
  • Não há necessidade de prioridade sobre Promises
  • A semântica padrão de microtask é suficiente

Use process.nextTick quando:

  • É necessária execução antes de qualquer Promise pendente
  • O código é Node.js-only e precisa do padrão de callback assíncrono consistente
  • Está construindo uma API de baixo nível que emite eventos

Casos práticos

Cenário 1 — EventEmitter no constructor: o clássico uso de nextTick

Uma biblioteca criava um EventEmitter e emitia 'ready' no construtor. Usuários reportavam que o listener nunca disparava.

const { EventEmitter } = require('node:events');
 
// ❌ Sem nextTick: emit roda DURANTE o construtor, antes do caller registrar .on()
class BadEmitter extends EventEmitter {
  constructor() {
    super();
    this.emit('ready'); // caller ainda não registrou o listener — silêncio
  }
}
const bad = new BadEmitter();
bad.on('ready', () => console.log('nunca chega aqui')); // tarde demais
 
// ✅ Com nextTick: emit é diferido para depois que o caller termina de configurar
class GoodEmitter extends EventEmitter {
  constructor() {
    super();
    process.nextTick(() => this.emit('ready')); // drena após o código síncrono atual
  }
}
const good = new GoodEmitter();
good.on('ready', () => console.log('disparou!')); // registrado antes do nextTick rodar

Por que funciona: nextTick drena após a call stack esvaziar — o caller tem a oportunidade de registrar listeners antes do emit.

Cenário 2 — Processamento de fila longa: nextTick vs setImmediate

Um worker processava filas de mensagens usando process.nextTick para encadear itens. Em produção, requests HTTP ao mesmo servidor passaram a expirar com timeout.

// ❌ nextTick recursivo: starvation — nenhum I/O executa enquanto a fila não terminar
function processarFila(items) {
  if (!items.length) return;
  processar(items.shift());
  process.nextTick(() => processarFila(items)); // enfileira mais um nextTick
}
// 10.000 itens = 10.000 nextTicks antes que qualquer request HTTP seja atendida
 
// ✅ setImmediate: fatia o trabalho, libera o loop entre cada item
function processarFilaSegura(items) {
  if (!items.length) return;
  processar(items.shift());
  setImmediate(() => processarFilaSegura(items)); // fase check — libera poll entre fatias
}
// O loop pode atender I/O entre cada item da fila

Diferença de impacto: nextTick tem prioridade absoluta sobre fases do loop; setImmediate roda na fase check, depois de I/O, cedendo espaço para requests.

Armadilhas comuns

nextTick recursivo causa starvation — o processo parece vivo mas está travado

A nextTickQueue é drenada completamente antes de qualquer fase avançar. Um nextTick que agenda outro nextTick cria um loop infinito na fila — nenhum timer, I/O ou setImmediate executa.

// ❌ Starvation em produção
function loop() { process.nextTick(loop); }
loop();
// O mesmo vale para queueMicrotask recursivo — mesma armadilha, fila diferente

Use setImmediate para trabalho fatiado — ele cede o loop entre fatias.

Erros em nextTick e queueMicrotask não têm .catch() — vão para uncaughtException

Diferente de Promise.then, onde um erro vira unhandledRejection (capturável com .catch()), erros em nextTick e queueMicrotask disparam uncaughtException diretamente — sem mecanismo de captura por chamada.

// ❌ Sem escape: derruba o processo em Node.js 15+
process.nextTick(() => { throw new Error('falha'); });
 
// ✅ Promise.then permite captura local
Promise.resolve().then(() => { throw new Error('falha'); })
  .catch(err => console.error('capturado:', err.message));

Em Node.js 15+, unhandledRejection também derruba o processo por padrão. Sempre trate erros.

setImmediate não é "imediato" no sentido de microtask — roda por último

setImmediate é uma macrotask da fase check — executa depois de toda drenagem de microtasks. Quem espera que seja “mais imediato” que Promise.then se surpreende.

setImmediate(() => console.log('A: setImmediate'));
Promise.resolve().then(() => console.log('B: Promise'));
process.nextTick(() => console.log('C: nextTick'));
// Saída: C → B → A  (nextTick > Promise > setImmediate)

Para executar antes de qualquer fase do loop, use nextTick. Para executar logo após I/O, use setImmediate dentro de um callback de I/O.

Em entrevista

Frase pronta (inglês)

“Node has three microtask APIs with a strict priority order: process.nextTick runs first — it has its own queue that’s drained before any other microtask. Then queueMicrotask and Promise.then are interleaved in the standard microtask queue. Microtasks run between every event loop phase, so they’re higher priority than any timer or I/O callback. The danger of process.nextTick is recursion — a callback that schedules another nextTick will starve the event loop, since the queue is drained completely before phases advance.”

Vocabulário técnico

PortuguêsInglês
Fila de microtarefasMicrotask queue
Fila de nextTicknextTick queue / nextTickQueue
PrioridadePriority
Inanição da filaQueue starvation
API específica de NodeNode-specific API
Drenagem da filaQueue draining
Iteração do event loopEvent loop tick / iteration
Avanço de fasePhase transition
Exceção não capturadaUncaught exception / uncaughtException
Rejeição não tratadaUnhandled rejection / unhandledRejection

Perguntas frequentes em entrevista

“Qual a diferença entre process.nextTick e setImmediate?” nextTick roda antes de qualquer fase do event loop avançar — é uma microtask com prioridade máxima. setImmediate roda na fase check, que é uma macrotask executada depois de I/O. Em termos de ordem: nextTick → microtasks → I/O → setImmediate → timers (próxima iteração).

“Quando devo usar queueMicrotask em vez de Promise.resolve().then()?” Quando não há uma Promise natural no contexto e você quer agendar uma microtask sem criar um wrapper de Promise desnecessário. queueMicrotask é mais explícito na intenção e tem overhead ligeiramente menor. Semanticamente são equivalentes em termos de prioridade e ordem. Prefira queueMicrotask em código que precisa ser portável entre Node.js e browser.

“O que acontece se eu lançar um erro dentro de process.nextTick?” O erro propaga como uncaughtException — não existe .catch() para nextTick. O processo pode ser derrubado se não houver um handler process.on('uncaughtException', ...). Diferente de Promise, onde o erro vira unhandledRejection e pode ser capturado com .catch().

“Um nextTick registrado dentro de uma Promise roda antes ou depois das outras Promises na fila?” Antes. A nextTickQueue é verificada após cada microtask individual — não apenas ao final de toda a fila. Um nextTick registrado dentro de .then() drena antes das demais Promises já enfileiradas.


O que vem a seguir

Você agora conhece a fila de maior prioridade do event loop. O próximo nível é entender a camada de macrotasks — os timers e setImmediate que executam depois das microtasks, e os detalhes que determinam sua ordem e precisão: jitter de setTimeout, coalescing de setInterval, e a distinção entre a fase check e a fase timers.

A nota 06 - Macrotasks e timers - setTimeout, setInterval, setImmediate cobre isso com exemplos de produção: por que setInterval pode acumular drift em carga e como setImmediate se posiciona estrategicamente para trabalho pós-I/O.

Veja também

Fontes