Promises por dentro

TL;DR

Uma Promise tem 3 estados: pending, fulfilled e rejected. Uma vez liquidada (settled), é imutável — não pode voltar a pending nem mudar de estado. .then(cb) não executa cb imediatamente: enfileira cb na microtask queue, mesmo que a promise já esteja resolvida. Encadeamento de .then devolve uma nova promise a cada chamada; erros propagam automaticamente até o primeiro .catch. Promise.resolve(x) é açúcar sintático para criar uma promise já fulfillada — equivalente a new Promise((res) => res(x)), mas mais legível. Em Node.js, promises rejeitadas sem handler disparam o evento unhandledRejection; no Node 15+ o padrão passou a terminar o processo.

Por que .then() chama o callback mesmo quando a Promise já está resolvida — de forma assíncrona?

Se uma Promise já tem valor, por que não chamar o callback imediatamente? E por que esquecer um return dentro de .then() silencia erros sem aviso? As respostas estão na máquina de estados e na semântica da microtask queue que governa o comportamento interno de Promises.

O que é

Uma Promise é um objeto que representa o resultado eventual de uma operação assíncrona. Ela age como um proxy: você não tem o valor ainda, mas tem um objeto ao qual pode encadear handlers que serão executados quando o valor chegar (ou quando a operação falhar).

Os três estados

stateDiagram-v2
    [*] --> pending: new Promise(executor)
    pending --> fulfilled: resolve(value)
    pending --> rejected: reject(reason)
    fulfilled --> [*]: imutável
    rejected --> [*]: imutável

    note right of pending: executor roda síncronamente\nno construtor
    note right of fulfilled: .then(cb) → cb enfileirado\nna microtask queue
    note right of rejected: .catch(cb) → cb enfileirado\nna microtask queue

Uma promise liquidada (settled) é toda promise que não está mais em pending — seja ela fulfilled ou rejected. A especificação garante que, após atingir qualquer um dos estados finais, a promise nunca muda. Isso torna promises seguras para múltiplos consumidores: você pode chamar .then() dezenas de vezes na mesma promise e cada handler recebe o mesmo valor.

O termo resolved (resolvida) tem um significado técnico mais sutil: uma promise está “resolved” quando sua trajetória foi fixada — seja com um valor direto, seja “seguindo” outra promise (thenable). Uma promise pode estar resolved mas ainda pending, se ela foi resolvida com outra promise que ainda não liquidou.

O executor e sua execução síncrona

const p = new Promise((resolve, reject) => {
  // Este bloco executa AGORA, de forma síncrona
  console.log('executor rodando');
  resolve(42);
});
 
console.log('depois do new Promise');
// Output:
// executor rodando
// depois do new Promise

O executor roda síncronamente dentro do construtor — é o único ponto síncrono da API de Promise. O que acontece depois de resolve() ou reject() é que os handlers registrados com .then() são enfileirados como microtasks — e microtasks só rodam após o código síncrono atual terminar.

O .then() e a microtask queue

A propriedade mais importante e mais mal-compreendida das Promises:

const p = new Promise((resolve) => resolve(42));
 
p.then((v) => console.log('then:', v));
console.log('síncrono');
 
// Output:
// síncrono
// then: 42

Mesmo que p já esteja fulfillada no momento em que .then() é chamado, o callback não roda imediatamente. Ele é enfileirado na microtask queue e só executa depois que todo o código síncrono do stack frame atual terminar. Isso é uma garantia da spec — handlers de Promise são sempre assíncronos, sem exceção.

Isso significa que o modelo de execução de Promises é previsível: você nunca precisa se perguntar se um .then() vai rodar agora ou depois. A resposta é sempre “depois do síncrono atual”.

Encadeamento — cada .then() retorna uma nova promise

const p1 = Promise.resolve(1);
 
const p2 = p1.then((v) => v + 1);   // p2 é uma NOVA promise
const p3 = p2.then((v) => v * 2);   // p3 é outra promise nova
 
p3.then((v) => console.log(v));     // 4

Cada chamada a .then() cria e retorna uma promise nova. O estado dessa nova promise depende do que o handler retorna:

O handler…A nova promise…
Retorna um valor primitivo vÉ fulfillada com v
Retorna outra promise qSegue o estado de q (torna-se q)
Lança uma exceçãoÉ rejeitada com o erro
Não tem handler para esse ladoPropaga o mesmo estado da promise pai

Essa última linha é o mecanismo de propagação de erros: se uma promise rejeitada não tem handler de rejeição em um .then(), a rejeição passa adiante — como uma exceção não capturada que sobe a pilha.

