O que é Programação Assíncrona? O Event Loop Explicado

[98] O que é Programação Assíncrona? O Event Loop Explicado

Uma linguagem que faz uma coisa de cada vez não deveria conseguir esperar três segundos por um servidor sem travar a tela — e no entanto consegue. O artigo abre a caixa: pilha de execução, as APIs que o navegador empresta, as duas filas e a regra que decide quem passa na frente de quem.
Javascript

18 min de leitura

Este é um dos artigos mais importantes de toda a série. Não porque seja o mais difícil — mas porque ele explica como o JavaScript realmente funciona. Muita gente usa Promises e async/await sem entender por quê existem. Esse artigo resolve isso.

Quando você entender o Event Loop, comportamentos que antes pareciam mágica ou bug vão fazer sentido perfeito. Vamos com calma.

O problema: JavaScript é single-threaded

JavaScript executa em uma única thread — isso significa que ele só faz uma coisa de cada vez. Não há paralelismo real. Enquanto uma linha de código executa, nenhuma outra pode executar ao mesmo tempo.

Parece uma limitação grave. E seria — se não existisse o modelo assíncrono.

Imagine este cenário sem assíncrono:

// Cenário hipotético — BLOQUEANTE (não é assim que funciona)
const dados = buscarDadosDoServidor(); // aguarda 3 segundos...
// Durante esses 3 segundos, NADA acontece na página
// O usuário não pode clicar, rolar, digitar — NADA
console.log(dados);

Uma página travada por 3 segundos é inaceitável. O JavaScript resolve isso com um modelo elegante: o Event Loop.

As peças do modelo assíncrono

Para entender o Event Loop, você precisa conhecer as peças:

┌─────────────────────────────────────────┐
│              CALL STACK                 │
│         (pilha de execução)             │
│   [ função3 ]                           │
│   [ função2 ]                           │
│   [ função1 ]  ← executa de cima        │
│   [ main    ]    para baixo             │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│            WEB APIs / NODE APIs         │
│  (fornecidas pelo ambiente, não pelo JS)│
│  setTimeout, fetch, DOM events,         │
│  setInterval, XMLHttpRequest...         │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│            CALLBACK QUEUE               │
│         (fila de callbacks)             │
│  [ cb3, cb2, cb1 ]  ← aguarda a vez    │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│            MICROTASK QUEUE              │
│  (fila de microtarefas — prioridade)    │
│  Promises, queueMicrotask              │
└─────────────────────────────────────────┘

           ↑↑↑ EVENT LOOP ↑↑↑
    Monitora a Call Stack e as filas.
    Quando a Call Stack esvazia,
    move callbacks para ela executar.

Call Stack — a pilha de execução

A Call Stack registra onde estamos na execução do programa. Toda vez que uma função é chamada, ela entra no topo da pilha. Quando retorna, sai.

function somar(a, b) {
  return a + b;       // 3. executa e sai da pilha
}

function calcular() {
  const r = somar(3, 4); // 2. somar entra na pilha
  console.log(r);        // 4. console.log entra e sai
}

calcular(); // 1. calcular entra na pilha

Passo a passo da pilha:

[calcular]         → calcular entra
[somar, calcular]  → somar entra
[calcular]         → somar retorna e sai
[console.log, calcular] → console.log entra e sai
[]                 → calcular termina e sai

Web APIs — delegando o trabalho pesado

Quando o JavaScript encontra uma operação demorada — como setTimeout, uma requisição HTTP ou um evento do DOM — ele delega para as Web APIs (fornecidas pelo navegador) e continua executando sem esperar:

console.log("1 — início");

setTimeout(() => {
  console.log("3 — dentro do timeout");
}, 2000);

console.log("2 — fim");

// Saída:
// 1 — início
// 2 — fim
// (2 segundos depois...)
// 3 — dentro do timeout

O setTimeout foi delegado ao navegador. O JavaScript não esperou — continuou para o console.log("2"). Depois que o timer de 2 segundos acabou, o callback entrou na fila.

Event Loop — o orquestrador

