async/await por Dentro: Event Loop, Microtasks e os Bugs que Ninguém Te Conta
← Voltar para Codeshort

async/await por Dentro: Event Loop, Microtasks e os Bugs que Ninguém Te Conta

Entender async/await de verdade significa saber o que acontece na microtask queue antes de cada linha depois de um await — e por que isso muda como você debugga, otimiza e evita os bugs silenciosos mais custosos do JavaScript assíncrono.

DC
Dev Code Software
06 de abril de 2026·10 min de leitura

Índice


Você escreve await e assume que a execução pausou ali. Ela não pausou. O engine registrou uma continuação na microtask queue, devolveu o controle para quem chamou a função e só vai retomar daquele ponto quando a fila de microtasks for drenada. Essa distinção — invisível na sintaxe, devastadora na prática — é a diferença entre usar async/await e realmente entender o que está acontecendo.

Este artigo desmonta o mecanismo completo: event loop, filas de prioridade, transformação de await em .then() e os erros silenciosos que aparecem justamente porque o mental model estava errado desde o início.

O que async realmente faz com uma função

Toda async function retorna uma Promise. Sem exceção, sem condição. Se você retornar um valor primitivo, o engine envolve em Promise.resolve(valor). Se a função lançar uma exceção não capturada, o engine a converte em Promise.reject(erro).

async function buscarUsuario(id) {
  return { id, nome: "Ana" };
}

function buscarUsuarioEquivalente(id) {
  return Promise.resolve({ id, nome: "Ana" });
}

const resultado = buscarUsuario(1);
console.log(resultado instanceof Promise);

Isso tem uma consequência que surpreende muitos desenvolvedores: erros em async functions nunca propagam de forma síncrona. Eles se tornam rejeições de Promise antes de qualquer try/catch externo ter a chance de agir.

async function buscarDados() {
  throw new Error("falhou");
}

try {
  buscarDados();
} catch (e) {
  console.log("isso nunca executa");
}

try {
  await buscarDados();
} catch (e) {
  console.log("agora sim:", e.message);
}

O primeiro try/catch não captura nada porque a exceção já foi convertida em Promise.reject() antes de chegar ao catch. O segundo funciona porque o await desembrulha a Promise — incluindo a rejeição — e a relança no contexto síncrono da função.

Microtasks, macrotasks e a ordem exata do event loop

O JavaScript runtime mantém três estruturas de execução que definem em que ordem o código roda. Entender as três é pré-requisito para qualquer raciocínio sobre comportamento assíncrono.

Call stack — onde código síncrono executa, frame por frame. Enquanto houver frames na stack, nada mais roda.

Microtask queue — fila de alta prioridade, drenada completamente antes de qualquer macrotask. Toda Promise opera aqui.

Task queue (macrotask queue) — fila de prioridade padrão. O event loop processa uma entrada por vez, sempre drenando a microtask queue entre cada macrotask.

FilaO que vai para lá
Microtask queue.then(), .catch(), .finally(), queueMicrotask(), MutationObserver
Macrotask queuesetTimeout(), setInterval(), callbacks de I/O, eventos do DOM

O ciclo é sempre o mesmo: esvazia a call stack → drena toda a microtask queue → processa uma macrotask → drena a microtask queue de novo → repete. O setTimeout(fn, 0) não significa "execute agora" — significa "coloque na macrotask queue, que vai rodar depois de todas as microtasks pendentes."

console.log("1");

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

Promise.resolve().then(() => console.log("3 - microtask"));

console.log("4");
1
4
3 - microtask
2 - macrotask

O 3 sai antes do 2 porque a Promise está na microtask queue — e ela é completamente drenada antes de qualquer macrotask rodar, mesmo que o setTimeout tenha zero milissegundos de delay.

Como await suspende sem travar o thread

await faz exatamente duas coisas. Primeiro, desembrulha a Promise recebida — se não for uma Promise, envolve o valor em Promise.resolve() automaticamente. Segundo, suspende a execução da função atual e registra o restante dela como uma continuação na microtask queue.

O thread principal não para. O event loop continua processando normalmente. Apenas aquela função específica fica aguardando a Promise resolver para retomar.

async function buscarPedido(id) {
  console.log("A");
  const pedido = await fetch(`/api/pedidos/${id}`);
  console.log("B");
  return pedido;
}

buscarPedido(1);
console.log("C");
A
C
B

O C imprime antes do B porque o await devolve o controle para o código que chamou buscarPedido. O fetch vai rodar em background; quando resolver, o restante da função — a linha do console.log("B") — entra na microtask queue e continua de onde parou.

