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 aqui | Exemplos |
|---|---|---|
| timers | Callbacks de timers cujo tempo expirou | setTimeout, setInterval |
| pending callbacks | Callbacks de algumas operações do sistema (adiadas) | Alguns erros/IO internos |
| idle/prepare | Uso interno | (não usamos diretamente) |
| poll | Espera e processa eventos de I/O; pode executar callbacks de I/O | fs, net, http, etc. (callbacks de I/O) |
| check | Executa callbacks agendados por setImmediate | setImmediate |
| close callbacks | Callbacks de fechamento | socket.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 deprocess.nextTickque 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.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
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:
AeE(síncrono)B(process.nextTicktem prioridade no Node)CeD(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:
syncpromise(microtask)- Depois,
timeout 0eimmediatepodem variar dependendo do contexto, mas frequentementetimeout 0aparece 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
Marque o que é síncrono: tudo fora de callbacks/Promises roda imediatamente.
Identifique microtasks:
then/await/queueMicrotask(eprocess.nextTickno Node). Elas rodam antes de avançar para a próxima fase.Identifique macrotasks: timers, I/O callbacks,
setImmediate.Localize o contexto:
setImmediatevssetTimeoutmuda dependendo se você está no topo do script ou dentro de I/O.Procure loops de microtasks: recursão via
then/queueMicrotask/nextTickpode 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/operationIde timestamps. Evite logs genéricos. - Trace de async: execute com
node --trace-uncaughte, quando aplicável,node --trace-warnings. Para Promises não tratadas:node --unhandled-rejections=strict. - Inspeção: use
node --inspecte breakpoints dentro de callbacks/continuações apósawaitpara observar reentrância (função retomando em outro “turno” do loop). - Reproduza determinismo: substitua latência aleatória por atrasos controlados (ex.:
setTimeoutfixo) 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
asyncpara controlar sequência; converta para Promise. - Nomeie etapas: em vez de
thenaninhado, crie funções pequenas e reutilizáveis. - Evite “Promise chain” gigante: se o fluxo tem ramificações,
try/catchcomawaitcostuma 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.allem listas grandes pode saturar recursos; prefiramapLimitou semáforo.