O Event Loop tem uma tarefa simples mas fundamental:

ENQUANTO o programa estiver rodando:
  SE a Call Stack estiver VAZIA:
    SE a Microtask Queue tiver itens:
      Mova TODOS para a Call Stack (um a um)
    SENÃO SE a Callback Queue tiver itens:
      Mova o PRIMEIRO para a Call Stack

É um loop infinito que fica observando. Quando a pilha esvazia, ele verifica as filas e move o próximo callback para execução.

Exemplo completo — rastreando o Event Loop

console.log("A");

setTimeout(() => console.log("B"), 0); // delay 0, mas ainda é assíncrono!

Promise.resolve().then(() => console.log("C")); // microtask

console.log("D");

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

Por quê? Vamos rastrear:

1. console.log("A")          → Call Stack executa → imprime "A"
2. setTimeout(cb, 0)         → delegado às Web APIs → cb vai para Callback Queue
3. Promise.resolve().then()  → microtask registrada → cb vai para Microtask Queue
4. console.log("D")          → Call Stack executa → imprime "D"

Call Stack está vazia. Event Loop verifica:
  → Microtask Queue tem "C" → executa → imprime "C"
  → Microtask Queue vazia
  → Callback Queue tem "B" → executa → imprime "B"

Resultado: A, D, C, B

Microtasks sempre têm prioridade sobre macrotasks (callbacks de setTimeout/setInterval).

Microtasks vs Macrotasks

// MACROTASKS (Callback Queue)
// setTimeout, setInterval, setImmediate (Node), eventos DOM

// MICROTASKS (Microtask Queue — maior prioridade)
// Promise.then/catch/finally, queueMicrotask, MutationObserver

console.log("1");

setTimeout(() => console.log("2 — macrotask"), 0);

Promise.resolve()
  .then(() => console.log("3 — microtask 1"))
  .then(() => console.log("4 — microtask 2"));

queueMicrotask(() => console.log("5 — microtask 3"));

console.log("6");

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

Todas as microtasks são processadas antes de qualquer macrotask — mesmo que a macrotask tenha sido agendada antes.

O que significa "bloquear a thread"

Agora que você entende o Event Loop, fica claro por que código síncrono pesado é um problema:

// Isso BLOQUEIA o Event Loop por vários segundos
// Durante esse tempo, nenhum evento, timer ou Promise pode executar

function calcularPesado() {
  const inicio = Date.now();
  while (Date.now() - inicio < 3000) {
    // loop travado por 3 segundos
  }
  return "feito";
}

setTimeout(() => console.log("timeout"), 100); // deveria executar em 100ms
calcularPesado(); // bloqueia por 3 segundos
// O timeout só executa depois dos 3 segundos — não em 100ms

Por isso operações pesadas devem ser assíncronas — para não bloquear a thread e deixar a interface responsiva.

O problema dos callbacks aninhados

Antes das Promises, o código assíncrono usava apenas callbacks. Funcionava, mas gerava o temido Callback Hell:

// Callback Hell — código que cresce para a direita
obterUsuario(id, function(usuario) {
  obterPedidos(usuario.id, function(pedidos) {
    obterDetalhesPedido(pedidos[0].id, function(detalhes) {
      calcularFrete(detalhes.endereco, function(frete) {
        processarPagamento(frete, function(resultado) {
          // chegamos ao inferno
          if (resultado.erro) {
            tratarErro(resultado.erro, function(r) {
              // mais um nível...
            });
          }
        });
      });
    });
  });
});

Isso é difícil de ler, difícil de manter e difícil de tratar erros. As Promises foram criadas para resolver exatamente esse problema — mas antes de chegar nelas vale entender bem a peça que elas substituem: o próximo artigo é sobre o callback em si.

Visualizando o Event Loop na prática

// Vamos rastrear este código completo:

console.log("Script inicia");              // 1

setTimeout(() => {                          // agendado para depois
  console.log("Timeout 1");               // 5
}, 0);

setTimeout(() => {                          // agendado para depois
  console.log("Timeout 2");               // 6
}, 0);