Atenção: chamar uma async function sem await não é necessariamente errado, mas significa que você está descartando a Promise retornada. Qualquer erro interno vira uma UnhandledPromiseRejection. No Node.js 15 em diante, isso derruba o processo.

A pilha de execução desmontada linha por linha

async/await é açúcar sintático sobre Promises encadeadas. O engine transforma o código antes de executar. Dado este exemplo:

async function autenticar(email, senha) {
  const usuario = await db.buscarPorEmail(email);
  const valido = await verificarSenha(senha, usuario.hash);
  if (!valido) throw new Error("Credenciais inválidas");
  return gerarToken(usuario);
}

A transformação interna equivale a:

function autenticar(email, senha) {
  return db.buscarPorEmail(email).then(usuario =>
    verificarSenha(senha, usuario.hash).then(valido => {
      if (!valido) throw new Error("Credenciais inválidas");
      return gerarToken(usuario);
    })
  );
}

Cada await vira um .then(). O código após o await vira o callback desse .then(). Múltiplos awaits em sequência formam uma cadeia — e cada elo entra na microtask queue apenas quando o elo anterior resolve.

Isso explica por que stack traces de async functions são notoriamente difíceis de ler em produção: o erro pode estar num callback que foi enfileirado em um tick completamente diferente da chamada original. O Chrome DevTools tem uma opção "Async stack traces" exatamente por isso — ative-a.

Os bugs silenciosos que consomem manhãs de debug

async dentro de forEach — o erro que mais aparece em code review

usuarios.forEach(async (usuario) => {
  await enviarEmail(usuario.email);
});
console.log("emails enviados");

O console.log vai imprimir antes de qualquer email sair. forEach não aguarda Promises — ele dispara todos os callbacks e retorna imediatamente, ignorando o que cada um retorna. O await suspende cada callback individualmente, mas nenhum deles controla a iteração externa.

for (const usuario of usuarios) {
  await enviarEmail(usuario.email);
}
console.log("emails enviados");

O for...of com await executa cada iteração em sequência, aguardando a Promise de cada uma antes de avançar. Se as operações forem independentes e você quiser paralelismo, use Promise.all com .map() (veja a seção seguinte).

Async function disparada sem await e sem .catch()

async function inicializar() {
  sincronizarCache();
  iniciarServidor();
}

Se sincronizarCache() rejeitar, a rejeição some no vácuo — a não ser que você tenha um listener global de unhandledRejection. Ninguém vai saber que o cache não sincronizou.

async function inicializar() {
  sincronizarCache().catch(err => logger.warn("cache sync falhou:", err));
  iniciarServidor();
}

Se a intenção é disparar sem bloquear, assuma a responsabilidade explicitamente: trate o erro com .catch() ou logue-o. Ignorar a Promise é uma decisão que precisa ser consciente.

try/catch sem await — silêncio garantido

try {
  operacaoAssincrona();
} catch (e) {
  console.log("nunca chega aqui");
}

Sem await, a função retorna uma Promise imediatamente — sem lançar exceção. O catch nunca dispara. A rejeição, se ocorrer, fica na Promise que você acabou de descartar.

try {
  await operacaoAssincrona();
} catch (e) {
  console.log("capturado:", e.message);
}

Sequencial vs paralelo: onde a latência vai embora

Dois awaits em sequência significam execução sequencial. A segunda Promise não começa até a primeira resolver. Quando as operações são independentes entre si, você está acumulando latência sem nenhum benefício.

async function carregarDashboard(userId) {
  const usuario = await buscarUsuario(userId);
  const pedidos = await buscarPedidos(userId);
  return { usuario, pedidos };
}

Se buscarUsuario leva 300ms e buscarPedidos leva 250ms, a função demora 550ms. As duas Promises poderiam rodar ao mesmo tempo — cada uma dispara sua requisição de rede e aguarda a resposta independentemente.

async function carregarDashboard(userId) {
  const [usuario, pedidos] = await Promise.all([
    buscarUsuario(userId),
    buscarPedidos(userId),
  ]);
  return { usuario, pedidos };
}

Com Promise.all, as duas Promises são criadas simultaneamente. O tempo total cai para max(300ms, 250ms) = 300ms. Em uma rota de API que chama três serviços independentes, essa diferença é a distinção entre uma resposta de 200ms e uma de 600ms.

Promise.all rejeita imediatamente se qualquer Promise rejeitar. Para casos onde você precisa que todas completem independente de falhas individuais:

