Pular para o conteúdo
Cursos

DevClub

LógicaFront-endBack-endMobile

IA Club

IA na prática
Estudar programaçãoEstudar IA
LiçãoIniciantecódigo testado

JavaScript assíncrono: event loop e call stack

Uma thread, uma pilha e duas filas: por que um laço longo congela a página e em que ordem síncrono, microtask e macrotask realmente saem no console.

Rodolfo Mori6 min de leitura

JavaScript executa uma coisa de cada vez. Existe uma única thread rodando o seu código, e uma única pilha de chamadas nela. Tudo que parece simultâneo — requisição, temporizador, clique — é espera delegada para fora do JavaScript e devolvida depois, em fila.

A resposta curta, para você já conseguir prever a saída de qualquer código: o motor roda todo o código síncrono até a pilha esvaziar; depois esvazia a fila de microtasks (o que vem de Promise); só então pega uma macrotask (setTimeout, evento, I/O) e recomeça o ciclo.

Todos os exemplos abaixo rodaram no Node 24.16.0, no mesmo domínio das lições anteriores da trilha de JavaScript: uma loja com carrinho, frete e estoque.

O nome técnico do modelo é run-to-completion: cada tarefa JavaScript termina antes de outra começar na mesma thread. Em palavras simples, a espera pode acontecer fora, mas nenhum callback interrompe uma função no meio.

A cozinha com uma bancada e duas filas de retirada

Imagine uma cozinha com uma única bancada de finalização. Pratos podem assar e esfriar fora dela, mas só um pedido é montado por vez. Quando a bancada libera, a equipe atende primeiro toda a fila curta de ajustes urgentes e depois pega um pedido da fila comum. A bancada representa a pilha; microtasks e macrotasks são as filas com regras de prioridade diferentes.

Antes de cada exemplo, separe os logs em três colunas: síncrono, microtask e macrotask. Numere a ordem dentro de cada coluna e só depois junte as listas. Execute e compare. Esse procedimento permite prever a saída sem usar “assíncrono é aleatório” como explicação.

Cada chamada de função empilha um quadro; cada return desempilha. Quando algo estoura, o Node imprime a pilha inteira de baixo para cima — e é a fotografia mais honesta do que estava rodando naquele instante.

js
function precoComFrete(preco, peso) {
  return preco + calcularFrete(peso);
}

function calcularFrete(peso) {
  return 12.5 + adicionalPorPeso(peso);
}

function adicionalPorPeso(peso) {
  return peso.kg * 3;
}

console.log('inicio');
console.log(precoComFrete(289.9));
console.log('fim');
inicio file:///private/tmp/loja-async/pilha.mjs:10 return peso.kg * 3; ^ TypeError: Cannot read properties of undefined (reading 'kg') at adicionalPorPeso (file:///private/tmp/loja-async/pilha.mjs:10:15) at calcularFrete (file:///private/tmp/loja-async/pilha.mjs:6:17) at precoComFrete (file:///private/tmp/loja-async/pilha.mjs:2:18) at file:///private/tmp/loja-async/pilha.mjs:14:13 Node.js v24.16.0

Três funções empilhadas, na ordem exata das chamadas, mais a linha do módulo que começou tudo — e o fim que nunca foi impresso. A pilha é síncrona: nada mais acontece enquanto ela não esvazia. O mecanismo desse erro específico está em TypeError: Cannot read properties of undefined.

Enquanto a pilha não esvazia, nada anda

Este é o experimento que convence. Agendo um timer para “agora” (0ms) e, logo em seguida, faço um laço de dois bilhões de voltas:

js
const inicio = Date.now();

setTimeout(() => {
  console.log('timer pedido para 0ms rodou depois de', Date.now() - inicio, 'ms');
}, 0);

let soma = 0;
for (let i = 0; i < 2_000_000_000; i++) soma += i;

console.log('laco terminou em', Date.now() - inicio, 'ms');
laco terminou em 1341 ms timer pedido para 0ms rodou depois de 1343 ms

O timer estava pronto desde o primeiro milissegundo e ficou 1,3 segundo esperando a pilha desocupar. Num navegador, esse mesmo laço é 1,3 segundo de página congelada: sem repintar, sem responder a clique, sem rolar.

A ordem real: síncrono, microtask, macrotask

Este trecho está escrito de propósito fora de ordem. Numerei as linhas pela ordem em que elas realmente saem:

js
console.log('1 — sincrono: abriu o carrinho');

setTimeout(() => console.log('4 — macrotask: setTimeout 0'), 0);

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

console.log('2 — sincrono: fechou o carrinho');
1 — sincrono: abriu o carrinho 2 — sincrono: fechou o carrinho 3 — microtask: Promise.then 4 — macrotask: setTimeout 0

O setTimeout foi agendado antes do Promise.then e mesmo assim saiu por último. Não é sorte: são filas diferentes, com prioridades diferentes.

call stack (1 por vez) seu código microtasks Promise / await macrotasks setTimeout / I/O event loop devolve a função para a pilha

Duas filas, e uma delas nunca fica pela metade

A regra que resolve os casos difíceis: a fila de microtasks é drenada até o fim antes da próxima macrotask — inclusive as microtasks criadas por outra microtask no meio da drenagem.

js
setTimeout(() => console.log('macrotask A'), 0);
setTimeout(() => {
  console.log('macrotask B');
  Promise.resolve().then(() => console.log('  microtask criada dentro de B'));
}, 0);

Promise.resolve().then(() => {
  console.log('microtask 1');
  Promise.resolve().then(() => console.log('microtask 2 (criada dentro da 1)'));
});

queueMicrotask(() => console.log('microtask 3'));

