A sintaxe async/await do JavaScript deveria nos salvar do callback hell. Em vez disso, ela introduziu um problema mais silencioso e insidioso: código que parece correto, mas se comporta de forma imprevisível. Você vê o await ali no corpo da função e assume que tudo faz uma pausa educada, linha por linha. Muitas vezes, não é o que acontece. Loops avançam de forma acelerada. Lotes inteiros colapsam devido a uma única requisição que falhou. Arquivos de entrada criam wrappers async feios sem motivo algum. Se você já passou por algum desses problemas, estes três padrões irão resolvê-los.
Pare de usar await dentro de forEach
Aqui está um erro comum que parece inofensivo à primeira vista:
const urls = ['/api/user', '/api/posts', '/api/comments'];
urls.forEach(async (url) => {
const res = await fetch(url);
const data = await res.json();
console.log(data);
});
console.log('All done!');
Execute isso, e 'All done!' será impresso antes mesmo de uma única resposta retornar. Por quê? O forEach executa o callback para cada elemento imediatamente. Ele não espera pela promise dentro de cada iteração. A palavra-chave async transforma cada callback em uma promise que o forEach ignora prontamente. Seu loop termina em microssegundos; as requisições de rede seguem seu próprio caminho. Se você precisa que os erros sejam tratados em ordem, ou se precisa garantir que uma requisição termine antes que a próxima comece, este padrão quebra silenciosamente ambas as garantias.
Substitua por um loop for...of:
const urls = ['/api/user', '/api/posts', '/api/comments'];
for (const url of urls) {
const res = await fetch(url);
const data = await res.json();
console.log(data);
}
console.log('All done!');
Agora o loop realmente para em cada await. A segunda requisição espera pela primeira. 'All done!' é impresso apenas depois que tudo se resolve.
Use for...of quando a sequência importar — como fazer upload de arquivos um por um para respeitar limites de taxa (rate limits), escrever linhas no banco de dados em uma ordem específica ou encadear chamadas de API onde a próxima requisição precisa de dados da resposta anterior. Se você realmente deseja execução paralela, não tente improvisar com o forEach. Utilize o Promise.all explicitamente para que sua intenção seja visível para o próximo desenvolvedor. Mas nunca misture await e forEach esperando um comportamento síncrono. Isso não vai acontecer.
Utilize Promise.allSettled quando o zero não puder ser a resposta
O Promise.all é semanticamente honesto. Você passa um array de promises e ele retorna um array de resultados. O problema é que, no momento em que qualquer única promise é rejeitada, tudo é rejeitado imediatamente. Todas as outras promises pendentes são deixadas para terminar por conta própria, mas você perde o acesso aos resultados delas. Em produção, esse comportamento de "tudo ou nada" é prejudicial.
Imagine que sua aplicação busca os widgets de um dashboard de quatro serviços independentes: análise de tráfego, dados de receita, feedback de usuários e integridade do servidor. A API de receita sofre um breve timeout. Com o Promise.all, seu dashboard inteiro lança um erro. As três respostas saudáveis desaparecem no vazio. O usuário vê um spinner e, em seguida, uma tela de erro, porque apenas um quarto dos dados apresentou problemas.
O Promise.allSettled oferece um contrato mais sensato. Ele espera até que cada uma das promises termine, independentemente do resultado. O valor resolvido é um array de objetos descrevendo cada desfecho:
const requests = [
fetch('/api/traffic'),
fetch('/api/revenue'),
fetch('/api/feedback'),
fetch('/api/health')
];
const results = await Promise.allSettled(requests);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
renderWidget(index, result.value);
} else {
renderError(index, result.reason);
}
});
Nenhuma resposta é descartada. Você renderiza o que for possível e isola a falha. Este padrão é importante sempre que você estiver lidando com operações não relacionadas — notificações em massa, disparos de webhooks de terceiros ou importação de registros de múltiplos fluxos de CSV. Você ainda precisará de um rastreamento de erros centralizado, mas sua aplicação manterá a estabilidade.
Uma observação prática: o allSettled retorna o conjunto completo, então você ainda precisará filtrar os resultados e decidir o que "sucesso parcial" significa para sua funcionalidade. Não trate o array retornado como dados uniformemente bem-sucedidos. Verifique os campos de status antes de enviar qualquer coisa para sua camada de estado.
Declare top-level await e elimine o wrapper IIFE
Durante anos, se você quisesse aguardar algo na raiz de um arquivo, você o envolvia em uma função async invocada imediatamente (IIFE):
(async () => {
const config = await loadConfig();
startServer(config);
})();
Isso funciona, mas é ruído. O top-level await, nativo em ES modules, permite que você elimine esse boilerplate cerimonial:
const config = await loadConfig();
startServer(config);
Use isso no ponto de entrada da sua aplicação ou em módulos de configuração dedicados, onde a inicialização deve ser concluída antes que qualquer outra coisa seja executada. Carregar arquivos de ambiente, estabelecer um pool de conexões com o banco de dados ou buscar feature flags remotas são casos de uso naturais. Como o top-level await bloqueia a execução do grafo de módulos — outros arquivos que importam este aguardarão a resolução da sua promise — você obtém um estado garantido. O restante do seu código pode importar o db sabendo que a conexão já está ativa.
Existem duas pegadinhas. Primeiro, seu runtime ou bundler deve suportar ES modules. No Node.js, isso significa usar extensões .mjs ou definir "type": "module" no seu package.json. Segundo, como a espera no nível do módulo atrasa todos os importadores, mantenha o trabalho aguardado focado. Requisições sequenciais pesadas no topo de um arquivo de utilitários importado com frequência atrasarão o cold start de toda a sua aplicação. Reserve o top-level await para tarefas de bootstrap reais das quais outros módulos dependem genuinamente.
O que realmente muda quando você adota esses padrões
A previsibilidade é o primeiro benefício. Quando você lê um loop for...of, você sabe exatamente quando o bloco abaixo termina. Não há promessas fantasmas correndo nos bastidores, nem callbacks de forEach se desprendendo de seus manipuladores de erro. Seu fluxo de controle corresponde ao formato do código na tela.
A resiliência vem em seguida. O Promise.allSettled força você a pensar em falhas parciais em vez de esperar que todos os sistemas externos permaneçam perfeitos. Software de produção não é binário. Alguns endpoints falharão intermitentemente. Algumas leituras de arquivos encontrarão erros de permissão. Projetar para a realidade de falhas espalhadas mantém sua aplicação de pé sem varrer dados legítimos para debaixo do tapete.
A clareza une tudo. O for...of é lido como uma progressão em linguagem natural. O allSettled declara sua intenção em seu nome. O top-level await remove wrappers IIFE crípticos para que seus arquivos de entrada comecem com lógica de negócio em vez de acrobacias sintáticas. O próximo engenheiro que tocar no arquivo — seja você daqui a seis meses ou um colega de equipe sob um prazo apertado — agradecerá.
Uma lição prática
Não trate o async/await como uma solução global que você espalha sobre o código existente. Audite seus projetos atuais em busca desses três anti-padrões específicos. Procure por await dentro de blocos forEach e substitua-os por for...of ou um Promise.all intencional. Revise cada Promise.all que se comunica com serviços externos e pergunte-se se uma única falha deve realmente sabotar toda a operação; se não, mude para Promise.allSettled e trate os resultados mistos. Por fim, remova as IIFEs assíncronas de seus pontos de entrada de módulos ES e deixe o top-level await lidar diretamente com sua sequência de bootstrap. Essas são pequenas mudanças mecânicas, mas, juntas, transformam scripts assíncronos frágeis em código no qual você pode realmente confiar.
