portfólio
← voltar

JavaScript por baixo dos panos · parte 3 de 3

Promises e async/await no JavaScript: microtasks, encadeamento e fluxo assíncrono

Entenda o que Promises realmente representam, como then/catch/finally entram na microtask queue e como async/await reorganiza o fluxo assíncrono sem bloquear o JavaScript.

Matheus Pergoli··9 min de leitura

Nos dois artigos anteriores, a base foi esta: primeiro, como o JavaScript executa código; depois, como o runtime organiza assincronismo com Call Stack, Web APIs, Task Queue, Microtask Queue e Event Loop.

Agora o próximo passo é olhar para Promise e async/await sem tratar nada como mágica. A ideia aqui é entender onde essas abstrações entram naquele mesmo desenho que já começamos a montar antes.

Se você quiser revisar a base antes, vale ler Event Loop no JavaScript.

Neste artigo, a ideia é seguir a mesma linha dos anteriores: construir modelo mental. O foco aqui não é decorar método de API, e sim entender o que Promise realmente representa, como os handlers entram na Microtask Queue e o que async/await faz de verdade com a execução da função.

Antes de tudo: Promise não cria assincronismo

Vale deixar isso claro logo no começo.

Promise não cria assincronismo. O assincronismo já existe por causa do ambiente e das APIs assíncronas que iniciam operações fora da Call Stack.

O que Promise faz é representar, do lado do JavaScript, o resultado futuro dessa operação.

Ou seja:

Então, quando falamos em Promise, estamos falando muito mais de modelagem do resultado assíncrono do que da origem do assincronismo em si.

O que uma Promise representa

Uma Promise é um objeto que representa um valor que ainda não está disponível no momento atual.

Ela pode estar em três estados:

Enquanto a operação ainda não terminou, ela está pending.

Quando termina com sucesso, ela fica fulfilled.

Quando falha, ela fica rejected.

O ponto central aqui é simples:

O que existe dentro de uma Promise

Quando a gente fala em Promise, é útil imaginar que existe um objeto com alguns dados internos importantes.

Sem entrar em todos os detalhes da especificação, os mais relevantes aqui são:

Em termos mais próximos da especificação, isso costuma aparecer em explicações como:

O ponto não é decorar esses nomes. O que interessa aqui é a ideia:

É justamente isso que torna o comportamento de .then(...), .catch(...) e await previsível.

O executor de new Promise(...) roda na hora

Esse detalhe costuma confundir bastante no começo.

Muita gente olha para new Promise(...) e assume que tudo ali já nasceu assíncrono. Mas o executor do construtor roda imediatamente.

JS
console.log("A")
 
new Promise((resolve) => {
  console.log("B")
  resolve("C")
}).then((value) => {
  console.log(value)
})
 
console.log("D")

A saída é:

TXT
A
B
D
C

O que acontece aqui?

  1. console.log("A") executa.
  2. O construtor da Promise é chamado.
  3. O executor roda imediatamente, então console.log("B") acontece agora.
  4. resolve("C") resolve a Promise.
  5. O callback do .then(...) não executa agora; ele entra na Microtask Queue.
  6. console.log("D") ainda faz parte do fluxo síncrono atual.
  7. Depois que esse trecho termina, a microtask roda e imprime C.

Esse exemplo mostra duas coisas importantes ao mesmo tempo:

O que resolve e reject fazem de verdade

resolve e reject não executam callbacks diretamente.

Eles apenas mudam o estado da Promise e registram o resultado daquele assentamento.

Depois disso, os handlers que estavam esperando essa resolução ou rejeição podem ser agendados.

Então vale separar bem as coisas:

Aqui também vale um refinamento importante: quando falamos em "assentar" uma Promise, estamos dizendo que ela deixa de estar pending e passa a ficar resolvida ou rejeitada de forma definitiva.

Depois que isso acontece, ela não volta para pending nem troca de estado novamente.

.then(...) não continua a mesma Promise; ele cria outra

Esse é um dos pontos que mais vale fixar.

Quando você chama .then(...), não está "editando" a Promise original. Você está criando uma nova Promise ligada ao resultado do callback.

JS
Promise.resolve(2)
  .then((value) => {
    return value * 2
  })
  .then((value) => {
    console.log(value)
  })

Saída:

TXT
4

O fluxo é este:

  1. a primeira Promise resolve com 2
  2. o primeiro .then(...) recebe 2
  3. ele retorna 4
  4. esse 4 resolve a próxima Promise da cadeia
  5. o segundo .then(...) recebe 4

Ou seja: cada passo da cadeia entrega um resultado para o próximo passo.

Esse é um bom momento para conectar com o artigo anterior: enquanto a Promise original ainda está pendente, o callback do .then(...) fica registrado como reação pendente. Quando a Promise assenta, essa reação é colocada na Microtask Queue, e o valor retornado por esse callback define o estado da próxima Promise da cadeia.

Quando um .then(...) retorna outra Promise

Aqui aparece uma das partes mais importantes do modelo mental.