Por que importa

Promises são a fundação de toda a programação assíncrona moderna em JavaScript. async/await é syntactic sugar sobre Promises — um await expr é essencialmente um .then() em expr, com a diferença que o resto da função async é o callback.

Sem entender que .then() enfileira microtasks (não executa imediatamente), você vai criar bugs sutis de ordem de execução — especialmente ao misturar código síncrono com código async em testes ou em inicialização de serviços.

Sem entender que cada .then() retorna uma promise nova, você vai esquecer return dentro de chains e criar promises soltas que propagam erros silenciosamente — um dos bugs mais difíceis de debugar em codebases JavaScript.

Como funciona

1. Estados e imutabilidade

const p = new Promise((resolve, reject) => {
  resolve(42);
  reject(new Error('ignorado')); // sem efeito — já resolvida
  resolve(99);                   // sem efeito — já resolvida
});
 
p.then((v) => console.log(v)); // 42

Após a primeira chamada a resolve(), qualquer chamada subsequente — seja resolve() ou reject() — é silenciosamente ignorada. Isso é parte do contrato da Promise.

2. Encadeamento com transformações

Promise.resolve(2)
  .then((v) => v + 1)     // 3
  .then((v) => v * 2)     // 6
  .then((v) => {
    console.log(v);        // 6
    return v;
  });

Cada .then() recebe o resultado do anterior e pode transformá-lo. A chain é lazy: os callbacks só rodam quando cada promise predecessora liquida.

3. Propagação de erros

Promise.resolve('ok')
  .then(() => {
    throw new Error('algo deu errado');
  })
  .then(() => {
    console.log('este nunca roda');
  })
  .catch((e) => {
    console.log('capturado:', e.message); // capturado: algo deu errado
  })
  .then(() => {
    console.log('continua após o catch'); // roda normalmente
  });

Erros pulam todos os .then() intermediários que não têm handler de rejeição, e são capturados pelo primeiro .catch(). Após o .catch() completar sem lançar, a chain retorna ao estado fulfilled — o .then() seguinte ao .catch() executa normalmente.

Para rejeitar a chain após um .catch(), basta relançar o erro:

.catch((e) => {
  logger.error(e);
  throw e; // mantém a rejeição propagando
})

4. Promise.resolve(x) vs new Promise((res) => res(x))

// Equivalentes na prática
const p1 = Promise.resolve(42);
const p2 = new Promise((resolve) => resolve(42));
 
// Promise.resolve com thenable — segue o estado
const p3 = Promise.resolve(fetch('/api/data')); // mesma coisa que fetch('/api/data')

Promise.resolve(x) é idiomático para normalizar um valor que pode ser síncrono ou uma promise. Se x já é uma Promise nativa, o runtime retorna x diretamente sem criar uma nova promise — otimização garantida pela spec.

5. Anti-pattern: Promise constructor desnecessário

// RUIM — "Promise constructor anti-pattern"
async function getData(url) {
  return new Promise((resolve, reject) => {
    fetch(url)
      .then((res) => resolve(res))
      .catch((err) => reject(err));
  });
}
 
// BOM — fetch já retorna uma Promise
async function getData(url) {
  return fetch(url);
}

Envolver uma promise em outra promise com o construtor é redundante e perigoso: se o código dentro do executor lançar uma exceção síncrona antes de chamar reject(), você perde o erro — ele vira uma rejeição não tratada invisível. Use o construtor apenas quando estiver adaptando uma API baseada em callbacks.

6. Deferred pattern — válido, mas evite como padrão

O “deferred pattern” extrai resolve e reject para fora do executor, criando um objeto que terceiros podem resolver:

function createDeferred() {
  let resolve, reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}
 
// Uso
const deferred = createDeferred();
 
// Em algum lugar do código
deferred.resolve(42);
 
// Em outro lugar
deferred.promise.then((v) => console.log(v));

Esse padrão tem usos legítimos — coordenação entre sistemas, testes que precisam de controle externo sobre resolução. Mas como padrão geral é um smell: indica que a lógica de resolução deveria estar encapsulada na própria promise, não vazada para fora.

7. unhandledRejection em Node.js

Quando uma promise é rejeitada e nenhum handler de rejeição é registrado dentro de um tick do event loop, o Node.js emite o evento unhandledRejection no objeto process:

process.on('unhandledRejection', (reason, promise) => {
  console.error('Promise rejeitada sem handler:', reason);
  console.error('Promise:', promise);
  // Em produção: exportar para o sistema de logging/alertas
});

O comportamento padrão evoluiu ao longo das versões do Node:

