Promises em JavaScript: estados, encadeamento e os erros que derrubam produção
← Voltar para Codeshort

Promises em JavaScript: estados, encadeamento e os erros que derrubam produção

Entenda como Promises funcionam por dentro — estados imutáveis, encadeamento correto, erros silenciosos e os bugs que só aparecem em produção.

DC
Dev Code Software
29 de abril de 2026·11 min de leitura

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

EstadoSignificaPode mudar?Próximo estado possível
pendingOperação ainda em andamentoSimfulfilled ou rejected
fulfilledConcluída com valorNão— imutável
rejectedConcluída com erroNã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 de return explí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

CombinadorResolve quandoRejeita quandoCaso de uso
Promise.allTodas resolvemQualquer falhaDados obrigatórios
Promise.allSettledSempre (após todas)NuncaMix obrigatório + opcional
Promise.racePrimeira terminarPrimeira rejeitarTimeout, fallback
Promise.anyQualquer resolverTodas falharemRedundâ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.