Node.js Essencial: Event loop, timers e fluxo assíncrono na prática

Capítulo 2

Tempo estimado de leitura: 9 minutos

+ Exercício

O que é o event loop (na prática)

No Node.js, o event loop é o mecanismo que coordena quando funções assíncronas “voltam” para executar seus callbacks, resoluções de Promises e timers. Ele organiza o trabalho em filas e percorre fases em ciclos, executando o que estiver pronto em cada etapa. Entender essas fases ajuda a prever a ordem de execução, evitar efeitos colaterais e depurar problemas como race conditions (condições de corrida).

Fases do event loop e onde cada API entra

O loop percorre fases. Em cada fase, ele processa uma fila de callbacks. Entre uma fase e outra, o Node também drena filas de microtarefas (microtasks).

Fase (visão prática)O que roda aquiExemplos
timersCallbacks de timers cujo tempo expirousetTimeout, setInterval
pending callbacksCallbacks de algumas operações do sistema (adiadas)Alguns erros/IO internos
idle/prepareUso interno(não usamos diretamente)
pollEspera e processa eventos de I/O; pode executar callbacks de I/Ofs, net, http, etc. (callbacks de I/O)
checkExecuta callbacks agendados por setImmediatesetImmediate
close callbacksCallbacks de fechamentosocket.on('close')

Microtasks vs Macrotasks

Além das fases acima (que lidam com “tarefas” maiores, frequentemente chamadas de macrotasks), existe a fila de microtasks, que tem prioridade e é drenada em pontos específicos do ciclo.

  • Microtasks: Promise.then/catch/finally, queueMicrotask (e, no Node, há também a fila especial de process.nextTick que roda antes das microtasks de Promise).
  • Macrotasks: timers (setTimeout/setInterval), setImmediate, callbacks de I/O, etc.

Regra prática: microtasks rodam “o quanto antes”, antes de o event loop avançar para a próxima fase, e podem “furar a fila” de timers e I/O se você encadear muitas.

Onde callbacks, Promises e async/await se encaixam

Callbacks

Um callback de I/O (por exemplo, leitura de arquivo) é enfileirado para execução em uma fase apropriada (geralmente poll). Um callback de timer entra na fase timers. Um callback de setImmediate entra na fase check.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Promises

Quando uma Promise resolve, seus handlers (.then/.catch/.finally) entram na fila de microtasks. Isso significa que, ao final do trecho síncrono atual, o Node tende a executar esses handlers antes de continuar para a próxima fase do loop.

async/await

async/await é sintaxe sobre Promises. Um await “pausa” a função async e agenda a continuação como microtask quando a Promise aguardada resolve. Por isso, await costuma ter comportamento semelhante a Promise.then em termos de ordem.

Exemplos reproduzíveis: ordem de execução

1) Síncrono vs microtasks (Promise) vs nextTick

Crie um arquivo order-1.js e execute com node order-1.js:

console.log('A: sync start');

process.nextTick(() => console.log('B: nextTick'));

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

queueMicrotask(() => console.log('D: queueMicrotask'));

console.log('E: sync end');

Ordem típica:

  • A e E (síncrono)
  • B (process.nextTick tem prioridade no Node)
  • C e D (microtasks de Promise/queueMicrotask)

Efeito colateral importante: abusar de process.nextTick pode “fomear” o event loop, atrasando timers e I/O.

2) setTimeout(0) vs setImmediate

Crie order-2.js:

setTimeout(() => console.log('timeout 0'), 0);
setImmediate(() => console.log('immediate'));

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

Saída comum:

  • sync
  • promise (microtask)
  • Depois, timeout 0 e immediate podem variar dependendo do contexto, mas frequentemente timeout 0 aparece antes quando agendados no topo do script.

Agora observe um caso mais determinístico: dentro de um callback de I/O, setImmediate tende a rodar antes de setTimeout(0). Crie order-2b.js:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout 0 inside I/O'), 0);
  setImmediate(() => console.log('immediate inside I/O'));
});

Em geral, você verá immediate inside I/O antes de timeout 0 inside I/O, porque após o poll o loop segue para check (onde roda setImmediate) antes de voltar para timers.

3) Microtasks podem atrasar timers (fome do loop)

Crie order-3.js:

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

