Macrotasks e timers: setTimeout, setInterval, setImmediate

TL;DR

setTimeout e setInterval rodam na fase timers. setImmediate roda na fase check. Em contexto de I/O, setImmediate é determinístico — sempre executa antes de setTimeout(fn, 0), porque a fase check vem antes da próxima fase timers. Fora de I/O, a ordem entre os dois é imprevisível e depende do tempo de inicialização do processo. Para sleep idiomático em código async, use node:timers/promises.

Por que setImmediate às vezes dispara antes de setTimeout(fn, 0) — e às vezes não?

Você tem código com setImmediate e setTimeout(fn, 0) lado a lado. Em produção, a ordem varia entre execuções sem motivo aparente. Em entrevistas, perguntam exatamente isso. A resposta está em quais fases do event loop hospedam cada API — e se você está ou não dentro de um callback de I/O quando as agenda.

O que é

Node.js expõe três APIs para agendar callbacks fora do fluxo síncrono atual — sem ser microtasks. São as três macrotask APIs nativas do runtime:

setTimeout(callback, ms[, ...args])

Agenda callback para execução pelo menos ms milissegundos no futuro, na fase timers do event loop. O delay é um mínimo, não um máximo — o callback pode disparar muito depois se outras fases estiverem ocupadas.

Detalhes importantes da implementação:

  • Se ms for menor que 1, maior que 2.147.483.647 (≈ 24,8 dias) ou NaN, o valor é normalizado para 1ms. O Node.js (via libuv) nunca usa delay zero — setTimeout(fn, 0) efetivamente se torna setTimeout(fn, 1).
  • Retorna um objeto Timeout, usado para cancelar via clearTimeout(timeout).
  • Argumentos adicionais após ms são repassados ao callback, evitando closures desnecessárias.
const timeout = setTimeout((nome) => {
  console.log(`Olá, ${nome}`);
}, 500, 'Alice');
 
// Para cancelar antes de disparar:
clearTimeout(timeout);

setInterval(callback, ms[, ...args])

Agenda callback para execução repetida a cada ms milissegundos, indefinidamente, até que clearInterval seja chamado. Mesmas regras de normalização de delay que setTimeout.

Retorna um objeto Timeout — a mesma classe de setTimeout, reutilizável com clearInterval e clearTimeout indistintamente.

let contador = 0;
const interval = setInterval(() => {
  contador++;
  console.log(`tick ${contador}`);
  if (contador >= 5) clearInterval(interval);
}, 200);

setInterval não é "cron"

setInterval não garante que os callbacks são executados exatamente a cada ms. Se o callback demora mais do que o intervalo para completar, ou se outras fases do event loop atrasam a execução, os disparos se acumulam e há drift progressivo. Ver seção Armadilhas.

setImmediate(callback[, ...args])

Agenda callback para execução na fase check — a fase imediatamente após a fase poll na mesma iteração do event loop. Retorna um objeto Immediate, cancelável via clearImmediate.

setImmediate(() => {
  console.log('fase check desta iteração');
});

setImmediate foi projetado especificamente para executar código depois que os callbacks de I/O da iteração atual terminaram, mas antes de qualquer timer da próxima iteração. Essa semântica é precisa dentro de callbacks de I/O; fora deles, a relação com setTimeout(fn, 0) é não determinística.

timers/promises — versões Promise-based (Node.js 15+)

O módulo node:timers/promises exporta versões awaitable das três APIs, eliminando a necessidade de wrappers manuais:

import {
  setTimeout,
  setImmediate,
  setInterval,
} from 'node:timers/promises';
 
// Sleep idiomático em código async
await setTimeout(1000);
 
// setTimeout com valor de retorno
const resultado = await setTimeout(500, 'pronto');
console.log(resultado); // 'pronto'
 
// setImmediate awaitable
await setImmediate();
 
// setInterval como async iterator
for await (const _ of setInterval(100)) {
  console.log('a cada 100ms');
  // use break para encerrar
}

Todas as funções do módulo aceitam um objeto options com:

  • ref: false — o timer não mantém o processo vivo (equivalente a chamar .unref())
  • signal — um AbortSignal para cancelamento cooperativo

Tabela comparativa

APIFase do event loopRepete?RetornoCancelamento
setTimeout(fn, ms)timersNãoTimeoutclearTimeout
setInterval(fn, ms)timers (repetido)SimTimeoutclearInterval
setImmediate(fn)checkNãoImmediateclearImmediate
timers/promises setTimeouttimersNãoPromise<T>AbortSignal
timers/promises setImmediatecheckNãoPromise<T>AbortSignal
timers/promises setIntervaltimers (repetido)Sim (async iterator)AsyncIteratorAbortSignal / break