Promise.resolve()
  .then(() => {
    console.log("Promise 1");             // 3
    return "valor";
  })
  .then((val) => {
    console.log("Promise 2:", val);       // 4
  });

console.log("Script termina");             // 2

// Saída:
// Script inicia
// Script termina
// Promise 1
// Promise 2: valor
// Timeout 1
// Timeout 2

setTimeout(fn, 0) — um truque útil

Como vimos, setTimeout com delay 0 não executa "agora" — executa após o código síncrono atual terminar. Isso é útil em alguns casos:

// Forçar o DOM a atualizar antes de executar algo pesado
button.textContent = "Carregando..."; // atualiza o texto

setTimeout(() => {
  // O navegador teve chance de renderizar a atualização do DOM
  // antes de executar este código pesado
  processarDadosGrandes();
}, 0);
// Adiar execução para depois do Event Loop atual
console.log("síncrono 1");

setTimeout(() => console.log("assíncrono"), 0);

console.log("síncrono 2");

// síncrono 1 → síncrono 2 → assíncrono

Node.js e o Event Loop

O Node.js usa o mesmo conceito de Event Loop, mas com algumas adições específicas — como as fases do loop (timers, I/O, idle, poll, check, close). A ideia central é a mesma: uma thread, delegação de operações pesadas, callbacks em filas.

É por isso que o Node consegue ser eficiente mesmo sendo single-threaded — operações de I/O (leitura de arquivos, banco de dados, rede) são delegadas ao sistema operacional, e o Node continua processando outras requisições enquanto espera.

// Em Node.js — servidor que não bloqueia
const http = require("http");

http.createServer((req, res) => {
  // Cada requisição chega como um evento
  // O Node não fica esperando — processa várias ao mesmo tempo
  res.end("Olá!");
}).listen(3000);

console.log("Servidor rodando na porta 3000");
// O Node não para aqui — fica ouvindo eventos indefinidamente

Resumo visual

┌─────────────────────────────────────────────────────┐
│                   EXECUÇÃO JAVASCRIPT                │
│                                                     │
│  Código síncrono → Call Stack (imediato)            │
│                                                     │
│  setTimeout/setInterval → Web API → Callback Queue  │
│  (macrotask — executa quando Call Stack vazia)      │
│                                                     │
│  Promise.then/catch → Microtask Queue               │
│  (prioridade — executa ANTES das macrotasks)        │
│                                                     │
│  EVENT LOOP monitora tudo e gerencia as filas       │
└─────────────────────────────────────────────────────┘

Ordem de prioridade:
1. Código síncrono (Call Stack)
2. Microtasks (Promises)
3. Macrotasks (setTimeout, eventos)

Tarefa para você

Sem executar o código, tente prever a saída de cada exemplo:

Desafio 1:

console.log("A");
setTimeout(() => console.log("B"), 1000);
setTimeout(() => console.log("C"), 0);
console.log("D");
// Qual a ordem? A, D, C, B ou A, D, B, C ou outra?

Desafio 2:

console.log("1");

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

Promise.resolve()
  .then(() => {
    console.log("3");
    setTimeout(() => console.log("4"), 0);
  })
  .then(() => console.log("5"));

console.log("6");
// Qual a ordem?

Desafio 3: Explique com suas palavras por que uma animação CSS é preferível a uma animação feita com setInterval quando há código JavaScript pesado rodando ao mesmo tempo.

Ver solução — as três respostas, e o modelo que as explica
// ---------------------------------------------------------------
// Desafio 1 — resposta: A, D, C, B
// ---------------------------------------------------------------
console.log("A");
setTimeout(() => console.log("B"), 1000);
setTimeout(() => console.log("C"), 0);
console.log("D");

// A e D saem primeiro porque são síncronos: rodam antes de qualquer
// callback, sempre. Depois vem C, e só então B.
//
// O detalhe é que `setTimeout(fn, 0)` NÃO significa "agora". Significa
// "coloque na fila; pode rodar assim que a pilha esvaziar, e não antes
// de 0 ms". O 0 é um piso, não um horário. C vem antes de B porque o
// piso dele é menor — não porque foi registrado depois.