VersãoComportamento padrão
Node < 15Aviso no stderr, processo continua
Node 15+Termina o processo com exit code não-zero
Node 22+Termina o processo (mesmo comportamento)

Terminar o processo é o comportamento correto para a maioria dos cenários de produção: uma promise rejeitada sem handler indica um estado inesperado que o código não sabe tratar — continuar rodando nesse estado é perigoso.

O flag --unhandled-rejections permite controlar o comportamento explicitamente:

node --unhandled-rejections=throw app.js   # lança exceção (padrão Node 15+)
node --unhandled-rejections=warn  app.js   # aviso apenas (legado)
node --unhandled-rejections=none  app.js   # silencia completamente (perigoso)

Nunca silenciar unhandledRejection em produção

--unhandled-rejections=none ou engolir o evento sem logar é uma forma garantida de criar bugs fantasma — erros que ocorrem silenciosamente sem deixar rastro. Sempre logue o reason com contexto suficiente para investigação posterior.

Na prática

Normalização de APIs value-or-promise

Libs que aceitam tanto valores síncronos quanto promises usam Promise.resolve() para normalizar:

async function processInput(input) {
  // input pode ser um valor ou uma promise — não importa
  const value = await Promise.resolve(input);
  return transform(value);
}

Isso evita que o chamador precise saber se a função retorna síncronamente ou não.

Handler de unhandledRejection em produção

Em aplicações Node.js de produção, o handler global é sempre presente e exporta para o sistema de logging:

process.on('unhandledRejection', (reason, promise) => {
  logger.error({
    event: 'unhandled_rejection',
    reason: reason instanceof Error ? reason.stack : String(reason),
  });
  // Deixar o processo terminar — o process manager (PM2, systemd) vai reiniciar
});

Fire and forget — quando é aceitável

Em casos onde a rejeição é genuinamente irrelevante (operações de melhor esforço, side effects não críticos), void documenta a intenção:

// void comunica que a rejeição foi considerada e deliberadamente ignorada
void updateAnalyticsCache(userId);
 
// Melhor ainda: trate internamente na própria função
async function updateAnalyticsCache(userId) {
  try {
    await cache.set(userId, await fetchAnalytics(userId));
  } catch (e) {
    logger.warn('analytics cache update failed', { userId, error: e });
    // Falha silenciosa intencional, com log
  }
}

void sem .catch() interno ainda vai disparar unhandledRejection se a promise rejeitar. O padrão correto é sempre tratar internamente quando fire-and-forget for necessário.

Promise.all([]) e casos extremos

Promise.all([])
  .then((results) => console.log(results)); // []
 
// Promise.all falha rápido — rejeita no primeiro erro
Promise.all([
  Promise.resolve(1),
  Promise.reject(new Error('falhou')),
  Promise.resolve(3),
]).catch((e) => console.log(e.message)); // 'falhou'
// Os outros valores (1, 3) são descartados silenciosamente
 
// Promise.allSettled aguarda todos — nunca rejeita
Promise.allSettled([
  Promise.resolve(1),
  Promise.reject(new Error('falhou')),
]).then((results) => console.log(results));
// [
//   { status: 'fulfilled', value: 1 },
//   { status: 'rejected', reason: Error('falhou') }
// ]

Casos práticos

Cenário 1 — Promise solta em cadeia de .then(): dados incorretos silenciosos

Um endpoint de usuário buscava permissões mas a lista sempre chegava vazia. Sem erro, sem log — simplesmente undefined.

// ❌ Promise solta: fetchPermissions executa, mas resultado é descartado
fetchUser(id)
  .then((user) => {
    fetchPermissions(user.id); // sem return! — a promise não é encadeada
  })
  .then((permissions) => {
    console.log(permissions); // undefined sempre — não as permissões
    res.json({ permissions }); // resposta incorreta, sem erro
  });
 
// ✅ Com return: a cadeia espera fetchPermissions resolver
fetchUser(id)
  .then((user) => {
    return fetchPermissions(user.id); // encadeia corretamente
  })
  .then((permissions) => {
    res.json({ permissions }); // dados corretos
  });

Qualquer erro dentro de fetchPermissions no código incorreto vira um unhandledRejection silencioso — especialmente perigoso em Node.js 14 e anteriores (que não encerravam o processo por padrão).

Cenário 2 — Promise.all vs Promise.allSettled em chamadas a múltiplos serviços

Um dashboard agregava dados de 5 serviços externos. Um dos serviços ficava instável, derrubando toda a resposta do dashboard.

