Execute isto no Node ou no console do navegador:

1
2
3
4
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");

O resultado é 1 4 3 2. Um setTimeout com um delay de 0ms ainda é executado depois do callback da promise, e os dois esperam que todas as linhas de código síncrono abaixo deles terminem primeiro. O async/await é construído sobre essa ordem, não é algo novo. A maior parte do comportamento surpreendente do JavaScript assíncrono vem de tratar o await como se ele pausasse o programa inteiro, quando na verdade ele só pausa a função em que está.

Por que o setTimeout roda depois de uma promise sem delay

O JavaScript executa seu código em uma única thread com uma única call stack. As funções entram nela quando são chamadas e saem quando retornam, e nada mais roda enquanto a stack tiver algo empilhado. Timers, respostas de rede e resoluções de promise são tratados pelo runtime que envolve a stack (o navegador, ou a libuv no Node), que coloca um callback numa fila para ser executado depois.

Em qual fila o callback cai depende do tipo de trabalho que ele representa:

  • A task queue (também chamada de macrotask queue) guarda os callbacks de setTimeout e setInterval, conclusões de I/O e eventos de UI. O event loop retira exatamente uma task dessa fila por iteração.
  • A microtask queue guarda os callbacks de promise (.then, .catch, .finally) e qualquer coisa agendada com queueMicrotask. O event loop esvazia essa fila por completo, incluindo microtasks adicionadas durante o próprio esvaziamento, antes de seguir adiante.

Uma iteração do loop funciona assim:

1
2
3
4
5
6
executa todo o código síncrono até a call stack ficar vazia
  -> esvazia completamente a microtask queue (novas microtasks entram na mesma fila)
    -> executa exatamente uma task da task queue
      -> esvazia a microtask queue de novo
        -> (somente no navegador) repaint se necessário
          -> repete

Assim, no trecho acima, console.log("4") encerra a execução síncrona, a microtask queue se esvazia e imprime 3, e só então o loop pega o callback do timer que imprime 2. Se você já trabalhou com uma fila de mensagens, o modelo é familiar: o trabalho espera na fila e um único consumidor processa um item por vez. A diferença é que o JavaScript tem duas filas, e a fila de microtasks é sempre atendida primeiro.

Um efeito colateral que vale conhecer: como a microtask queue precisa estar vazia antes de qualquer task rodar, uma cadeia de promises que continua agendando novos callbacks de promise impede os timers de rodar e, no navegador, bloqueia os repaints.

Onde o await realmente pausa a execução

Uma função async sempre retorna uma promise, mesmo sem return e sem await no corpo. O await pausa apenas essa função: tudo depois dele se torna uma continuação que roda como uma microtask quando o valor esperado se resolve. É só uma camada de sintaxe sobre o .then(), não um mecanismo diferente.

1
2
3
4
5
6
7
8
9
async function example() {
  console.log("a");
  await null;
  console.log("b");
}

console.log("start");
example();
console.log("end");

Isso imprime start, a, end, b. example() roda de forma síncrona até await null, e então devolve o controle para quem a chamou. console.log("end") roda antes de example ser retomada, porque a retomada é colocada na fila como uma microtask, e microtasks esperam o código síncrono atual terminar.

Esse único fato — o await cede o controle para a microtask queue e nada além disso — explica todos os erros abaixo. É também por isso que o async/await substituiu as cadeias de .then(): uma cadeia que trata erros em três profundidades diferentes é o fluxo de controle emaranhado que o spaghetti code descreve, e instruções await sequenciais colocam a ordem das operações de volta na página, de cima para baixo.

O que acontece quando você esquece a palavra-chave await

Esqueça um await e você recebe o objeto promise em vez do resultado. Nenhum erro, apenas um dado errado três linhas depois:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function printUser(id) {
  const user = getUser(id); // missing await
  console.log(user.name);
}

printUser(4821); // "undefined" — user is a Promise, not the resolved object

getUser(4821) retorna imediatamente com uma Promise pendente, e uma Promise não tem propriedade name. A correção é uma palavra: const user = await getUser(id). O que torna esse bug caro é o silêncio. Nada lança um erro na linha problemática; você encontra undefined mais adiante, ou [object Promise] dentro de uma template string.

Você deve usar await em loop ou Promise.all?

O await dentro de um loop for termina cada iteração antes de começar a próxima, mesmo quando as iterações não dependem umas das outras:

1
2
3
4
5
6
7
async function loadUsers(ids) {
  const users = [];
  for (const id of ids) {
    users.push(await getUser(id)); // one request at a time
  }
  return users;
}

Se cada chamada a getUser leva 200ms e ids tem três itens, isso leva cerca de 600ms. A segunda requisição só começa depois que a primeira resposta chega. Quando as requisições são independentes, dispare todas de uma vez e aguarde o lote:

