Event Loop: como o JavaScript realmente funciona

1. O Modelo Single-Threaded e a Ilusão de Concorrência

JavaScript é single-threaded. Isso significa que ele só consegue executar uma instrução por vez em uma única thread. Como então conseguimos construir servidores que atendem milhares de requisições simultâneas ou interfaces de usuário que respondem a cliques enquanto baixam dados?

A resposta está no Event Loop — o coração do modelo concorrente do JavaScript. O Event Loop não é parte da linguagem em si, mas sim do ambiente de execução (browser ou Node.js). Ele cria a ilusão de concorrência ao gerenciar a ordem de execução de código síncrono e assíncrono.

A vantagem desse modelo é a ausência de condições de corrida clássicas de threads concorrentes. A limitação é que operações pesadas na Call Stack bloqueiam tudo — inclusive a interface do usuário.

2. Call Stack: O Palco Principal da Execução

A Call Stack é uma estrutura LIFO (Last In, First Out) que armazena os contextos de execução das funções. Quando você chama uma função, um novo frame é empilhado. Quando a função retorna, o frame é removido.

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function logResult(x) {
  console.trace('Inside logResult');
  return square(x);
}

console.log(logResult(5));

O console.trace() acima mostra exatamente a sequência de frames na pilha no momento da chamada. Se você tiver uma recursão infinita ou loops muito profundos, pode causar um stack overflow:

function infinite() {
  return infinite();
}

// RangeError: Maximum call stack size exceeded
// infinite();

3. Web APIs e Node APIs: O Backstage Assíncrono

A mágica assíncrona acontece fora da Call Stack. O motor V8 (que executa JavaScript) não implementa setTimeout, fetch, ou operações de I/O. Essas são APIs fornecidas pelo ambiente:

  • Browser: setTimeout, fetch, DOM events, requestAnimationFrame
  • Node.js: fs.readFile, crypto.pbkdf2, http.createServer

Quando você chama setTimeout(callback, 1000), o timer é registrado no ambiente (Web API ou Node API), e o callback é colocado em uma fila quando o tempo expira. A Call Stack continua livre para executar outras coisas.

console.log('1: Início');

setTimeout(() => {
  console.log('2: Timer de 0ms');
}, 0);

console.log('3: Fim do script síncrono');

// Saída: 1, 3, 2 (nunca 1, 2, 3)

4. Task Queue (Macrotask Queue): A Fila de Tarefas

A Task Queue (ou Macrotask Queue) armazena callbacks prontos para execução. O Event Loop verifica constantemente: se a Call Stack está vazia, pega a primeira tarefa da fila e a empilha.

Fontes de macrotasks incluem:
- setTimeout / setInterval
- Eventos de UI (cliques, scroll)
- I/O callbacks (Node.js)
- requestAnimationFrame (tratado de forma especial no browser)

console.log('A');

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

console.log('C');

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

console.log('E');

// Saída: A, C, E, B, D

5. Microtask Queue: A Pista Expressa

A Microtask Queue tem prioridade máxima. Sempre que a Call Stack esvazia, o Event Loop processa todas as microtasks disponíveis antes de tocar na Task Queue.

Fontes de microtasks:
- Promise.then(), Promise.catch(), Promise.finally()
- queueMicrotask()
- MutationObserver (browser)

console.log('1');

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

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

console.log('4');

// Saída: 1, 4, 3, 2

Isso pode causar starvation: se você criar microtasks infinitamente, as macrotasks (como renderização de UI ou timers) nunca executarão.

function starvation() {
  Promise.resolve().then(starvation);
}
// starvation(); // Cuidado: trava o Event Loop

6. O Ciclo Completo do Event Loop

No Node.js

O Event Loop no Node.js tem fases bem definidas:

  1. Timers: executa callbacks de setTimeout e setInterval vencidos
  2. Pending callbacks: executa callbacks de I/O postergados
  3. Idle, prepare: uso interno
  4. Poll: busca novos eventos de I/O; executa callbacks de I/O
  5. Check: executa callbacks de setImmediate
  6. Close callbacks: executa callbacks de fechamento (ex: socket.on('close'))

Entre cada fase, o Event Loop processa a microtask queue.

const fs = require('fs');

fs.readFile(__filename, () => {
  console.log('1: I/O callback');
  setTimeout(() => console.log('2: Timer dentro de I/O'), 0);
  setImmediate(() => console.log('3: setImmediate dentro de I/O'));
});

setTimeout(() => console.log('4: Timer principal'), 0);
setImmediate(() => console.log('5: setImmediate principal'));

// Ordem provável: 4, 5, 1, 3, 2 (ou 4, 5, 1, 2, 3)

No Browser

No browser, o ciclo é mais simples, mas inclui renderização. O navegador tenta renderizar a 60fps (a cada ~16ms). Se houver muitas tarefas síncronas ou microtasks, a renderização atrasa.

requestAnimationFrame é executado antes da renderização, tornando-o ideal para animações.

7. Implicações Práticas para React e Node.js

React e o Event Loop

O setState no React é assíncrono por design. Quando você chama setState, o React não atualiza o estado imediatamente — ele coloca a atualização em fila e a processa durante um ciclo de reconciliação, que ocorre em lote.

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1); // Não atualiza imediatamente
    setCount(count + 1); // Mesmo valor de count (0 + 1)
    console.log(count);   // Ainda 0
  }

  return <button onClick={handleClick}>{count}</button>;
}

No React 18 com createRoot, o batching acontece até mesmo dentro de Promises e setTimeout. Isso é possível porque o React usa queueMicrotask internamente para agendar a reconciliação.

Evitando Bloqueios

Operações pesadas no Event Loop congelam a UI ou travam o servidor:

// BLOQUEANTE: trava o Event Loop por segundos
function processLargeArray(items) {
  items.forEach(item => {
    // Operação pesada
    heavyComputation(item);
  });
}

Soluções:
- Web Workers (browser): executam código em threads separadas
- Worker Threads (Node.js): similar, para CPU-bound tasks
- Dividir em chunks: usar setTimeout para ceder o controle

function processInChunks(items, chunkSize = 100) {
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, items.length);
    for (let i = index; i < end; i++) {
      heavyComputation(items[i]);
    }
    index = end;
    if (index < items.length) {
      setTimeout(processChunk, 0); // Cede o Event Loop
    }
  }

  processChunk();
}

Debugando o Event Loop

Ferramentas úteis:
- Chrome DevTools: Performance tab mostra flame graphs da Call Stack
- Node.js --inspect: profiler integrado
- process.hrtime.bigint(): medição precisa de tempo

Padrões anti-performance:
- Microtasks infinitas (starvation)
- Promises dentro de loops síncronos que criam microtasks desnecessárias
- Uso excessivo de process.nextTick (Node.js) — similar a microtask, mas com prioridade ainda maior

Referências