Diagrama — posição das macrotask APIs no event loop

flowchart LR
    subgraph LOOP["Event Loop — uma iteração"]
        T["1. TIMERS\nsetTimeout · setInterval"]
        PC["2. PENDING\nCALLBACKS"]
        IP["3. IDLE /\nPREPARE"]
        POLL["4. POLL\nI/O callbacks"]
        CH["5. CHECK\nsetImmediate"]
        CL["6. CLOSE\nCALLBACKS"]
        T --> PC --> IP --> POLL --> CH --> CL --> T
    end

    note1["📌 Dentro de I/O:\ncheck vem ANTES de timers\n→ setImmediate ganha"]
    note2["📌 Fora de I/O:\nordem depende do startup\n→ não determinístico"]

    POLL -.-> note1
    T -.-> note2

Regra de ouro: setImmediate é sempre determinístico dentro de callbacks de I/O (fase poll → check → timers na próxima iteração). Fora desse contexto, não assuma ordem.

Por que importa

A distinção de fases entre setTimeout/setInterval (fase timers) e setImmediate (fase check) determina a ordem de execução em cenários que misturam I/O e timers. Esse detalhe aparece com frequência em entrevistas sênior e em debugging de bugs sutis de ordem de execução.

Mais concretamente:

Dentro de um callback de I/O, setImmediate é determinístico: sempre executa antes de qualquer setTimeout(fn, 0). O motivo é geométrico — a fase poll (onde o callback de I/O executou) é seguida diretamente pela fase check. A fase timers só é verificada na próxima iteração. Portanto, agendar via setImmediate dentro de I/O é a forma idiomática de dizer “execute no próximo slot disponível após este callback” com garantia de ordem.

Fora de I/O, a relação é imprevisível. O event loop inicia na fase timers. Se o processo levou mais de 1ms para inicializar (o mínimo normalizado de setTimeout(fn, 0)), o timer já expirou e dispara primeiro. Se levou menos, a fase timers não encontra nada, avança até check e o setImmediate dispara primeiro. Essa variabilidade de 1–2ms de startup torna o resultado não determinístico entre execuções.

Compreender essa diferença é o que separa “eu sei que existe setImmediate” de “eu sei quando usá-lo corretamente”.

Como funciona

Exemplo 1 — setTimeout(fn, 0): o delay mínimo de 1ms

console.log('início');
 
setTimeout(() => {
  console.log('timer disparou');
}, 0); // delay 0 é normalizado para 1ms internamente
 
console.log('fim do síncrono');
 
// Saída:
// início
// fim do síncrono
// timer disparou

O callback nunca executa sincronamente — mesmo com delay 0. O código síncrono atual (call stack) sempre termina antes de qualquer fase do event loop processar seus callbacks. E o delay efetivo é pelo menos 1ms, não zero.

Exemplo 2 — Em contexto de I/O: setImmediate sempre primeiro

const fs = require('node:fs');
 
fs.readFile(__filename, () => {
  // Estamos na fase POLL, executando um callback de I/O
  // O loop vai para CHECK antes de voltar para TIMERS
 
  setTimeout(() => {
    console.log('timeout'); // fase TIMERS — próxima iteração
  }, 0);
 
  setImmediate(() => {
    console.log('immediate'); // fase CHECK — ainda nesta iteração
  });
});
 
// Saída sempre garantida:
// immediate
// timeout

O fluxo detalhado:

  1. fs.readFile completa → callback entra na fila da fase poll
  2. Fase poll executa o callback → setTimeout e setImmediate são agendados
  3. Fase poll termina → event loop avança para check
  4. Fase check executa setImmediate → imprime immediate
  5. Iteração termina → nova iteração começa → fase timers → imprime timeout

Essa ordem é garantida e determinística quando ambos são agendados de dentro de um callback de I/O.

Exemplo 3 — Fora de I/O: ordem não determinística

// Top-level: fora de qualquer callback de I/O
setTimeout(() => {
  console.log('timeout');    // pode ser primeiro OU segundo
}, 0);
 
setImmediate(() => {
  console.log('immediate');  // pode ser primeiro OU segundo
});
 
// Possível saída A (processo demorou > 1ms para inicializar):
// timeout
// immediate
 
// Possível saída B (processo demorou < 1ms para inicializar):
// immediate
// timeout

