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:
- Timers: executa callbacks de
setTimeoutesetIntervalvencidos - Pending callbacks: executa callbacks de I/O postergados
- Idle, prepare: uso interno
- Poll: busca novos eventos de I/O; executa callbacks de I/O
- Check: executa callbacks de
setImmediate - 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
- MDN: Concurrency model and the event loop — Documentação oficial da MDN explicando o modelo de concorrência do JavaScript, Call Stack, Task Queue e Microtask Queue com exemplos visuais
- Node.js: The Node.js Event Loop — Guia oficial do Node.js detalhando as 6 fases do Event Loop e a diferença entre
setTimeout,setImmediateeprocess.nextTick - Jake Archibald: In The Loop (JSConf.Asia) — Palestra essencial que explica visualmente o Event Loop no browser, incluindo renderização, microtasks e requestAnimationFrame
- React: State and Lifecycle — Documentação oficial do React explicando por que
setStateé assíncrono e como o React interage com o Event Loop para batching de atualizações - Philip Roberts: What the heck is the event loop anyway? — Palestra clássica e introdutória sobre o Event Loop no browser, com animações que facilitam o entendimento
- V8: JavaScript engine fundamentals — Documentação do motor V8 explicando como a Call Stack e a heap funcionam internamente
- Node.js: Don't Block the Event Loop — Guia oficial do Node.js sobre boas práticas para evitar bloqueios no Event Loop, com exemplos de Worker Threads e particionamento de tarefas