Promises em JavaScript: o que dev intermediário erra (e como corrigir)
← Voltar para Codeshort

Promises em JavaScript: o que dev intermediário erra (e como corrigir)

Do callback hell ao async/await sem lacunas: encadeamento, métodos estáticos, armadilhas de produção e os erros que aparecem em PRs reais.

DC
Dev Code Software
08 de abril de 2026·12 min de leitura

Índice


O problema que Promises vieram resolver

Callback hell não é só feio — é um problema real de raciocínio. Você perde o fio de execução, o tratamento de erro se fragmenta em vários lugares, e qualquer refatoração vira pesadelo. Se você já tentou debugar algo assim num sistema em produção, sabe exatamente do que estou falando:

getUser(id, function(err, user) {
  if (err) return handleError(err);
  getOrders(user.id, function(err, orders) {
    if (err) return handleError(err);
    getInvoice(orders[0].id, function(err, invoice) {
      if (err) return handleError(err);
    });
  });
});

Cada nível de indentação é um nível a mais de contexto mental que você precisa manter. Cada if (err) repetido é uma armadilha esperando o momento em que alguém esquece de colocar o return. O custo real não está no estilo — está nas horas de debug e nas regressões silenciosas.

Promises chegaram no ES6 para linearizar esse fluxo. Não para eliminar a assincronia — para torná-la gerenciável. A ideia central é simples: uma Promise representa um valor que ainda não existe, mas que vai existir (ou falhar) no futuro.


Anatomia de uma Promise

Uma Promise é um objeto que encapsula uma operação assíncrona e expõe seu resultado quando ela termina — com sucesso ou com falha.

const promise = new Promise((resolve, reject) => {
  const sucesso = true;

  if (sucesso) {
    resolve("valor resultante");
  } else {
    reject(new Error("algo deu errado"));
  }
});

Os três estados possíveis

Uma Promise tem exatamente três estados: pending (ainda em execução), fulfilled (resolvida com sucesso) e rejected (falhou). Uma vez que sai de pending, o estado não muda mais — isso é garantia da spec ECMAScript e significa que você nunca vai receber dois resultados diferentes da mesma Promise.

pending ──→ fulfilled (resolve foi chamado)
       ╰──→ rejected  (reject foi chamado ou exceção lançada)

Na prática, você raramente cria Promises do zero. A maioria das APIs modernas já retorna Promises: fetch, fs.promises, drivers de banco de dados, clientes HTTP. O que você precisa dominar é como consumi-las.

Atenção ao executor síncrono: a função passada para new Promise() roda de forma síncrona. O que é assíncrono é a resolução. Isso pega muita gente desprevenida:

console.log("antes");

new Promise((resolve) => {
  console.log("dentro do executor");
  resolve("ok");
});

console.log("depois");

// Saída:
// antes
// dentro do executor
// depois

Encadeamento com .then()

Aqui está o poder real das Promises. Cada .then() retorna uma nova Promise, o que permite encadear operações sequenciais de forma legível.

Imagine um fluxo de e-commerce: buscar o usuário, depois buscar os pedidos dele, depois buscar a fatura do pedido mais recente.

fetch("/api/users/42")
  .then(response => response.json())
  .then(user => fetch(`/api/orders?userId=${user.id}`))
  .then(response => response.json())
  .then(orders => fetch(`/api/invoices/${orders[0].id}`))
  .then(response => response.json())
  .then(invoice => {
    renderInvoice(invoice);
  });

O que está acontecendo: cada .then() recebe o valor resolvido da Promise anterior. Se o callback retorna um valor simples, ele é passado para o próximo .then(). Se retorna uma Promise, o chain espera ela resolver antes de continuar.

Promise.resolve(1)
  .then(n => n + 1)
  .then(n => n * 3)
  .then(console.log);
// → 6

Promise.resolve(1)
  .then(n => Promise.resolve(n + 1))
  .then(n => n * 3)
  .then(console.log);
// → 6 (o chain desempacota a Promise automaticamente)

Nos dois casos o resultado é o mesmo. Isso é importante: o chain sempre "achata" uma Promise retornada, nunca entrega uma Promise dentro de outra.


Tratamento de erros: .catch() e .finally()

.catch(fn) é equivalente a .then(null, fn) — captura qualquer rejeição no chain anterior, independente de onde ela aconteceu.

fetch("/api/dados")
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return response.json();
  })
  .then(dados => processar(dados))
  .catch(err => {
    console.error("Falhou em algum ponto do chain:", err.message);
  })
  .finally(() => {
    esconderLoading();
  });

.finally() roda sempre — independente de sucesso ou falha — e não recebe o valor resolvido nem o erro. É o lugar certo para limpar estado: esconder loaders, fechar conexões, liberar locks.