let count = 0;
function spinMicrotasks() {
  if (count++ >= 200000) return;
  queueMicrotask(spinMicrotasks);
}
spinMicrotasks();

console.log('scheduled');

Mesmo com setTimeout(..., 0), o timer só dispara depois que a fila de microtasks for drenada. Isso demonstra como microtasks em excesso podem causar latência e “travamentos” lógicos.

Passo a passo: como prever a ordem de execução

  1. Marque o que é síncrono: tudo fora de callbacks/Promises roda imediatamente.

  2. Identifique microtasks: then/await/queueMicrotask (e process.nextTick no Node). Elas rodam antes de avançar para a próxima fase.

  3. Identifique macrotasks: timers, I/O callbacks, setImmediate.

  4. Localize o contexto: setImmediate vs setTimeout muda dependendo se você está no topo do script ou dentro de I/O.

  5. Procure loops de microtasks: recursão via then/queueMicrotask/nextTick pode impedir timers e I/O de progredirem.

Depurando concorrência lógica (race conditions)

Race condition em Node geralmente não é “thread race” no JavaScript, mas sim ordem inesperada de eventos assíncronos que competem para ler/alterar o mesmo estado.

Exemplo reproduzível: estado compartilhado e ordem não garantida

let balance = 100;

async function withdraw(amount) {
  // Simula latência variável
  await new Promise(r => setTimeout(r, Math.random() * 20));
  if (balance >= amount) {
    balance -= amount;
  }
}

async function run() {
  await Promise.all([
    withdraw(80),
    withdraw(80)
  ]);
  console.log({ balance });
}

run();

Mesmo com um único thread, as duas operações “intercalam” por causa do await. Ambas podem passar no if antes de uma delas subtrair, levando a um resultado incorreto.

Técnicas práticas de depuração

  • Log com contexto: inclua um requestId/operationId e timestamps. Evite logs genéricos.
  • Trace de async: execute com node --trace-uncaught e, quando aplicável, node --trace-warnings. Para Promises não tratadas: node --unhandled-rejections=strict.
  • Inspeção: use node --inspect e breakpoints dentro de callbacks/continuações após await para observar reentrância (função retomando em outro “turno” do loop).
  • Reproduza determinismo: substitua latência aleatória por atrasos controlados (ex.: setTimeout fixo) para forçar a ordem e confirmar hipóteses.
  • Proteja invariantes: identifique variáveis globais/compartilhadas e pontos onde são lidas e escritas em momentos diferentes (antes/depois de await).

Correção com exclusão mútua (mutex) em nível de aplicação

Uma forma simples é serializar seções críticas. Exemplo de mutex minimalista:

class Mutex {
  constructor() {
    this._locked = false;
    this._waiters = [];
  }

  lock() {
    return new Promise(resolve => {
      if (!this._locked) {
        this._locked = true;
        resolve(this._unlock.bind(this));
      } else {
        this._waiters.push(resolve);
      }
    });
  }

  _unlock() {
    const next = this._waiters.shift();
    if (next) next(this._unlock.bind(this));
    else this._locked = false;
  }
}

const mutex = new Mutex();
let balance = 100;

async function withdraw(amount) {
  await new Promise(r => setTimeout(r, Math.random() * 20));
  const unlock = await mutex.lock();
  try {
    if (balance >= amount) balance -= amount;
  } finally {
    unlock();
  }
}

(async () => {
  await Promise.all([withdraw(80), withdraw(80)]);
  console.log({ balance });
})();

Isso torna a seção crítica (checagem + atualização) atômica do ponto de vista lógico.

Estruturando código assíncrono legível

Preferir async/await com funções pequenas

Evite funções longas com múltiplos await misturados a lógica de negócio e tratamento de erro. Extraia etapas para funções nomeadas.

async function fetchUser(id) { /* ... */ }
async function fetchOrders(userId) { /* ... */ }

async function getUserDashboard(userId) {
  const user = await fetchUser(userId);
  const orders = await fetchOrders(user.id);
  return { user, orders };
}

Tratamento de erro consistente

Em Promises paralelas, decida se você quer falhar rápido (Promise.all) ou coletar resultados (Promise.allSettled).

const results = await Promise.allSettled(tasks);
const errors = results.filter(r => r.status === 'rejected');

Evitar “await em loop” quando você quer paralelismo