console.log('sincrono');
sincrono microtask 1 microtask 3 microtask 2 (criada dentro da 1) macrotask A macrotask B microtask criada dentro de B

Repare na microtask 2: ela nasceu durante a drenagem e ainda assim entrou antes da macrotask A. E repare na última linha: depois de cada macrotask, o event loop volta a esvaziar as microtasks antes de seguir.

As filas extras do Node

No navegador existem essas duas filas. No Node existem outras, e a ordem entre elas muda conforme o tipo do arquivo. Em um módulo ES (.mjs):

js
setTimeout(() => console.log('timers: setTimeout'), 0);
setImmediate(() => console.log('check: setImmediate'));
Promise.resolve().then(() => console.log('microtask: Promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sincrono');
sincrono microtask: Promise nextTick timers: setTimeout check: setImmediate

O mesmo arquivo salvo como .cjs, sem trocar uma linha, inverte as duas do meio:

js
setTimeout(() => console.log('timers: setTimeout'), 0);
setImmediate(() => console.log('check: setImmediate'));
Promise.resolve().then(() => console.log('microtask: Promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sincrono');
sincrono nextTick microtask: Promise timers: setTimeout check: setImmediate

O process.nextTick tem prioridade sobre as microtasks de Promise — mas o corpo de um módulo ES já é avaliado dentro de um contexto de microtask, e por isso o then chega primeiro. É um detalhe que só aparece quando você mede. A lição prática é a mesma dos dois lados: não escreva código que dependa da ordem entre nextTick e Promise.

O erro que todo mundo comete uma vez

Você chama a API, guarda o resultado numa variável e usa logo abaixo. O código parece certo:

js
function buscarProdutos() {
  let produtos;
  fetch('http://localhost:4599/produtos')
    .then((resposta) => resposta.json())
    .then((dados) => {
      produtos = dados;
    });
  return produtos;
}

const lista = buscarProdutos();
console.log(lista);
console.log(lista.length);
undefined file:///private/tmp/loja-async/depois-do-fetch.mjs:13 console.log(lista.length); ^ TypeError: Cannot read properties of undefined (reading 'length') at file:///private/tmp/loja-async/depois-do-fetch.mjs:13:19 Node.js v24.16.0

O return produtos roda no mesmo tique do relógio em que a função foi chamada. Nesse instante o fetch mal saiu pela placa de rede: o then só vira microtask lá na frente, quando a resposta chega. A variável ainda vale undefined, e o .length estoura.

A correção não é “esperar um pouco” com setTimeout — é declarar a espera:

js
async function buscarProdutos() {
  const resposta = await fetch('http://localhost:4599/produtos');
  return resposta.json();
}

const lista = await buscarProdutos();
console.log(lista.length, 'produtos');
console.log(lista[0].nome);
3 produtos Teclado mecânico

Note o detalhe que fecha o raciocínio: buscarProdutos continua devolvendo na hora. O que ela devolve agora é uma Promise, e é o await de quem chamou que segura o valor até chegar. Esse contrato é o assunto de Promise em JavaScript e de async e await.

Como ler código assíncrono sem se enganar

Três perguntas resolvem quase todo caso:

  • Essa linha roda agora ou depois? fetch, setTimeout, addEventListener e readFile registram e voltam imediatamente. O corpo do callback é “depois”.
  • Quem está segurando a pilha? Se a interface travou, procure o laço, o JSON.parse de 30 MB ou o sort de cem mil itens — não a rede.
  • De qual fila esse callback vem? Se veio de Promise, ele fura a fila do setTimeout. Se veio de timer, ele espera a vez.

E uma regra de escrita: uma função que faz trabalho assíncrono devolve Promise, sempre. Função que “às vezes devolve o valor, às vezes devolve undefined” transforma o bug em loteria — quem chama não tem como saber o que esperar. Se esse conceito de valor devolvido ainda estiver cru, vale revisar funções em JavaScript.

A próxima lição desmonta a peça mais simples do lado assíncrono, o setTimeout e o setInterval — onde o atraso que você pede quase nunca é o atraso que você recebe.

  • assincrono
  • event loop
  • call stack
  • microtask
  • macrotask

Perguntas frequentes

JavaScript é single-thread mesmo com tanta coisa acontecendo junto?
O seu código roda numa thread só. O que acontece em paralelo é a espera — rede, disco, temporizador — e isso quem faz é o navegador ou o Node, em código nativo, fora do JavaScript. Quando a espera termina, a função volta para a fila e só roda quando a sua thread estiver livre.
Qual a diferença prática entre microtask e macrotask?
A fila de microtasks é esvaziada inteira antes de o event loop pegar a próxima macrotask. Na prática: tudo que vem de Promise fura a fila do setTimeout, mesmo um setTimeout de 0ms agendado antes.
Por que meu console.log imprime undefined logo depois do fetch?
Porque a linha do console.log rodou antes de a resposta chegar. O fetch só registra o que fazer depois; a execução segue em frente na mesma hora. A correção é usar await ou mover o uso do dado para dentro do then.
Um laço grande realmente trava a página inteira?
Trava. Enquanto o laço não termina, a pilha não esvazia, e o navegador não consegue nem repintar nem responder a clique. Se o cálculo é pesado de verdade, ele precisa sair da thread principal — em Web Worker no navegador ou em worker_threads no Node.

Dúvidas e comentários

Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.

Todo o código deste artigo foi executado em Node 24.16.0, e as saídas exibidas são as reais — como produzimos este conteúdo.

Fontes consultadas

  1. MDN — Modelo de concorrência e event loop — developer.mozilla.org
  2. Node.js — The Node.js Event Loop — nodejs.org

Continue por aqui