Se um .then(...) retorna uma Promise, a próxima etapa da cadeia não recebe "uma Promise dentro de outra". Ela espera essa Promise terminar e adota o valor final dela.

JS
Promise.resolve(2)
  .then((value) => {
    return Promise.resolve(value * 2)
  })
  .then((value) => {
    console.log(value)
  })

Saída:

TXT
4

Isso importa porque mostra que a cadeia de Promise já sabe lidar com Promises retornadas por callbacks.

É justamente por isso que encadeamento funciona tão bem: cada etapa pode devolver um valor simples ou uma Promise, e a próxima etapa recebe sempre o resultado já "desembrulhado".

Em outras palavras, a cadeia não vai acumulando camadas de Promise<Promise<...>> do jeito que muita gente imagina no começo. Ela adota o resultado final da Promise retornada e segue a partir dali.

Erros propagam pela cadeia

Outro ponto central é o caminho de erro.

Se um callback dentro de .then(...) lança erro, a próxima Promise da cadeia fica rejeitada.

JS
Promise.resolve("ok")
  .then(() => {
    throw new Error("algo deu errado")
  })
  .catch((error) => {
    console.log(error.message)
  })

Saída:

TXT
algo deu errado

Isso significa que, em uma cadeia de Promise:

E o .catch(...) entra justamente para interceptar esse caminho de erro.

.finally(...) é encerramento, não transformação

O finally tem outro papel.

Ele não existe para transformar o valor que passa pela cadeia. Ele existe para rodar código quando a Promise termina, seja em sucesso ou em erro.

JS
Promise.resolve("sucesso")
  .then((value) => {
    console.log(value)
  })
  .finally(() => {
    console.log("encerrado")
  })

Saída:

TXT
sucesso
encerrado

O ponto importante aqui é este:

Por que .then(...) sempre parece "vir depois"

Aqui entra diretamente a ponte com o artigo anterior.

JS
console.log("Start")
 
Promise.resolve("Operation with .then")
  .then((value) => {
    console.log(value)
  })
 
console.log("End")

Saída:

TXT
Start
End
Operation with .then

Isso acontece porque o callback de .then(...) entra na Microtask Queue.

Então a ordem real é:

  1. o trecho síncrono atual roda até o fim
  2. depois as microtasks pendentes são processadas
  3. aí o callback do .then(...) roda

É exatamente aqui que Promise se conecta ao modelo de Event Loop que vimos antes.

async sempre devolve uma Promise

Quando uma função é marcada como async, ela sempre retorna uma Promise, mesmo quando você retorna um valor comum.

JS
async function getNumber() {
  return 42
}
 
getNumber().then((value) => {
  console.log(value)
})

Saída:

TXT
42

Na prática, isso significa que:

Então async já coloca a função inteira dentro desse modelo baseado em Promise.

Isso também significa que uma async function participa da mesma lógica de encadeamento e microtasks que qualquer outra Promise.

await não bloqueia o JavaScript inteiro

O await não para o programa inteiro. Ele pausa a continuação da função assíncrona atual até a Promise ser resolvida.

JS
function delay(ms) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms)
  })
}
 
async function main() {
  console.log("antes do await")
  await delay(1000)
  console.log("depois do await")
}
 
main()
console.log("fora da função")

A saída é:

TXT
antes do await
fora da função
depois do await

O que acontece aqui?

  1. main() começa.
  2. console.log("antes do await") executa.
  3. await delay(1000) pausa a continuação de main.
  4. o restante do código fora da função continua, então console.log("fora da função") roda.
  5. quando a Promise resolve, a continuação da função é agendada como microtask.
  6. console.log("depois do await") executa.

Então o await não bloqueia a thread principal. Ele apenas reorganiza a continuação daquela função.

Pensando por baixo dos panos, o await divide a função em duas partes:

Essa continuação entra no mesmo mecanismo de microtasks que vimos antes.

await é uma forma melhor de expressar encadeamento

Por baixo dos panos, async/await continua dependendo de Promise e microtasks.

O ganho principal está na forma como o fluxo fica escrito.

Estas duas versões expressam a mesma ideia:

JS
delay(1000)
  .then(() => {
    return delay(1000)
  })
  .then(() => {
    console.log("fim")
  })
JS
async function run() {
  await delay(1000)
  await delay(1000)
  console.log("fim")
}

O que muda não é a existência do assincronismo. O que muda é a legibilidade do fluxo.

Por isso dá para pensar em await como uma forma mais legível de expressar "espere o resultado desta Promise e continue depois".

await em sequência é serial

Se você faz isto:

JS
function delayValue(ms, value) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), ms)
  })
}
 
async function sequential() {
  const value1 = await delayValue(1000, "A")
  const value2 = await delayValue(1000, "B")
 
  console.log(value1, value2)
}
 
sequential()

Essa função leva algo perto de 2 segundos.

Isso acontece porque a segunda operação só começa depois que a primeira terminou.

Ou seja: aqui o fluxo é serial.

O ponto realmente importante: quando as Promises começam

Agora veja este caso:

JS
function delayValue(ms, value) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(value), ms)
  })
}
 