const resultados = await Promise.allSettled([
  buscarUsuario(userId),
  buscarPedidos(userId),
  buscarNotificacoes(userId),
]);

for (const resultado of resultados) {
  if (resultado.status === "fulfilled") {
    processar(resultado.value);
  } else {
    logger.error("parcial falhou:", resultado.reason);
  }
}

Guia de referência: Promise.all, allSettled, any e race

MétodoResolve quandoRejeita quandoCaso de uso
Promise.allTodas resolvemQualquer uma rejeitaOperações independentes que precisam de todos os resultados
Promise.allSettledTodas completamNunca rejeitaProcessar resultados mesmo com falhas parciais
Promise.anyA primeira resolveTodas rejeitam (AggregateError)Múltiplas fontes — pegar a mais rápida a responder
Promise.raceA primeira completa (resolve ou reject)A primeira rejeitaTimeout: corrida entre a requisição e um setTimeout
function fetchComTimeout(url, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`Timeout após ${ms}ms`)), ms)
  );
  return Promise.race([fetch(url), timeout]);
}

const asset = await Promise.any([
  fetch("https://cdn1.exemplo.com/bundle.js"),
  fetch("https://cdn2.exemplo.com/bundle.js"),
  fetch("https://cdn3.exemplo.com/bundle.js"),
]);

Checklist: async/await sem surpresas

Use como referência rápida antes de fazer code review ou abrir um PR:

  • await está dentro de uma async function ou em top-level ESM?
  • Toda async function chamada sem await tem um .catch() explícito?
  • Iterações assíncronas usam for...of (sequencial) ou Promise.all + .map() (paralelo)?
  • Múltiplos awaits independentes foram substituídos por Promise.all?
  • try/catch envolve apenas chamadas que têm await?
  • Stack traces de erro foram verificados com async stack traces ativo?

FAQ

Por que código síncrono após uma chamada async executa antes do resultado?

Porque await suspende apenas a função onde está declarado, não o código que a chamou. Se você chamar uma async function sem awaitar o retorno, o chamador continua imediatamente para a próxima linha. O código dentro da async function, após o await, só retoma quando a Promise resolve — e o chamador já foi embora faz tempo.

async/await tem custo de performance comparado a Promises encadeadas?

O overhead é negligenciável em aplicações reais. Cada await adiciona um microtick extra para garantir comportamento assíncrono consistente, mas o gargalo real está sempre na operação aguardada: I/O, query, rede. Se performance ao nível de microticks importar para você, o profiler vai apontar outros problemas muito antes de você chegar nessa discussão.

Por que await dentro de setTimeout não funciona como esperado?

setTimeout recebe um callback comum — mesmo que seja uma async function, a Promise que ela retorna é ignorada pelo runtime. O await suspende o callback em si, não o contexto externo. Para delays awaitable, use um utilitário:

const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));

await sleep(1000);

await funciona fora de async functions?

Em Node.js com ESM (.mjs ou "type": "module" no package.json), await no nível do módulo funciona — é o top-level await. Em CommonJS (require()), você recebe um SyntaxError. No browser, funciona em <script type="module">. Para CommonJS que precisa de top-level await, o padrão é IIFE async:

(async () => {
  const dados = await buscarDados();
  inicializar(dados);
})();

Quando usar .then() em vez de async/await?

Para encadeamentos curtos e autocontidos — como .then(res => res.json()) após um fetch.then() é mais compacto. Para lógica com múltiplos passos, tratamento de erro condicional ou variáveis compartilhadas entre etapas, async/await é mais legível. O problema real é misturar os dois estilos de forma inconsistente no mesmo codebase. Escolha uma convenção e documente.

Como cancelar uma operação assíncrona em andamento?

Promises nativas não têm cancelamento — mas a Fetch API aceita um AbortController. O mesmo padrão funciona para qualquer operação que respeite um AbortSignal:

const controller = new AbortController();

setTimeout(() => controller.abort(), 3000);

try {
  const res = await fetch("/api/dados", { signal: controller.signal });
  const dados = await res.json();
} catch (e) {
  if (e.name === "AbortError") {
    console.log("requisição cancelada após 3 segundos");
  }
}

Com o mecanismo do async/await claro, o próximo passo natural é entender as diferenças entre setImmediate(), process.nextTick() e setTimeout(fn, 0) no Node.js — três formas de adiar execução com ordens de prioridade distintas dentro do event loop da libuv. O raciocínio é o mesmo; a camada é um nível abaixo.