Nunca assuma uma ordem específica neste cenário. O resultado varia entre execuções e entre ambientes (máquina de desenvolvimento vs. container de CI vs. produção).

Exemplo 4 — setInterval com handler lento (drift e reentrância)

// Cenário problemático: handler demora mais que o intervalo
let execucoes = 0;
 
const interval = setInterval(() => {
  execucoes++;
  console.log(`[${Date.now()}] execução ${execucoes} início`);
 
  // Simula trabalho síncrono que demora 200ms
  const deadline = Date.now() + 200;
  while (Date.now() < deadline) { /* busy wait — apenas para ilustração */ }
 
  console.log(`[${Date.now()}] execução ${execucoes} fim`);
 
  if (execucoes >= 3) clearInterval(interval);
}, 100); // intervalo de 100ms, mas handler leva 200ms
 
// Comportamento real:
// - A execução 1 começa ~100ms, termina ~300ms
// - A execução 2 começa ~300ms (não ~200ms como esperado)
// - O intervalo NÃO dispara callbacks simultâneos — ele aguarda a fase timers
// - Há drift acumulado: cada execução começa mais tarde do que o agendado

Node.js não executa callbacks de setInterval em paralelo — a thread JS é single-threaded. Se o handler demora mais que o intervalo, a próxima execução simplesmente começa na próxima oportunidade em que a fase timers é alcançada. Não há “callbacks empilhados” executando simultaneamente, mas há drift crescente.

Exemplo 5 — Sleep idiomático com timers/promises

import { setTimeout as sleep } from 'node:timers/promises';
 
async function processar(items) {
  for (const item of items) {
    await processarItem(item);
    await sleep(100); // pausa de 100ms entre cada item — sem callbacks manuais
  }
}

Comparado ao padrão de callback antigo:

// Padrão antigo — difícil de compor, propenso a erros
function aguardar(ms, callback) {
  setTimeout(callback, ms);
}
 
// Padrão moderno — awaitable, cancelável, integrável com AbortSignal
import { setTimeout as sleep } from 'node:timers/promises';
 
const ac = new AbortController();
const { signal } = ac;
 
try {
  await sleep(5000, undefined, { signal }); // sleep de 5s cancelável
} catch (err) {
  if (err.name === 'AbortError') {
    console.log('sleep cancelado antes de completar');
  }
}
 
// Para cancelar externamente:
ac.abort();

Exemplo 6 — Loop com setTimeout recursivo vs. setInterval (anti-drift)

// setInterval com drift — inadequado para polling de precisão
setInterval(() => {
  realizarTarefa(); // se demorar, o próximo disparo se afasta do schedule
}, 1000);
 
// setTimeout recursivo com cálculo de drift — mais robusto
function agendarProximaExecucao() {
  const inicio = Date.now();
 
  realizarTarefa();
 
  const duracao = Date.now() - inicio;
  const proximo = Math.max(0, 1000 - duracao); // compensa o tempo gasto
 
  setTimeout(agendarProximaExecucao, proximo);
}
 
agendarProximaExecucao();
 
// Versão async com timers/promises — mais limpa e cancelável
import { setTimeout as sleep } from 'node:timers/promises';
 
async function loop(signal) {
  while (!signal.aborted) {
    const inicio = Date.now();
    await realizarTarefaAsync();
    const duracao = Date.now() - inicio;
    await sleep(Math.max(0, 1000 - duracao), undefined, { signal });
  }
}

Na prática

Preferir setImmediate sobre setTimeout(fn, 0) dentro de I/O

Quando a intenção é “execute este código na próxima oportunidade, após este callback de I/O terminar”, setImmediate é a escolha correta — e a única com garantia de ordem:

// Dentro de qualquer callback de I/O:
fs.readFile(caminho, (err, dados) => {
  if (err) throw err;
 
  // BOM: explícito e determinístico em contexto de I/O
  setImmediate(() => {
    processarDados(dados);
  });
 
  // AMBÍGUO: funciona na prática mas sem garantia de ordem
  setTimeout(() => {
    processarDados(dados);
  }, 0);
});

Preferir setTimeout recursivo sobre setInterval para trabalho periódico

setInterval é raramente a escolha correta em produção para tarefas que envolvem I/O ou lógica não trivial. O padrão recomendado em bibliotecas de produção é um setTimeout recursivo que calcula o próximo delay com base no tempo gasto pela execução anterior — conforme mostrado no Exemplo 6. Isso evita drift acumulado e garante um intervalo mínimo entre o fim de uma execução e o início da próxima.