// ❌ Promise.all: falha rápida — um serviço instável derruba o dashboard inteiro
const [usuarios, pedidos, estoque, financeiro, analytics] = await Promise.all([
  buscarUsuarios(),
  buscarPedidos(),
  buscarEstoque(),
  buscarFinanceiro(), // se este rejeitar, todos os outros são ignorados
  buscarAnalytics(),
]);
 
// ✅ Promise.allSettled: cada serviço contribui independentemente
const resultados = await Promise.allSettled([
  buscarUsuarios(),
  buscarPedidos(),
  buscarEstoque(),
  buscarFinanceiro(), // rejeição aqui não cancela os outros
  buscarAnalytics(),
]);
 
const dados = resultados.reduce((acc, r, i) => {
  if (r.status === 'fulfilled') acc[SERVICOS[i]] = r.value;
  else console.warn(`${SERVICOS[i]} falhou:`, r.reason);
  return acc;
}, {});

Armadilhas comuns

Esquecer return dentro de .then() cria promise solta — erros desaparecem silenciosamente

Cada .then() retorna uma nova promise cujo estado depende do que o handler retorna. Sem return, a promise interna executa mas o resultado é descartado — a cadeia continua com undefined, e erros viram unhandledRejection invisíveis.

// ❌ Sem return: resultado descartado, erros silenciosos
p.then((user) => { fetchPermissions(user.id); }) // promise solta
// ✅ Com return: cadeia correta
p.then((user) => { return fetchPermissions(user.id); })

await Promise.resolve(syncFn()) não move trabalho para outra thread

syncFn() executa síncronamente antes mesmo do await. Envolver em Promise.resolve() não torna a função assíncrona — o await só suspende depois que o trabalho síncrono já bloqueou a thread.

// ❌ Bloqueia a thread antes do await
const result = await Promise.resolve(computeHeavySync()); // computeHeavySync roda agora
// ✅ Para trabalho CPU-bound real: Worker Threads

Silenciar unhandledRejection sem logar cria bugs fantasma em produção

“Tratar” o evento sem logar é pior que não tratar: o processo não termina (pre-Node 15), mas os erros também não aparecem em nenhum lugar. Bugs se acumulam silenciosamente.

// ❌ Engole erros — versão mais perigosa
process.on('unhandledRejection', () => { /* nada */ });
// ✅ Sempre logar; em produção, considere terminar o processo
process.on('unhandledRejection', (reason, promise) => {
  logger.error({ reason, promise }, 'Unhandled rejection');
  process.exit(1); // Node 15+ faz isso por padrão
});

Em entrevista

Frase pronta (use em inglês)

“A Promise has three states: pending, fulfilled, and rejected — once settled, it’s immutable. .then doesn’t run synchronously; it queues the callback in the microtask queue, even if the promise is already resolved. Chained .then calls return new promises, so errors propagate down the chain until the first .catch. The unhandled rejection handler in Node logs the error and, by default in modern versions, terminates the process — which is the right behavior for unrecoverable bugs.”

Perguntas frequentes

“Por que .then() é sempre assíncrono, mesmo para promises já resolvidas?” Para garantir comportamento previsível e composição correta. Se .then() às vezes rodasse síncronamente, o código teria que lidar com dois modelos de execução dependendo do estado da promise — exatamente o problema que Promises foram criadas para resolver.

“Qual a diferença entre Promise.resolve(p) onde p já é uma Promise?” Se p é uma Promise nativa, Promise.resolve(p) retorna p sem criar uma nova promise. Se p é um thenable de terceiros, cria uma nova promise que segue p.

“Quando usar Promise.allSettled em vez de Promise.all?” Promise.all é para “preciso de todos os resultados ou quero falhar rápido”. Promise.allSettled é para “quero saber o resultado de cada operação independentemente, sem que uma falha cancele as outras”.

Vocabulário para entrevistas

PT-BREN
estado (pending, fulfilled, etc.)state / status
liquidada / resolvidasettled
encadearchain / pipe
rejeição não tratadaunhandled rejection
fila de microtasksmicrotask queue / job queue
executorexecutor function
propagar erropropagate / bubble up
açúcar sintáticosyntactic sugar
promessa soltafloating promise

O que vem a seguir

Com a máquina de estados de Promises entendida, o próximo passo é ver como async/await simplifica o trabalho com Promises — e quais armadilhas surgem da confusão entre a sintaxe mais simples e o modelo de execução subjacente que não mudou.

A nota 09 - async-await - o que é, o que não é desfaz o mito de que async paraleliza ou cria threads, e mostra padrões como Promise.all para paralelismo real e Worker Threads para CPU-bound.

Veja também

Fontes