O comportamento que pega muita gente

Um .catch() que não relança o erro transforma a Promise de volta para fulfilled:

Promise.reject(new Error("ops"))
  .catch(err => {
    console.log("capturou:", err.message);
    return "recuperado";
  })
  .then(val => console.log(val));
// → capturou: ops
// → recuperado

Se você colocar um .then() depois do .catch(), a execução continua normalmente com o valor retornado pelo catch. Isso é útil para fallbacks, mas pode gerar comportamentos inesperados se você não estiver prestando atenção na estrutura do chain.


Promise.all, Promise.race e os outros métodos estáticos

Quando você precisa coordenar múltiplas Promises ao mesmo tempo, os métodos estáticos são o caminho.

Promise.all — paralelo, falha rápido

Aguarda todas resolverem. Se qualquer uma rejeitar, rejeita imediatamente com o erro dessa Promise.

const [usuario, pedidos, config] = await Promise.all([
  buscarUsuario(id),
  buscarPedidos(id),
  buscarConfig(),
]);

Se cada operação leva 300ms, Promise.all termina em ~300ms. Em sequência com três await separados, seriam ~900ms. Parece óbvio, mas é comum ver código encadeando await desnecessariamente quando as operações não dependem uma da outra.

Promise.allSettled — paralelo, sem falha rápida

Como Promise.all, mas nunca rejeita. Retorna o resultado de cada Promise com seu status.

const resultados = await Promise.allSettled([
  buscarDados("fonte-a"),
  buscarDados("fonte-b"),
  buscarDados("fonte-c"),
]);

resultados.forEach(resultado => {
  if (resultado.status === "fulfilled") {
    processar(resultado.value);
  } else {
    logErro(resultado.reason);
  }
});

Use quando falhas parciais são toleráveis — por exemplo, buscar dados de múltiplas fontes e processar o que vier.

Promise.race e Promise.any

Promise.race — resolve ou rejeita com a primeira Promise que finalizar, independente do resultado.

Promise.any — resolve com a primeira que tiver sucesso. Só rejeita se todas falharem.

const timeout = new Promise((_, reject) =>
  setTimeout(() => reject(new Error("Timeout após 5s")), 5000)
);

const resultado = await Promise.race([fetchDados(), timeout]);

Tabela de comparação

MétodoExecuçãoQuando rejeitaCaso de uso
Promise.allParalelaQualquer falhaOperações obrigatórias, todas necessárias
Promise.allSettledParalelaNuncaBatch com falhas toleráveis
Promise.raceParalelaPrimeira rejeição ou resoluçãoTimeout, corrida entre fontes
Promise.anyParalelaTodas falharemFallback automático entre fontes
await sequencialSequencialPrimeira falhaOperações dependentes em série

Async/Await é só açúcar sintático — mas muda tudo

async/await não substitui Promises — compila para elas. O que muda é a legibilidade e o controle de escopo.

function buscarDadosUsuario(id) {
  return getUser(id)
    .then(user => getProfile(user.profileId))
    .then(profile => ({ ...user, profile }));
}

Esse código tem um bug silencioso: user não existe no escopo do segundo .then(). Cada callback tem seu próprio escopo isolado, então você precisa passar dados adiante explicitamente ou aninhar .then() — o que nos leva de volta ao callback hell.

async function buscarDadosUsuario(id) {
  const user = await getUser(id);
  const profile = await getProfile(user.profileId);
  return { ...user, profile };
}

Com async/await, user está disponível em toda a função. O fluxo é linear, o escopo é limpo, e o stack trace quando algo falha aponta para a linha exata.

Esse bug específico apareceu num PR de uma empresa que trabalhei. O comentário do revisor foi curto: "Vai funcionar? Talvez. Vai ser mantido daqui a seis meses? Não."


Armadilhas de produção: erros que já vi em PRs reais

1. Esquecer de retornar a Promise dentro do .then()

.then(user => {
  getProfile(user.id);
})
.then(profile => {
  console.log(profile);
})

getProfile() é chamado, mas o chain não espera ele resolver. O próximo .then() recebe undefined. O erro não aparece no console — o código silenciosamente faz a coisa errada.

.then(user => {
  return getProfile(user.id);
})
.then(profile => {
  console.log(profile);
})

2. Await dentro de .forEach()

ids.forEach(async id => {
  await processarItem(id);
});
console.log("feito");

forEach não tem como esperar callbacks assíncronos. Ele dispara todos os processarItem e segue em frente. O console.log("feito") roda antes de qualquer item terminar de processar. Sem erro, sem aviso — comportamento não intencional silencioso.

for (const id of ids) {
  await processarItem(id);
}

await Promise.all(ids.map(id => processarItem(id)));