Usar .unref() para timers que não devem manter o processo vivo

// Timer de keepalive/heartbeat em um processo CLI:
// Não deve impedir o processo de encerrar quando o trabalho principal termina
const heartbeat = setInterval(() => {
  enviarHeartbeat();
}, 30_000);
 
heartbeat.unref(); // o processo pode encerrar mesmo com o interval ativo
 
// Com timers/promises, use ref: false:
import { setInterval } from 'node:timers/promises';
 
for await (const _ of setInterval(30_000, null, { ref: false })) {
  enviarHeartbeat();
}

Cancelamento cooperativo com AbortSignal

import { setTimeout as sleep, setInterval } from 'node:timers/promises';
 
const ac = new AbortController();
 
// Cancelar todos os timers de uma vez:
async function tarefa() {
  try {
    await sleep(10_000, undefined, { signal: ac.signal });
    for await (const _ of setInterval(1_000, undefined, { signal: ac.signal })) {
      await realizarPasso();
    }
  } catch (err) {
    if (err.name === 'AbortError') return; // cancelado graciosamente
    throw err;
  }
}
 
// Em outro ponto do código, para cancelar:
ac.abort();

Casos práticos

Cenário 1 — setInterval com handler assíncrono lento: rajadas em produção

Um poller de métricas usava setInterval(buscar, 200). Em horas de pico, buscar levava 500ms. Em vez de um poll a cada 200ms, o sistema entrava em rajadas: 3-4 execuções disparavam em sucessão rápida, depois silêncio de ~1 segundo.

// ❌ setInterval não espera o handler: 500ms > 200ms → acúmulo de callbacks
setInterval(async () => {
  await buscarMetricas(); // ~500ms em pico
}, 200);
// t=0: execução 1 começa; t=200: execução 2 agendada; t=400: execução 3 agendada
// t=500: execução 1 termina → execuções 2 e 3 disparam imediatamente em sequência
 
// ✅ setTimeout recursivo: intervalo medido a partir do FIM do handler
async function pollComIntervalo() {
  while (true) {
    const inicio = Date.now();
    await buscarMetricas();
    const elapsed = Date.now() - inicio;
    await setTimeout(Math.max(0, 200 - elapsed), undefined, { signal });
  }
}

Cenário 2 — Memory leak por timer com closure nunca limpa

Uma API de websocket criava um setInterval para heartbeat por conexão e nunca chamava clearInterval no fechamento. Com 10.000 conexões simultâneas, o heap crescia indefinidamente.

// ❌ closure captura 'ws' e 'dados' — GC nunca libera enquanto o timer existir
function iniciarHeartbeat(ws, dados) {
  const timer = setInterval(() => {
    ws.send(JSON.stringify(dados)); // closure segura referências
  }, 30_000);
  // Se ws.close() não chama clearInterval, timer (e closure) vivem para sempre
}
 
// ✅ limpar o timer no evento de fechamento da conexão
function iniciarHeartbeat(ws, dados) {
  const timer = setInterval(() => ws.send(JSON.stringify(dados)), 30_000);
  ws.on('close', () => clearInterval(timer)); // libera closure → GC pode coletar
}

Armadilhas comuns

setInterval reentrante acumula execuções quando o handler é mais lento que o intervalo

setInterval não verifica se a execução anterior terminou — ele enfileira o próximo callback no timer independentemente. Se o handler leva mais do que ms, os callbacks se acumulam na fase timers e disparam em rajadas assim que o handler atual libera a thread.

Use setTimeout recursivo ou timers/promises setInterval com for await — ambos aguardam o handler completar antes de agendar a próxima execução.

setTimeout(fn, 0) não é síncrono — o delay mínimo efetivo é 1ms, não zero

O Node.js normaliza delays menores que 1ms para 1ms internamente (comportamento do libuv). O callback nunca roda na mesma iteração que o código síncrono que o agendou.

let flag = false;
setTimeout(() => { flag = true; }, 0);
console.log(flag); // sempre false — timer ainda não disparou
// Para execução assíncrona imediata, use queueMicrotask ou Promise.resolve().then()

Timer com closure pesada nunca limpa → memory leak detectável pelo heap

Timers mantêm referências a tudo que a closure captura. Um setInterval sem clearInterval no teardown impede o GC de coletar qualquer objeto referenciado pela closure — incluindo objetos grandes como conexões, buffers ou dados de sessão.