// ---------------------------------------------------------------
// Desafio 2 — resposta: 1, 6, 3, 5, 2, 4
// ---------------------------------------------------------------
console.log("1");

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

Promise.resolve()
  .then(() => {
    console.log("3");
    setTimeout(() => console.log("4"), 0);
  })
  .then(() => console.log("5"));

console.log("6");

// Passo a passo:
//
//   síncrono ........... 1, 6
//   microtasks ......... 3, depois 5
//   macrotasks ......... 2, depois 4
//
// A parte que surpreende é 3 e 5 saírem ANTES de 2, mesmo o setTimeout
// do 2 tendo sido registrado primeiro. É que promise e setTimeout não
// disputam a mesma fila:
//
//   · `.then` vai para a fila de MICROTASKS
//   · `setTimeout` vai para a de MACROTASKS (tasks)
//
// E a regra é: ao esvaziar a pilha, o event loop drena a fila de
// microtasks INTEIRA antes de pegar uma única macrotask. Por isso o 5
// (segundo `.then`) ainda passa na frente do 2.
//
// O 4 sai por último porque só foi agendado lá dentro do primeiro
// `.then` — quando ele entrou na fila de macrotasks, o 2 já estava lá.

// ---------------------------------------------------------------
// A regra em uma frase
// ---------------------------------------------------------------
// 1. Roda todo o código síncrono (a pilha).
// 2. Esvazia TODA a fila de microtasks (promises, queueMicrotask).
// 3. Pega UMA macrotask (timer, evento de I/O, clique).
// 4. Esvazia de novo TODA a fila de microtasks.
// 5. Se for hora, o navegador pinta a tela. Volta ao 3.

// Prova de que microtask fura a fila mesmo criada depois:
setTimeout(() => console.log("macro"), 0);
queueMicrotask(() => console.log("micro"));
// micro, macro

// E prova de que microtask pode travar a página: esta fila nunca
// esvazia, então o passo 3 nunca chega. A aba congela — sem laço
// infinito nenhum à vista.
//
//   function nuncaMais() { queueMicrotask(nuncaMais); }
//   nuncaMais();

// ---------------------------------------------------------------
// Se você colar tudo isto de uma vez, a saída NÃO sai agrupada
// ---------------------------------------------------------------
// Rodando o bloco inteiro num arquivo só, o resultado é:
//
//   A, D, 1, 6, 3, micro, 5, C, 2, macro, 4, B
//
// Parece bagunça e é a própria lição: as filas são do runtime, não de
// cada trecho. Todo o código síncrono dos três exemplos roda primeiro
// (A, D, 1, 6), depois as microtasks de todos eles, e só então os
// timers — na ordem em que venceram, misturando C do primeiro exemplo
// com 2 do segundo. O B fica por último porque é o único com 1000 ms.
//
// Para conferir a resposta de cada desafio, rode um por vez.

// ---------------------------------------------------------------
// Desafio 3 — por que a animação CSS ganha
// ---------------------------------------------------------------
// A resposta curta: `setInterval` disputa a mesma thread do seu
// JavaScript pesado. A animação CSS, não.
//
// Com setInterval, cada quadro depende de a pilha estar livre no
// instante certo. Um `for` de 200 ms segurando a thread significa
// que o callback do timer espera — e o movimento trava, porque o
// navegador não pinta nada enquanto a pilha não esvazia.
//
// A animação CSS (transform e opacity) é entregue ao COMPOSITOR, uma
// etapa que roda fora da thread principal, na GPU. O JavaScript pode
// estar ocupado que o quadro continua saindo no ritmo do monitor.
//
// Além disso, o CSS conhece a taxa de atualização da tela; o
// `setInterval(fn, 16)` só chuta 60 Hz e erra em telas de 120 Hz.

const elemento = document.querySelector("#caixa");

// ❌ compete com o JS e ignora a taxa real da tela
let x = 0;
setInterval(() => {
  x += 2;
  elemento.style.left = `${x}px`; // left ainda força layout a cada quadro
}, 16);