Use for...of quando precisar de sequência. Use Promise.all com .map() para paralelo com controle.

3. Não tratar rejeições

fetch("/api/dados").then(r => r.json()).then(processar);

No Node.js 15+ e em navegadores modernos, uma Promise rejeitada sem handler causa UnhandledPromiseRejection — que pode derrubar o processo ou ser logado como erro crítico.

fetch("/api/dados")
  .then(r => r.json())
  .then(processar)
  .catch(err => console.error(err));

try {
  const dados = await fetch("/api/dados").then(r => r.json());
  processar(dados);
} catch (err) {
  console.error(err);
}

4. Promise dentro de Promise sem await

async function salvar(dados) {
  db.save(dados).then(() => {
    console.log("salvo");
  });
}

A função salvar retorna uma Promise que resolve imediatamente — antes do db.save() terminar. Quem fizer await salvar(dados) vai pensar que o dado foi salvo quando na verdade a operação ainda está em andamento.

async function salvar(dados) {
  await db.save(dados);
  console.log("salvo");
}

5. Misturar .then() e async/await no mesmo módulo sem consistência

Não é errado misturar, mas criar um padrão inconsistente dentro do mesmo arquivo dificulta code review e aumenta a chance de bugs de escopo. Escolha um estilo por módulo e seja consistente.

6. Usar Promise.all quando as operações dependem uma da outra

const [user, orders] = await Promise.all([
  buscarUsuario(id),
  buscarPedidos(id),
]);

Isso funciona porque buscarPedidos recebe o id diretamente. Mas se você precisasse do user.accountId para buscar os pedidos, Promise.all não funcionaria — as duas Promises são criadas ao mesmo tempo, antes de qualquer uma resolver.


FAQ

O que é uma Promise em JavaScript? Uma Promise é um objeto que representa o resultado eventual de uma operação assíncrona. Ela pode estar em três estados: pending (aguardando), fulfilled (concluída com sucesso) ou rejected (falhou). Uma vez que sai de pending, o estado é imutável.

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve("pronto"), 1000);
});

p.then(val => console.log(val));

Uma Promise cancelada para de executar? Não. Promises em JavaScript não são canceláveis nativamente. Uma vez criada, a operação roda até o fim — mesmo que você ignore o resultado. Para cancelamento real, use AbortController com fetch:

const controller = new AbortController();

fetch("/api/dados", { signal: controller.signal })
  .then(r => r.json())
  .catch(err => {
    if (err.name === "AbortError") console.log("cancelado");
  });

controller.abort();

Qual a diferença entre async function e retornar Promise.resolve()? O comportamento final é equivalente — ambas retornam uma Promise. A diferença prática é que async function garante que qualquer exceção lançada internamente seja convertida em rejeição, enquanto uma função comum que lança antes de retornar a Promise vai lançar de forma síncrona e não vai ser capturada por .catch().

function comErroSincrono() {
  throw new Error("ops");
  return Promise.resolve(42);
}

async function comErroAsync() {
  throw new Error("ops");
}

comErroSincrono();
comErroAsync().catch(err => console.error(err));

Promise.all falha rápido. Como processar os que deram certo? Use Promise.allSettled. Ele nunca rejeita — retorna um array com o status de cada Promise:

const resultados = await Promise.allSettled([op1(), op2(), op3()]);
const sucessos = resultados
  .filter(r => r.status === "fulfilled")
  .map(r => r.value);

Por que await fora de async dá erro de sintaxe? Até o ES2022, await só era válido dentro de funções async. A partir do ES2022, top-level await é suportado em módulos ES (arquivos .mjs ou "type": "module" no package.json). Em scripts comuns, ainda precisa de uma função async envolvendo.

Posso usar .then() em funções async? Sim. Uma função async retorna uma Promise, então você pode encadear .then() normalmente. Misturar os dois estilos é legítimo, desde que seja intencional e consistente dentro do módulo.


Checklist antes do próximo deploy

Antes de fazer merge de qualquer código com operações assíncronas, passe por estes pontos:

  • Todo .then() que chama uma função async tem return?
  • Algum forEach está recebendo callback async? Substitua por for...of ou Promise.all com .map()
  • Toda Promise tem um .catch() ou está dentro de um try/catch?
  • Operações independentes estão usando Promise.all em vez de await sequencial?
  • Alguma função async tem Promise interna sem await?
  • O tratamento de erro está no nível certo — não apenas logando, mas respondendo adequadamente?

Próximos passos

  • Estude AbortController se trabalha com fetch e precisa de cancelamento real
  • Conheça p-limit no npm para controle de concorrência quando o volume de operações async crescer
  • Revise código legado que usa forEach com async — o comportamento provavelmente não é o esperado