async function parallelStart() {
  const promise1 = delayValue(1000, "A")
  const promise2 = delayValue(1000, "B")
 
  const value1 = await promise1
  const value2 = await promise2
 
  console.log(value1, value2)
}
 
parallelStart()

Agora o tempo total fica perto de 1 segundo.

Isso mostra um ponto muito importante:

Se você cria as operações antes, elas já começam a andar. O await só decide quando você vai esperar por cada resultado.

Promise.all(...) como coordenação, não como "mágica de paralelismo"

Promise.all(...) não cria paralelismo do nada. Ele coordena um conjunto de Promises que já foram iniciadas.

JS
Promise.all([
  Promise.resolve("A"),
  Promise.resolve("B"),
  Promise.resolve("C")
]).then((values) => {
  console.log(values)
})

Saída:

TXT
[ 'A', 'B', 'C' ]

O comportamento importante aqui é:

Então o modelo mental é: Promise.all(...) coordena um conjunto de operações quando todas precisam dar certo.

O detalhe importante aqui é que ele não inicia essas operações por conta própria. Ele recebe Promises que já foram criadas e coordena o momento em que o conjunto pode ser considerado concluído.

Promise.allSettled(...) quando você quer olhar o conjunto inteiro

Às vezes você não quer falhar rápido. Você quer olhar o resultado do conjunto inteiro.

JS
Promise.allSettled([
  Promise.resolve("A"),
  Promise.reject("erro"),
  Promise.resolve("C")
]).then((results) => {
  console.log(results)
})

Saída:

TXT
[
  { status: 'fulfilled', value: 'A' },
  { status: 'rejected', reason: 'erro' },
  { status: 'fulfilled', value: 'C' }
]

Cada item do resultado carrega o status e, dependendo do estado, o value ou o reason.

Então o modelo mental muda:

Promise.race(...) e Promise.any(...)

Esses dois métodos parecem parecidos, mas resolvem coisas diferentes.

Promise.race(...)

Resolve ou rejeita com a primeira Promise que terminar.

JS
Promise.race([
  new Promise((resolve) => setTimeout(() => resolve("rápida"), 500)),
  new Promise((resolve) => setTimeout(() => resolve("lenta"), 1000))
]).then((value) => {
  console.log(value)
})

Saída:

TXT
rápida

Aqui o ponto é: "o que terminar primeiro vence", seja sucesso ou erro.

Um detalhe importante: as outras Promises não são canceladas. Elas continuam rodando normalmente — apenas seus resultados são ignorados. JavaScript não tem um mecanismo nativo de cancelamento de Promises.

Promise.any(...)

Resolve com a primeira Promise bem-sucedida.

JS
Promise.any([
  Promise.reject("erro 1"),
  Promise.resolve("sucesso"),
  Promise.reject("erro 2")
]).then((value) => {
  console.log(value)
})

Saída:

TXT
sucesso

Aqui o ponto é: "quero a primeira que der certo". Erros são ignorados até que uma Promise resolva com sucesso.

Se todas rejeitarem, Promise.any(...) lança um AggregateError contendo todos os motivos de rejeição.

Assim como no Promise.race(...), as demais Promises continuam rodando — seus resultados simplesmente não são usados.

Um erro comum: forEach com await

forEach não espera await do jeito que muita gente imagina.

JS
const items = [1, 2, 3]
 
items.forEach(async (item) => {
  await delayValue(1000, item)
  console.log(item)
})
 
console.log("fim")

Saída:

TXT
fim
1
2
3

O console.log("fim") aparece primeiro porque o forEach não foi feito para coordenar fluxo assíncrono. Ele dispara cada callback e segue em frente sem esperar o await interno. Cada iteração cria uma Promise independente que o forEach simplesmente ignora.

Quando você precisa controle real de sequência ou concorrência, as alternativas são:

for...of com await para sequência:

JS
for (const item of items) {
  await delayValue(1000, item)
  console.log(item)
}

Aqui cada item espera o anterior terminar antes de continuar. O tempo total é proporcional ao número de itens.

.map(...) com Promise.all(...) para concorrência coordenada:

JS
await Promise.all(
  items.map(async (item) => {
    await delayValue(1000, item)
    console.log(item)
  })
)

Aqui todos os itens começam ao mesmo tempo e o código aguarda todos terminarem antes de continuar.

O modelo mental que vale guardar

Se eu tivesse que resumir o papel de Promise e async/await, eu guardaria isto:

Fechando

Depois que esse modelo mental fica claro, Promise e async/await deixam de parecer "um conjunto de APIs para decorar" e passam a fazer parte do mesmo desenho que já vimos no Event Loop.

Esse é o ponto que mais importa nesta série: cada camada nova faz mais sentido quando você entende como ela se encaixa na anterior.

Com essa base, o próximo passo natural é sair um pouco da mecânica e entrar mais em padrões práticos: concorrência controlada, batching, limites paralelos e composição de fluxos assíncronos.

Artigo anterior

Event Loop no JavaScript: Web APIs, Task Queue e Microtask Queue

← voltar para o blog