// ✅ sai da thread principal e acompanha o monitor
// elemento.style.transition = "transform .3s ease";
// elemento.style.transform = "translateX(300px)";

// Quando a animação precisa mesmo ser calculada em JavaScript, o
// mínimo é trocar setInterval por requestAnimationFrame: ele roda
// antes de cada pintura, pausa em aba de fundo e informa o tempo real
// decorrido, o que permite animar por tempo em vez de por quadro.
function animar(inicio) {
  requestAnimationFrame(function quadro(agora) {
    const decorrido = agora - inicio;
    elemento.style.transform = `translateX(${Math.min(300, decorrido / 2)}px)`;

    if (decorrido < 600) requestAnimationFrame(quadro);
  });
}

// Regra prática: transform e opacity são baratos (compositor); left,
// top, width e height são caros (layout + pintura a cada quadro).

A hierarquia que resolve 90% das pegadinhas: síncrono → todas as microtasks → uma macrotask → todas as microtasks de novo. setTimeout(fn, 0) nunca fura a fila de promises, e uma microtask que se reagenda sozinha congela a aba sem nenhum laço infinito aparente.

Uma linguagem de uma thread só que atende milhares de operações ao mesmo tempo parece contradição, e deixa de ser quando fica claro que quem espera não é o JavaScript: é o navegador, ou o sistema operacional, que avisa ao terminar. A pilha só recebe trabalho novo quando está vazia — e é essa frase que explica o setTimeout de zero milissegundo que não roda na hora, a microtask que fura a fila das macrotasks, e o laço pesado que congela a página inteira enquanto roda.

Fontes e Referências

Exercícios

Exercício 1

Escreva a ordem exata da saída.

console.log("A");

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

Promise.resolve().then(() => {
  console.log("C");
  setTimeout(() => console.log("D"), 0);
});

queueMicrotask(() => console.log("E"));

console.log("F");
Ver resposta

✓ Resposta: A, F, C, E, B, D. O código síncrono roda inteiro primeiro, então A e F saem antes de qualquer coisa agendada. Quando a pilha esvazia, o event loop drena a fila de microtasks toda, e nela estão C e E, nessa ordem de registro — o D nasce dentro do C, mas nasce como macrotask, então vai para o fim da outra fila, atrás do B, que já estava lá. Só com as microtasks zeradas o loop pega uma macrotask: primeiro B, depois D. O ponto que vale guardar é que "delay 0" não é um pedido de prioridade: uma promise resolvida na mesma linha sempre passa na frente de um setTimeout(fn, 0), porque as duas nem disputam a mesma fila.

Exercício 2

As duas funções entram em laço infinito. Qual delas deixa a aba responder ao clique de um botão, e qual congela a página?

function versaoMacro() {
  setTimeout(versaoMacro, 0);
}

function versaoMicro() {
  queueMicrotask(versaoMicro);
}
Ver resposta

✓ Resposta: A versaoMacro deixa a página viva; a versaoMicro congela tudo. A diferença está no ciclo do event loop: ele pega uma macrotask por volta e, entre uma e outra, tem a chance de processar eventos de entrada e pintar a tela. Um setTimeout que se reagenda sozinho consome CPU e faz o ventilador ligar, mas a cada volta o navegador ainda respira. Já a fila de microtasks precisa ser esvaziada por completo antes de o loop seguir adiante — e uma microtask que enfileira outra microtask nunca deixa a fila esvaziar. O loop fica preso ali, não chega a processar clique, nem a rolar a página, nem a repintar: a aba trava, e o navegador acaba oferecendo encerrá-la. É um congelamento sem nenhum while (true) à vista, o que o torna especialmente difícil de encontrar — e é a razão de a prioridade das microtasks ser uma faca de dois gumes.

Exercício 3

O botão deveria mostrar "Carregando..." e só então processar. Por que, na primeira versão, o texto nunca aparece?

// versão A
botao.addEventListener("click", () => {
  botao.textContent = "Carregando...";
  processarPorTresSegundos();
  botao.textContent = "Pronto";
});

