Índice
- O que
asyncrealmente faz com uma função - Microtasks, macrotasks e a ordem exata do event loop
- Como
awaitsuspende sem travar o thread - A pilha de execução desmontada linha por linha
- Os bugs silenciosos que consomem manhãs de debug
- Sequencial vs paralelo: onde a latência vai embora
- Guia de referência: Promise.all, allSettled, any e race
- Checklist: async/await sem surpresas
- FAQ
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.
| Fila | O que vai para lá |
|---|---|
| Microtask queue | .then(), .catch(), .finally(), queueMicrotask(), MutationObserver |
| Macrotask queue | setTimeout(), 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
awaitnão é necessariamente errado, mas significa que você está descartando a Promise retornada. Qualquer erro interno vira umaUnhandledPromiseRejection. 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étodo | Resolve quando | Rejeita quando | Caso de uso |
|---|---|---|---|
Promise.all | Todas resolvem | Qualquer uma rejeita | Operações independentes que precisam de todos os resultados |
Promise.allSettled | Todas completam | Nunca rejeita | Processar resultados mesmo com falhas parciais |
Promise.any | A primeira resolve | Todas rejeitam (AggregateError) | Múltiplas fontes — pegar a mais rápida a responder |
Promise.race | A primeira completa (resolve ou reject) | A primeira rejeita | Timeout: 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:
awaitestá dentro de umaasync functionou em top-level ESM?- Toda async function chamada sem
awaittem um.catch()explícito? - Iterações assíncronas usam
for...of(sequencial) ouPromise.all+.map()(paralelo)? - Múltiplos
awaits independentes foram substituídos porPromise.all? try/catchenvolve apenas chamadas que têmawait?- 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.