1
2
3
async function loadUsers(ids) {
  return Promise.all(ids.map((id) => getUser(id)));
}

Agora as três requisições disparam juntas e a função retorna em cerca de 200ms, o tempo da mais lenta. O Promise.all rejeita no instante em que uma única promise falha. Se você precisa de todos os resultados independentemente de falhas individuais, o Promise.allSettled dá um status para cada uma em vez de interromper tudo.

O await sequencial é a escolha certa em dois casos. Um: cada iteração precisa do resultado anterior, como paginar uma API usando um cursor da resposta anterior. Dois: você está limitando deliberadamente as chamadas a um endpoint com rate limit que rejeitaria uma rajada de chamadas concorrentes. Para um array ids grande, o próprio Promise.all pode cair nesse segundo problema, então limite a concorrência com um pool pequeno ou uma biblioteca como p-limit.

Por que o async forEach não espera

O forEach chama seu callback para cada elemento e descarta o que quer que o callback retorne, promise incluída:

1
2
3
4
5
6
7
function printAll(ids) {
  ids.forEach(async (id) => {
    const user = await getUser(id);
    console.log(user.name);
  });
  console.log("done");
}

"done" é impresso antes de qualquer nome de usuário. O forEach dispara os três callbacks async e retorna sem esperar por nenhum deles, e você não pode usar await nele porque ele retorna undefined, não uma promise. Para trabalho sequencial, use for...of com await dentro. Para trabalho concorrente, use a mesma correção do loop acima:

1
2
3
4
async function printAll(ids) {
  const users = await Promise.all(ids.map((id) => getUser(id)));
  users.forEach((user) => console.log(user.name));
}

Isso funciona porque map retorna um array de promises, que é exatamente o que o Promise.all recebe.

Por que uma unhandled promise rejection derruba o Node

Uma função async que lança um erro produz uma promise rejeitada, não uma exceção no stack frame de quem a chamou:

1
2
3
4
5
async function risky() {
  throw new Error("boom");
}

risky(); // called with no .catch and no surrounding try/catch

Em uma aba do navegador, isso registra Uncaught (in promise) no console e a página continua rodando. O Node é mais rígido: desde o Node 15, uma rejeição não tratada conta como uma exceção não capturada por padrão, e o processo termina. Trate as rejeições onde elas acontecem, com try/catch em volta do await ou um .catch() na chamada. Depois, como uma rede de último recurso, adicione um handler global:

1
2
3
process.on("unhandledRejection", (reason) => {
  console.error("unhandled rejection:", reason);
});

Registrar esse listener substitui o crash padrão do Node, então o processo agora continua rodando depois de uma rejeição sobre a qual ele não sabe nada. Use-o para registrar o erro e então encerrar de forma limpa (chame process.exit(1) depois de descarregar os logs), não para mascarar handlers ausentes.

Por que o try/catch não captura erros em callbacks

Um try/catch só vê exceções lançadas enquanto seu bloco está em execução. Código agendado para depois, em outra task ou microtask, está fora de alcance mesmo que esteja escrito na linha seguinte:

1
2
3
4
5
6
7
try {
  setTimeout(() => {
    throw new Error("late");
  }, 100);
} catch (err) {
  console.log("caught:", err.message); // never runs
}

O setTimeout agenda o callback e retorna, então o bloco try termina imediatamente. Quando o callback lança o erro 100ms depois, ele roda numa call stack nova, sem nenhum catch acima dele.

O await é diferente. A função async fica suspensa, não terminada: seu estado local e seu bloco try são preservados, e quando a promise esperada rejeita, a função é retomada lançando a rejeição na linha do await, dentro do try:

1
2
3
4
5
6
7
8
async function loadUser(id) {
  try {
    return await getUser(id);
  } catch (err) {
    console.error(`failed to load user ${id}:`, err.message);
    throw err;
  }
}

Note o return await. Com um simples return getUser(id), a função devolve a promise direto, sem esperar por ela, e uma rejeição pula esse catch completamente.

Como pegar esses bugs antes de irem para produção

A maioria desses erros é detectável de forma estática, então deixe o linter encontrá-los em vez do event loop. No ESLint, ative o no-await-in-loop para sinalizar awaits sequenciais (e depois desative-o inline onde o sequenciamento é intencional) e o no-async-promise-executor. Se você usa TypeScript, adicione o @typescript-eslint/no-floating-promises e o @typescript-eslint/no-misused-promises: o primeiro pega promises que não são nem aguardadas nem tratadas, o segundo pega callbacks async passados para lugares como forEach que ignoram a promise retornada. Quando o resultado ainda vier numa ordem que você não esperava, anote em qual fila cada callback cai antes de tocar no código.

Artigos relacionados