// versão B
botao.addEventListener("click", () => {
  botao.textContent = "Carregando...";
  setTimeout(() => {
    processarPorTresSegundos();
    botao.textContent = "Pronto";
  }, 0);
});
Ver resposta

✓ Resposta: Na versão A o texto muda de verdade — o que não acontece é a pintura. Alterar textContent só modifica o DOM em memória; o navegador desenha o resultado numa etapa posterior, que só pode acontecer quando a pilha estiver livre. Como o processamento pesado entra logo em seguida e segura a thread por três segundos, a primeira pintura possível é depois dele — e a essa altura o texto já é "Pronto". O usuário vê um botão que congela e depois muda de uma vez. A versão B corta o trabalho em duas tarefas: o handler termina deixando só a mudança de texto, o navegador pinta "Carregando...", e o processamento entra na volta seguinte do loop. Vale a ressalva honesta de que isso não conserta o travamento — a página continua presa durante os três segundos, apenas com o aviso na tela. Para não travar de fato, o caminho é fatiar o trabalho em pedaços ou mandá-lo para um Web Worker, que roda em outra thread de verdade.

Exercício 4

Este código imprime "fim" alguma vez? E o navegador pinta os quadros da animação enquanto ele roda?

const inicio = Date.now();

while (Date.now() - inicio < 5000) {
  // cinco segundos de trabalho síncrono
}

console.log("fim");
Ver resposta

✓ Resposta: Imprime "fim", sim — cinco segundos depois. E o navegador não pinta quadro nenhum nesse intervalo, ao menos não das animações que dependem da thread principal. Enquanto o while roda, a pilha não esvazia; sem pilha vazia não há event loop, e sem event loop não há evento de clique processado, nem timer disparado, nem repintura. Botão não responde, campo de texto não aceita letra, setTimeout agendado para 100 ms atrás continua esperando. Há uma exceção importante e que costuma confundir: animações CSS de transform e opacity são executadas pelo compositor, fora da thread principal, e continuam fluindo normalmente durante o travamento — o que dá a impressão enganosa de que "a página está ok". Anime uma propriedade que exija recálculo de layout, como left ou width, e ela para junto com o resto.

Exercício 5

Um setTimeout de 100 ms e outro de 200 ms são registrados juntos, e em seguida a thread fica ocupada por 500 ms. Os dois callbacks rodam ao mesmo tempo? Em que ordem?

setTimeout(() => console.log("100ms"), 100);
setTimeout(() => console.log("200ms"), 200);

const t = Date.now();
while (Date.now() - t < 500) {}

console.log("liberou");
Ver resposta

✓ Resposta: Sai liberou, depois 100ms, depois 200ms — nessa ordem, e os dois praticamente juntos, meio segundo atrasados. Os dois prazos venceram enquanto a thread estava presa: aos 100 ms e aos 200 ms os callbacks foram movidos para a fila de macrotasks e ficaram lá esperando a pilha esvaziar, o que só aconteceu aos 500 ms. Quando ela esvaziou, o loop pegou um por volta, na ordem em que entraram na fila, que é a ordem em que os prazos venceram. Duas consequências que aparecem em código real: o atraso de um temporizador não tem teto — o delay é um piso, e o teto depende inteiramente do que mais estiver rodando; e vários timers vencidos durante um travamento não são compactados num só, eles se acumulam e disparam em rajada logo que a thread libera. É isso que produz o efeito de uma animação por setInterval "correr atrás do prejuízo" depois de um engasgo.

Comentários

Mais em Javascript

Revisão + Projeto: API tipada e testada
Revisão + Projeto: API tipada e testada

Os quatro artigos do módulo se encontram num projeto só: uma API de produtos…

Deploy: do código ao ar
Deploy: do código ao ar

Construir é metade do trabalho; a outra metade é colocar no ar. O artigo…

Mini Projeto: App de Clima Completo
Mini Projeto: App de Clima Completo

O Módulo 3 fecha juntando tudo num app de clima: geolocalização do navegador…