O bug que você não consegue reproduzir localmente
Imagine o cenário: deploy na sexta à tarde, dashboard carrega em branco para 30% dos usuários, e o Sentry mostra apenas UnhandledPromiseRejection sem stack trace útil. Você adiciona logs, faz rollback, não consegue reproduzir localmente. Dois dias depois, descobre: era uma Promise rejeitada sem .catch() dentro de um loop forEach.
Esse tipo de bug não acontece por ignorância do JavaScript. Acontece porque Promise tem comportamentos específicos que a maioria aprende superficialmente — e que só aparecem em produção, sob carga, com dados reais.
Este guia cobre esses comportamentos: não o que o MDN explica, mas o que o MDN não enfatiza o suficiente.
O que é uma Promise em JavaScript
Uma Promise é um objeto que representa o resultado futuro de uma operação assíncrona. Ela não é a resposta em si — é um proxy para um valor que ainda não existe no momento em que é criada.
const promessa = new Promise((resolve, reject) => {
setTimeout(() => {
resolve("chegou"); // ou reject(new Error("deu ruim"))
}, 1000);
});
Internamente, uma Promise pode estar em apenas um dos três estados — e essa característica tem implicações práticas que a maioria ignora.
Os três estados de uma Promise
| Estado | Significa | Pode mudar? | Próximo estado possível |
|---|---|---|---|
pending | Operação ainda em andamento | Sim | fulfilled ou rejected |
fulfilled | Concluída com valor | Não | — imutável |
rejected | Concluída com erro | Não | — imutável |
O ponto que mais gera bug: uma vez settled (fulfilled ou rejected), a Promise não muda mais. Se você guardar a referência e chamar .then() depois, o callback executa imediatamente com o valor já armazenado.
Isso tem implicação direta em sistemas de cache:
const cache = new Map();
function buscarUsuario(id) {
if (cache.has(id)) {
return cache.get(id); // retorna a mesma Promise já resolvida
}
const p = fetch(`/api/users/${id}`).then(r => r.json());
cache.set(id, p);
return p;
}
// Ambas as chamadas recebem o mesmo objeto Promise.
// A segunda não dispara nova requisição de rede.
buscarUsuario(42).then(u => console.log(u.nome));
buscarUsuario(42).then(u => console.log(u.email));
Encadeamento com .then(): o erro que só aparece em runtime
Cada .then() retorna uma nova Promise. Isso é o que torna o encadeamento possível — e também o que gera os bugs mais difíceis de rastrear, porque o TypeScript geralmente não pega e o ESLint só detecta com plugin específico.
O erro mais comum:
// ❌ res.json() retorna uma Promise, mas sem return
// o próximo .then() recebe undefined
fetch('/api/user')
.then(res => {
res.json(); // sem return!
})
.then(data => {
console.log(data); // undefined. Sempre. Sem erro visível.
});
// ✅ return explícito
fetch('/api/user')
.then(res => res.json())
.then(data => {
console.log(data); // objeto real
});
Regra prática: se você abre chaves
{}dentro de um.then(), precisa dereturnexplícito. Se usa arrow sem chaves, o return é implícito. Misturar os dois estilos na mesma cadeia é receita para bug.
Você pode retornar qualquer coisa dentro do .then(): valor primitivo, objeto, ou outra Promise. Se retornar outra Promise, o próximo .then() aguarda ela resolver:
fetch('/api/user')
.then(res => res.json())
.then(user => fetch(`/api/posts?userId=${user.id}`))
.then(res => res.json())
.then(posts => renderizarPosts(posts))
.catch(err => mostrarErro(err));
O posicionamento correto do .catch()
.catch() não é opcional. É tratamento de erro obrigatório. E o local onde você o coloca muda completamente o comportamento da cadeia.
// ❌ catch antes de .then() não protege os passos seguintes
fetch('/api/user')
.catch(err => console.error(err)) // só pega erro do fetch
.then(res => res.json()) // se res for undefined, explode aqui
.then(user => salvar(user)); // e aqui também
// ✅ catch no final pega qualquer erro da cadeia toda
fetch('/api/user')
.then(res => res.json())
.then(user => salvar(user))
.catch(err => tratarErro(err));
.catch() também retorna uma Promise. Se você não re-lançar o erro, a cadeia continua como se tivesse dado certo — com valor undefined:
fetch('/api/user')
.then(res => res.json())
.catch(err => {
console.error(err);
// sem throw: cadeia continua com undefined
})
.then(user => {
salvar(user); // user é undefined aqui
});
// Para interromper a cadeia, re-lance o erro
fetch('/api/user')
.then(res => res.json())
.catch(err => {
logger.error('Falha crítica:', err);
throw err; // ou: throw new AppError('falhou', { cause: err })
})
.then(user => salvar(user));
Promise.all, Promise.allSettled, Promise.race e Promise.any
Quando você precisa de múltiplas operações assíncronas, encadeá-las sequencialmente com await uma por uma é o erro de performance mais comum em code reviews.
Promise.all
Aguarda todas as Promises resolverem. Se qualquer uma rejeitar, rejeita imediatamente (fail-fast) e as outras são ignoradas.
const [usuario, permissoes, configuracoes] = await Promise.all([
buscarUsuario(id),
buscarPermissoes(id),
buscarConfiguracoes(id)
]);
// Tempo total = tempo da mais lenta, não somatório
Promise.allSettled
Igual ao all, mas nunca rejeita. Você recebe um array com o status de cada Promise — útil quando algumas são opcionais.
const resultados = await Promise.allSettled([
fetch('/api/critico').then(r => r.json()),
fetch('/api/opcional').then(r => r.json())
]);
for (const r of resultados) {
if (r.status === 'fulfilled') usar(r.value);
else logar(r.reason);
}
Promise.race
Resolve (ou rejeita) com a primeira que terminar. Padrão clássico para timeout manual:
function comTimeout(promise, ms) {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error(`Timeout após ${ms}ms`)), ms)
);
return Promise.race([promise, timeout]);
}
const dados = await comTimeout(fetch('/api/lento'), 5000);
Quando usar cada um
| Combinador | Resolve quando | Rejeita quando | Caso de uso |
|---|---|---|---|
Promise.all | Todas resolvem | Qualquer falha | Dados obrigatórios |
Promise.allSettled | Sempre (após todas) | Nunca | Mix obrigatório + opcional |
Promise.race | Primeira terminar | Primeira rejeitar | Timeout, fallback |
Promise.any | Qualquer resolver | Todas falharem | Redundância de fonte |
async/await: o que ninguém te conta sobre performance
async/await é açúcar sintático sobre Promises. Uma função async sempre retorna uma Promise — mesmo que você retorne um valor primitivo.
O bug de performance mais silencioso:
// ❌ Sequencial desnecessário
// Tempo total = t(user) + t(posts) + t(prefs) ≈ 600ms
async function carregarDashboard(userId) {
const user = await buscarUsuario(userId);
const posts = await buscarPosts(userId);
const prefs = await buscarPreferencias(userId);
return { user, posts, prefs };
}
// ✅ Paralelo com Promise.all
// Tempo total = máximo entre os três ≈ 210ms
async function carregarDashboard(userId) {
const [user, posts, prefs] = await Promise.all([
buscarUsuario(userId),
buscarPosts(userId),
buscarPreferencias(userId)
]);
return { user, posts, prefs };
}
Em uma aplicação real com 3 endpoints de 200ms cada: a versão sequencial leva ~600ms, a paralela ~210ms. A diferença é perceptível pelo usuário — e invisível no código sem prestar atenção.
Erros silenciosos: a armadilha que derruba produção
No Node.js 15+, uma Promise rejeitada sem tratamento derruba o processo. No browser, vira um aviso no console — fácil de ignorar em desenvolvimento, impossível de depurar em produção.
O problema do forEach com async
// ❌ forEach não é Promise-aware
// Todas executam em paralelo sem controle
// Erros são silenciados completamente
ids.forEach(async (id) => {
await processarItem(id); // erro aqui nunca é capturado
});
// ✅ Sequencial controlado com for...of
for (const id of ids) {
await processarItem(id);
}
// ✅ Paralelo controlado com Promise.all
await Promise.all(ids.map(id => processarItem(id)));
// ✅ Paralelo com limite de concorrência (biblioteca p-limit)
import pLimit from 'p-limit';
const limit = pLimit(5); // máximo 5 simultâneos
await Promise.all(ids.map(id => limit(() => processarItem(id))));
Tratamento correto em funções async
// ❌ Erro silencioso
async function salvar(dados) {
await db.insert(dados); // e se der erro?
}
// ✅ Tratamento explícito com contexto
async function salvar(dados) {
try {
await db.insert(dados);
} catch (err) {
logger.error('Falha ao salvar:', { dados, err });
throw new AppError('persistencia_falhou', { cause: err });
}
}
Detectar rejeições não tratadas globalmente
// Node.js
process.on('unhandledRejection', (reason, promise) => {
logger.fatal('Promise não tratada:', { reason });
});
// Browser
window.addEventListener('unhandledrejection', event => {
analytics.track('promise_error', { reason: event.reason });
});
FAQ
Posso misturar .then() e async/await no mesmo código?
Sim, tecnicamente funcionam juntos. Mas dentro de um mesmo fluxo de código, escolha um estilo e mantenha. Trocar entre os dois na mesma função dificulta leitura e revisões de código.
Promise.resolve() tem alguma vantagem sobre retornar o valor diretamente?
Sim: Promise.resolve() é idempotente. Se o valor já for uma Promise, ela retorna a mesma instância sem criar uma nova. Útil em funções genéricas que precisam garantir que o retorno é sempre uma Promise, independente do input.
async/await tem overhead de performance?
Não mensurável em aplicações reais. Motores modernos (V8, SpiderMonkey) otimizam as duas formas de forma equivalente. O gargalo de performance em código async quase sempre é sequencialidade desnecessária, não a sintaxe.
O que é top-level await e quando usar?
Permite usar await fora de funções async, direto no nível do módulo. Funciona em módulos ES (.mjs ou "type": "module"). Útil para inicialização de aplicações, conexões de banco de dados e carregamento de configurações no startup.
Qual a diferença entre throw dentro de um .then() e chamar reject()?
Dentro de um .then(), lançar um erro com throw tem o mesmo efeito de rejeitar a Promise — o próximo .catch() captura. reject() só está disponível no callback do construtor new Promise(...). Fora disso, throw é o caminho correto.
Próximos passos
Se você chegou até aqui, já entende Promises em um nível que a maioria dos devs que "só usa async/await" nunca vai ter. Os próximos pontos naturais são:
- AbortController — para cancelar fetches e Promises que você não quer mais esperar
- Generators e iteradores assíncronos — para processar streams de dados com
for await...of - Microtask queue vs macrotask queue — por que
setTimeout(fn, 0)executa depois de uma Promise resolvida mesmo com delay zero
Para fixar o que aprendeu aqui: abra o DevTools, crie algumas Promises no console e observe os estados transitando. Escreva um forEach com async e veja o comportamento no Network. Não tem substituto para isso.