// ❌ Leak: 'dados' nunca coletado enquanto o timer viver
const timer = setInterval(() => processa(dados), 1000);
// ✅ Sempre limpar em teardown/close
ws.on('close', () => clearInterval(timer));

setImmediate vs setTimeout(fn, 0) fora de callbacks de I/O — ordem não garantida

Dentro de callbacks de I/O, setImmediate sempre ganha (fase check vem antes de timers na iteração atual). Fora desse contexto, a ordem depende de quanto tempo o processo levou para inicializar — pode variar entre execuções do mesmo código.

// ❌ Frágil em top-level: pode sair em qualquer ordem
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
 
// ✅ Determinístico: agende dentro de callback de I/O
fs.readFile(path, () => {
  setImmediate(() => console.log('sempre primeiro'));
  setTimeout(() => console.log('sempre segundo'), 0);
});

Em entrevista

Frase pronta (inglês)

setTimeout and setInterval run in the timers phase; setImmediate runs in the check phase. The interesting detail: inside an I/O callback, setImmediate always runs before setTimeout(fn, 0) — it’s deterministic, because the loop is in the poll phase and the very next phase is check. The timers phase only runs in the next iteration. Outside I/O, the order is non-deterministic because it depends on whether the 1ms minimum delay has elapsed by the time the first iteration starts. For modern async code, prefer setImmediate when you need ‘run after this I/O callback’, and use node:timers/promises for awaitable sleeps with AbortSignal support.”

Use essa frase para responder:

  • “What’s the difference between setImmediate and setTimeout(fn, 0)?”
  • “When would you use setImmediate over setTimeout?”
  • “How do timers work in Node.js?”
  • “Why does the order of setImmediate vs setTimeout change depending on context?”

Vocabulário de entrevista

PortuguêsInglêsContexto
Fase de timerstimers phasefase do event loop onde setTimeout/setInterval disparam
Fase checkcheck phasefase onde setImmediate dispara, logo após poll
Deriva do timertimer driftacúmulo de atraso entre disparos esperados e reais
Reentrânciareentrancyquando um handler é agendado novamente antes do anterior terminar
Determinísticodeterministiccomportamento previsível independente de timing externo
Threshold do timertimer thresholddelay mínimo que deve passar para o timer disparar
Iteração do looploop iteration / tickuma passagem completa pelas seis fases
Cancelamento cooperativocooperative cancellationcancelamento via AbortSignal acordado entre produtor e consumidor

Perguntas de follow-up comuns

“O que acontece se eu passar delay 0 para setTimeout?” O delay é normalizado internamente para 1ms pelo libuv. O callback nunca dispara sincronamente e sempre aguarda pelo menos 1ms — além do tempo que outras fases levarem para terminar antes de a fase timers ser alcançada.

“Como evitar drift em um polling periódico?” Usar setTimeout recursivo com cálculo: setTimeout(fn, Math.max(0, intervalo - tempoGasto)). Ou usar timers/promises setInterval com for await, que aguarda cada iteração completar antes de iniciar a próxima.

“Quando setImmediate é garantidamente determinístico?” Apenas quando agendado de dentro de um callback de I/O (fase poll). Nesse contexto, setImmediate sempre precede qualquer setTimeout(fn, 0) agendado no mesmo callback.

“Por que setInterval raramente é correto em produção?” Porque ele não aguarda o handler completar antes de agendar o próximo disparo. Com operações assíncronas, o intervalo entre o fim de uma execução e o início da próxima pode ser zero — ou negativo em termos de intent. O padrão com setTimeout recursivo ou for await com timers/promises é mais seguro.

O que vem a seguir

Com timers e macrotasks cobertas, o próximo passo é entender o que acontece dentro da fase poll — como o Node.js coleta eventos de I/O do sistema operacional e quais operações usam o thread pool de libuv versus I/O assíncrono nativo do kernel. Esse é o mecanismo que torna o Node.js eficiente para I/O concorrente.

A nota 07 - I-O assíncrono - kernel vs thread pool explica quando uma operação como fs.readFile usa epoll (sem thread) versus getaddrinfo (com thread do pool) — e por que essa distinção importa para diagnóstico de performance.

Veja também

Fontes

  • Node.js — Timers — documentação oficial de setTimeout, setInterval, setImmediate e o módulo node:timers/promises
  • Node.js — Timers in Node.js (guia) — explica delay mínimo de 1ms, normalização, e a distinção de fases entre timers e setImmediate
  • libuv — Timers — implementação interna dos timers no libuv, incluindo o cálculo do timeout da fase poll