Se as operações são independentes, dispare primeiro e aguarde depois:

// Sequencial (mais lento)
for (const id of ids) {
  await doWork(id);
}

// Paralelo (cuidado com volume)
await Promise.all(ids.map(id => doWork(id)));

Quando o volume é grande, paralelizar tudo pode causar estouro de recursos; nesse caso, aplique limitação de concorrência (próxima seção).

Padrões para controle de concorrência (limitação de paralelismo)

1) Worker pool simples (limite N)

Este padrão processa uma lista com no máximo limit promessas ativas ao mesmo tempo.

async function mapLimit(items, limit, mapper) {
  const results = new Array(items.length);
  let index = 0;

  async function worker() {
    while (true) {
      const i = index++;
      if (i >= items.length) return;
      results[i] = await mapper(items[i], i);
    }
  }

  const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
  await Promise.all(workers);
  return results;
}

// Uso
const pages = await mapLimit(urls, 5, async (url) => {
  const res = await fetch(url);
  return res.text();
});

2) Semáforo (controle fino por recurso)

Útil quando você quer limitar acesso a um recurso específico (ex.: chamadas a um serviço externo).

class Semaphore {
  constructor(max) {
    this.max = max;
    this.current = 0;
    this.queue = [];
  }

  acquire() {
    return new Promise(resolve => {
      const tryAcquire = () => {
        if (this.current < this.max) {
          this.current++;
          resolve(() => {
            this.current--;
            const next = this.queue.shift();
            if (next) next();
          });
        } else {
          this.queue.push(tryAcquire);
        }
      };
      tryAcquire();
    });
  }
}

const sem = new Semaphore(3);

async function limitedCall(fn) {
  const release = await sem.acquire();
  try {
    return await fn();
  } finally {
    release();
  }
}

3) Fila com backpressure (quando há produtor/consumidor)

Se você produz tarefas mais rápido do que consome, implemente uma fila e pare de produzir quando atingir um limite (backpressure). Mesmo sem streams, a ideia é: não acumular Promises infinitamente.

async function runQueue(producer, consumer, { highWaterMark = 100, concurrency = 5 } = {}) {
  const queue = [];
  let producing = true;

  async function fill() {
    while (producing && queue.length < highWaterMark) {
      const item = await producer();
      if (item === null) {
        producing = false;
        break;
      }
      queue.push(item);
    }
  }

  async function worker() {
    while (producing || queue.length) {
      if (!queue.length) {
        await fill();
        continue;
      }
      const item = queue.shift();
      await consumer(item);
      await fill();
    }
  }

  await fill();
  await Promise.all(Array.from({ length: concurrency }, worker));
}

Boas práticas para evitar callback hell e encadeamentos confusos

  • Padronize em Promises + async/await: quando usar APIs baseadas em callback, prefira wrappers que retornem Promise (ou versões nativas que já retornam Promise).
  • Não misture estilos no mesmo fluxo: evite usar callback dentro de uma função async para controlar sequência; converta para Promise.
  • Nomeie etapas: em vez de then aninhado, crie funções pequenas e reutilizáveis.
  • Evite “Promise chain” gigante: se o fluxo tem ramificações, try/catch com await costuma ficar mais legível.
  • Seção crítica não pode atravessar await: se você precisa manter invariantes, faça a leitura+escrita sem “pontos de suspensão” ou use mutex/semaphore.
  • Não bloqueie com microtasks: evite recursão infinita via process.nextTick/queueMicrotask. Se precisar ceder o controle, use uma macrotask (ex.: setImmediate) para permitir que I/O e timers progridam.
  • Use paralelismo com limite: Promise.all em listas grandes pode saturar recursos; prefira mapLimit ou semáforo.

Agora responda o exercício sobre o conteúdo:

Ao analisar a ordem de execução no Node.js, qual afirmação descreve corretamente a prioridade entre process.nextTick, microtasks de Promise e as fases do event loop (timers/I/O)?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

No Node.js, process.nextTick roda antes das microtasks de Promise. Depois, a fila de microtasks (Promises/queueMicrotask) é drenada antes de o loop seguir para a próxima fase, o que pode adiar timers e callbacks de I/O.

Próximo capitúlo

Node.js Essencial: Módulos, ESM vs CommonJS e organização de dependências

Arrow Right Icon
Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
13%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.