Como funciona o async/await no JavaScript: o guia completo
Você escreve isso no primeiro dia:
const usuario = fetch('http://localhost:3999/usuarios/1');
console.log(usuario.nome);
E aparece isso:
o que veio: Promise { <pending> }
usuario.nome → undefined
undefined. Não porque o usuário não existe — ele existe, chama Ana Souza. Mas naquele instante ele ainda não chegou, e o JavaScript não vai ficar parado esperando.
Esse undefined é o começo de tudo. Entender por que ele acontece é entender async/await inteiro.
Este guia vai do zero — do que é uma Promise — até pool de concorrência, cancelamento, race condition e como o motor faz tudo isso funcionar. Todo bloco de código foi executado no Node.js 22.23; as saídas são reais, copiadas do terminal. Os 36 scripts estão em provas/, um por afirmação.
Parte 1 · Do zero
Por que o JavaScript não espera
O JavaScript roda com uma linha de execução só. Uma. Não existe "enquanto isso, numa outra thread".
Pensa numa cozinha com um cozinheiro só. Ele coloca a água pra ferver. Se ele ficar parado olhando a panela até levantar fervura, ninguém mais é atendido — o restaurante inteiro trava por cinco minutos.
O que ele faz de verdade: coloca a água no fogo, anota que precisa voltar nela, e vai picar a cebola. Quando a água ferve, ele volta.
O fetch é a panela. Ele não devolve o usuário — ele devolve um comprovante de que o pedido foi feito. Esse comprovante é a Promise.
o que veio: Promise { <pending> }
<pending> quer dizer: ainda estou cozinhando.
O que é uma Promise, na prática
Uma Promise é um objeto que representa um valor que ainda não existe, mas vai existir. Ela tem exatamente três estados, e você consegue vê-los no terminal:
const pendente = new Promise(() => {}); // ninguém resolveu ainda
const resolvida = Promise.resolve('valor pronto');
const rejeitada = Promise.reject(new Error('deu ruim'));
pendente → Promise { <pending> }
resolvida → Promise { 'valor pronto' }
rejeitada → Promise { <rejected> Error: deu ruim }
- pending — em andamento. É o estado de toda promise recém-criada.
- fulfilled — deu certo, e tem um valor dentro.
- rejected — deu errado, e tem um erro dentro.
Uma vez que sai de pending, nunca mais volta. Promise não se reaproveita: cada fetch cria uma nova.
Criando uma Promise na mão
Você raramente precisa, mas precisa entender:
const dorme = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const podeFalhar = (deuCerto) => new Promise((resolve, reject) => {
if (deuCerto) resolve('valor');
else reject(new Error('deu ruim'));
});
new Promise recebe uma função com dois argumentos: resolve e reject. Você chama um dos dois. Chamar os dois, ou chamar duas vezes, não faz nada — só o primeiro conta.
Os três degraus, com o mesmo código
Aqui está a mesma tarefa — buscar o usuário 1 na API — escrita nas três gerações do JavaScript. Rodei as três lado a lado:
callback → Ana Souza
promise → Ana Souza
await → Ana Souza
Mesmo resultado. O que muda é quanto você sofre pra escrever e pra ler.
Degrau 1 · Callback (como era antes de 2015)
Você entrega uma função e pede: "chama ela quando terminar".
function buscarUsuario(id, callback) {
http.get(`${API}/usuarios/${id}`, (res) => {
let corpo = '';
res.on('data', pedaco => corpo += pedaco);
res.on('end', () => callback(null, JSON.parse(corpo)));
}).on('error', err => callback(err));
}
buscarUsuario(1, (err, usuario) => {
if (err) return console.error(err);
console.log(usuario.nome); // 'Ana Souza'
});
Repare no (err, valor): essa é a convenção de callback do Node — o erro sempre vem primeiro. Guarde isso, vai importar quando falarmos de promisify.
Funciona. O problema aparece quando você precisa de duas coisas em sequência — buscar o usuário e depois os pedidos dele:
http.get(`${API}/usuarios/${id}`, r1 => {
let a = ''; r1.on('data', p => a += p); r1.on('end', () => {
const usuario = JSON.parse(a);
http.get(`${API}/pedidos/${usuario.id}`, r2 => {
let b = ''; r2.on('data', p => b += p); r2.on('end', () => {
const pedidos = JSON.parse(b);
pronto({ usuario, total: pedidos.reduce((s, p) => s + p.total, 0) });
});
});
});
});
Cada passo empurra o código mais pra direita. Com quatro ou cinco passos vira uma escada deitada — é isso que as pessoas chamam de callback hell. E tratar erro aqui significa checar err em cada nível, à mão.
Degrau 2 · Promise (2015)
A Promise desaninha. Em vez de passar a função pra dentro, você encadeia:
function buscarUsuario(id) {
return fetch(`${API}/usuarios/${id}`).then(r => r.json());
}
buscarUsuario(1)
.then(usuario => console.log(usuario.nome))
.catch(err => console.error(err));
.then() significa "quando chegar, faça isso". Ele devolve outra Promise, então dá pra encadear em linha reta em vez de aninhar. E o .catch() no fim pega o erro de qualquer passo da cadeia — muito melhor que checar err em cada nível.
Ainda assim a lógica fica picada entre funções, e usar if ou try no meio de uma cadeia é desconfortável.
Degrau 3 · async/await (2017)
Agora o mesmo código, com a forma de código comum:
async function buscarUsuario(id) {
const resposta = await fetch(`${API}/usuarios/${id}`);
return resposta.json();
}
const usuario = await buscarUsuario(1);
console.log(usuario.nome); // 'Ana Souza'
Duas palavras novas, e só:
asyncantes da função significa "esta função lida com coisas que demoram". Ela passa a devolver uma Promise, sempre.awaitantes de uma Promise significa "espera o valor chegar e me dá o conteúdo, não o comprovante".
É await que resolve o undefined da primeira linha deste artigo. Sem ele, você segura o comprovante; com ele, você segura o usuário.
E o encadeamento que virava escada deitada com callback vira isto:
async function resumoDoCliente(id) {
const usuario = await fetch(`${API}/usuarios/${id}`).then(r => r.json());
const pedidos = await fetch(`${API}/pedidos/${usuario.id}`).then(r => r.json());
return { usuario, total: pedidos.reduce((s, p) => s + p.total, 0) };
}
callback → Ana Souza · R$ 219.40
await → Ana Souza · R$ 219.40
Mesmo resultado, mesmas duas requisições — e o segundo lê de cima pra baixo, como uma receita.
Duas regras que evitam 90% dos problemas
1. await só funciona dentro de função async. Se você usar fora, dá erro de sintaxe. (Existe uma exceção, o top-level await, e ela tem uma pegadinha nova no Node 22 — volto nisso mais pra frente.)
2. Função async sempre devolve Promise. Mesmo esta:
async function dois() { return 2; }
const x = dois(); // Promise { 2 } — não é 2
const y = await dois(); // 2 — agora sim
Esquecer a regra 2 é a causa mais comum de undefined em código de gente que já sabe async/await. Você chamou a função, esqueceu o await, e passou o comprovante adiante como se fosse o valor.
Parte 2 · Escrevendo código de verdade
Convertendo código antigo de callback
Você vai encontrar bibliotecas que só falam callback. Três formas de trazer para o mundo das promises, da melhor para a pior:
// 1. Muitos módulos do Node já têm versão em promise — prefira sempre
const fs = require('fs/promises');
const dados = await fs.readFile('arquivo.txt', 'utf8');
// 2. util.promisify: funciona quando a assinatura é (err, valor)
const { promisify } = require('util');
const lerArquivo = promisify(require('fs').readFile);
// 3. Na mão: quando a assinatura foge do padrão
const lerAMao = (caminho) => new Promise((resolve, reject) => {
require('fs').readFile(caminho, 'utf8', (err, dados) => err ? reject(err) : resolve(dados));
});
promisify: 818 · à mão: 818 · fs/promises: 818
os três são idênticos? true
util.promisify só funciona no padrão do Node — callback como último argumento, erro como primeiro parâmetro. Se a biblioteca inverte isso ou passa dois valores, é na mão mesmo.
Sequencial ou paralelo: a pergunta que decide tudo
Este é o erro de desempenho mais comum em API Node:
const resultados = [];
for (const id of ids) {
resultados.push(await buscarUsuario(id)); // ← uma de cada vez
}
Cinco tarefas de 100 ms, medidas dos dois jeitos:
sequencial (await no laço) 506ms
paralelo (Promise.all) 103ms

A versão paralela:
const resultados = await Promise.all(ids.map(id => buscarUsuario(id)));
Repare no que mudou: ids.map(...) dispara todas as buscas imediatamente e devolve um array de promises. O Promise.all só espera. O ganho não veio do await — veio de começar tudo antes de esperar qualquer coisa.
A pergunta que decide: a iteração N precisa do resultado da N‑1?
- Precisa → sequencial está certo. Paginação por cursor, transação em ordem, cada passo usando o ID gerado no anterior.
- Não precisa → você está deixando tempo na mesa.
Também é legítimo usar sequencial de propósito para não estourar rate limit de uma API externa. Aí não é descuido, é decisão.
Os quatro combinadores
Promise.all é o mais conhecido, mas são quatro — e escolher errado dá bug sutil. Medidos com uma promise que falha em 10 ms e outra que resolve em 60 ms:
all ✓ ["a","b"] 62ms
all+erro ✗ falhou 11ms
allSettled ✓ ["rejected","fulfilled"] 61ms
race ✗ rápida-erro 11ms
any ✓ "primeira-ok" 61ms
| Combinador | Resolve quando | Rejeita quando | Use para |
|---|---|---|---|
Promise.all |
todas resolvem | a primeira falha | tudo é obrigatório |
Promise.allSettled |
todas terminam | nunca | quero o resultado de cada uma |
Promise.race |
a primeira termina | a primeira termina com erro | timeout, corrida |
Promise.any |
a primeira que dá certo | todas falham | réplicas, fallback |
Na prática:
// all — a resposta precisa dos três; se um falhar, não tem resposta
const [usuario, endereco, cartao] = await Promise.all([
buscarUsuario(id), buscarEndereco(id), buscarCartao(id),
]);
// allSettled — enviar 500 e-mails: quero saber quais falharam, não parar no primeiro
const envios = await Promise.allSettled(lista.map(enviarEmail));
const falhos = envios.filter(e => e.status === 'rejected');
// any — três réplicas do mesmo dado; me dá a que responder primeiro
const preco = await Promise.any([replicaA(), replicaB(), replicaC()]);
Repare na linha all+erro: terminou em 11 ms, não em 60. Promise.all é fail-fast — rejeita no primeiro erro sem esperar o resto.
E aqui vem o detalhe que morde:
Promise.all rejeitou com "rápida" em 11ms (não esperou os 200ms)
a promise "lenta" ainda está viva neste instante
Promise.all não cancela nada. Ele só para de esperar. A requisição lenta continua ocupando conexão e memória — e se ela falhar depois, vira uma rejeição sem dono. Promise em JavaScript não é cancelável; para cancelar de verdade, AbortController, logo abaixo.
Limitando a concorrência
Promise.all sobre 200 itens abre 200 operações ao mesmo tempo. Contra uma API externa isso é rate limit na cara; contra um banco, é o pool de conexões esgotado.
A solução não é voltar ao sequencial — é limitar:
async function comLimite(itens, limite, fn) {
const resultados = new Array(itens.length);
let proximo = 0;
const trabalhador = async () => {
while (proximo < itens.length) {
const i = proximo++; // pega o próximo índice livre
resultados[i] = await fn(itens[i]);
}
};
await Promise.all(
Array.from({ length: Math.min(limite, itens.length) }, trabalhador)
);
return resultados;
}
A ideia: em vez de 200 tarefas soltas, você cria N trabalhadores que consomem de uma fila compartilhada. Cada um pega o próximo índice, processa, e volta pra pegar outro.
Com 200 tarefas de 20 ms:
sequencial (for...of) 4256ms · 200 itens
Promise.all (200 de uma vez) 27ms · 200 itens
pool de 10 422ms · 200 itens
pool de 25 179ms · 200 itens
O Promise.all cru é o mais rápido no papel e o mais perigoso na prática. Um pool de 10 já entrega dez vezes o desempenho do sequencial, com uma pressão que o seu banco aguenta.
Retry com backoff exponencial
Rede falha. A resposta certa quase nunca é desistir na primeira tentativa:
const dorme = ms => new Promise(r => setTimeout(r, ms));
async function comRetry(fn, { vezes = 4, base = 50 } = {}) {
for (let i = 0; i < vezes; i++) {
try {
return await fn();
} catch (e) {
if (i === vezes - 1) throw e; // última tentativa: propaga
const espera = base * 2 ** i; // 50, 100, 200, 400…
const jitter = Math.round(espera * 0.2); // evita todo mundo voltar junto
await dorme(espera + jitter);
}
}
}
tentativa 1 falhou (falha 1) · esperando ~50ms
tentativa 2 falhou (falha 2) · esperando ~100ms
resultado: funcionou · 184ms
Três detalhes que fazem diferença:
- Backoff exponencial (
base * 2 ** i) — dobrar a espera dá tempo do outro lado se recuperar. - Jitter — se mil clientes falharem juntos e todos esperarem exatamente 50 ms, voltam juntos e derrubam o serviço de novo. O ruído aleatório espalha.
- Não repita o que não adianta. Um
400 Bad Requestvai falhar igual nas quatro tentativas. Só repita erro de rede, timeout,429e5xx.
Timeout e cancelamento de verdade
Promise.race com um timer é o padrão que todo mundo copia — e ele só desiste de esperar. A operação continua rodando do outro lado.
Para cancelar mesmo, AbortController:
// numa requisição HTTP, é uma linha
const resposta = await fetch(url, { signal: AbortSignal.timeout(3000) });
// numa operação sua, você escuta o sinal
const lenta = (signal) => new Promise((resolve, reject) => {
const id = setTimeout(() => resolve('terminou'), 500);
signal?.addEventListener('abort', () => {
clearTimeout(id); // ← limpa o que ficaria pendurado
reject(signal.reason);
});
});
abortou em 107ms · TimeoutError: The operation was aborted due to timeout
o timer interno foi limpo — nada ficou pendurado
AbortSignal.timeout() é nativo no Node 17.3+ e em todos os navegadores modernos. Para cancelar na mão — o usuário fechou a aba, outra réplica respondeu antes — use new AbortController() e chame controller.abort().
for await...of: paginação, cursor e stream
Quando os dados vêm em pedaços, for await...of deixa o código com cara de laço normal:
async function* paginas() {
for (let p = 1; p <= 3; p++) {
const dados = await buscarPagina(p);
yield dados;
}
}
for await (const pagina of paginas()) {
console.log(pagina);
}
página 1 · 10 itens · 29ms
página 2 · 20 itens · 63ms
página 3 · 30 itens · 88ms
Repare nos tempos: cada página é processada conforme chega. É a diferença entre streaming e carregar tudo na memória antes de começar.
O mesmo vale para arquivo grande:
const rl = readline.createInterface({ input: fs.createReadStream(caminho) });
for await (const linha of rl) {
// processa uma linha por vez
}
9 linhas lidas · 3 contêm "await" · memória usada nunca passou de uma linha
Um arquivo de 10 GB passa por aqui sem estourar a memória. fs.readFile no mesmo arquivo mataria o processo.
async em rotas de API
No Express 4, um erro dentro de handler async não chega no seu middleware de erro — ele vira rejeição não tratada:
app.get('/quebra', async (req, res) => {
throw new Error('explodiu'); // ← some
});
/quebra → erro escapou do handler (processo em risco)
/protegida → middleware de erro pegou: explodiu
O conserto é um wrapper de uma linha:
const rota = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/protegida', rota(async (req, res) => {
const usuario = await repo.buscar(req.params.id);
res.json(usuario);
}));
Promise.resolve(...) funciona mesmo se fn for síncrona, e o .catch(next) entrega o erro ao Express. Express 5 e Fastify já fazem isso sozinhos — se você está no 4, o wrapper é obrigatório.
async em classes
constructor não pode ser async:
class X { async constructor() {} }
SyntaxError
Faz sentido: new precisa devolver a instância, e um async devolveria uma Promise. O padrão é uma factory estática:
class Repositorio {
constructor(conexao) { this.conexao = conexao; }
static async conectar(url) {
const conexao = await abrirConexao(url);
return new Repositorio(conexao);
}
async buscar(id) { /* ... */ }
}
const repo = await Repositorio.conectar('postgres://localhost');
{ id: 7, via: 'conexão com postgres://localhost' }
Métodos comuns podem ser async sem problema — a restrição é só no constructor.
Testando código assíncrono
O Node tem runner nativo desde a v18, sem instalar nada:
const { test } = require('node:test');
const assert = require('node:assert');
test('resolve com o valor certo', async () => {
assert.strictEqual(await somar(2, 3), 5);
});
test('rejeita com o erro certo', async () => {
await assert.rejects(falhar, { message: 'não foi' });
});
ok 1 - resolve com o valor certo
ok 2 - rejeita com o erro certo
# pass 2
# fail 0
Duas regras: a função de teste precisa ser async e você precisa dar await — sem isso o teste passa antes de verificar nada. E para testar rejeição use assert.rejects em vez de try/catch, porque try/catch passa silenciosamente quando a função não falha.
Top-level await
Você pode usar await fora de qualquer função — em módulos ES:
const config = await carregarConfig();
Aqui está a parte que os tutoriais mais antigos ensinam errado. A regra clássica é "só em .mjs ou com "type": "module"". No Node 22, um arquivo .js sem package.json também roda:
rodou em .js puro · 27ms
é módulo ES? true
O Node detecta a sintaxe de módulo e reinterpreta o arquivo como ESM. Mas se o package.json declarar CommonJS explicitamente, a regra antiga volta:
SyntaxError: await is only valid in async functions and the top level bodies of modules
Ou seja: a detecção só age na ausência de declaração. Se o seu projeto tem "type": "commonjs", top-level await continua fora de alcance.
Parte 3 · Erros
try/catch/finally funciona como no código síncrono
Essa é a maior vantagem prática de async/await sobre .then():
try {
const usuario = await buscarUsuario(id);
return usuario;
} catch (e) {
logger.error({ id, erro: e.message });
throw e; // ou devolve um fallback
} finally {
await conexao.liberar(); // roda dando certo ou dando errado
}
1 · catch pegou: boom
2 · catch
2 · finally sempre roda
Um try gigante em volta de tudo, porém, esconde qual linha falhou. Prefira envolver o trecho que você sabe tratar.
return versus return await dentro de try
Esta é a pegadinha mais sutil da linguagem, e custa horas de depuração:
async function semAwait() {
try { return buscar(); } // devolve a promise
catch (e) { return 'catch pegou'; }
}
async function comAwait() {
try { return await buscar(); } // espera aqui dentro
catch (e) { return 'catch pegou'; }
}
return → VAZOU: erro interno
return await → catch pegou
Sem o await, você devolve a promise antes de ela rejeitar. Quando a rejeição acontece, a função já terminou e o catch dela não existe mais — o erro vaza para quem chamou.
Dentro de try/catch, return await é obrigatório.
E fora dele? Aqui a sabedoria popular está desatualizada. Durante anos se ensinou que return await era desperdício, e havia até uma regra de lint contra ele. Medi:
tick1 → comAwait → tick2 → semAwait → tick3 → tick4 → tick5
return await resolveu um tick ANTES do return puro. Devolver uma promise de dentro de uma função async obriga o motor a assimilar aquele objeto, o que custa ticks extras — enquanto await sobre promise nativa foi otimizado no V8 e custa um só.
A regra no-return-await do ESLint foi descontinuada na versão 8.46, sem substituta, exatamente por isso. Se você tem essa regra ligada num projeto antigo, ela está te fazendo escrever código mais lento.
Uma ressalva honesta: a documentação do ESLint também cita stack trace melhor como motivo. Não consegui reproduzir essa diferença no Node 22 — testei com throw imediato e com rejeição assíncrona, e os stacks saíram equivalentes. O ganho de tick, esse dá para medir.
Floating promise: o erro que simplesmente some
Esquecer um await não dá erro de sintaxe. Dá algo pior:
async function comBug() {
salvar(); // ← faltou o await
return 'salvo com sucesso'; // mentira
}
sem await → salvo com sucesso
com await → falha ao salvar
⚠ rejeição solta: falha ao salvar
A função respondeu "salvo com sucesso" enquanto a gravação falhava. O erro aparece depois, desacoplado da requisição que o causou, e no log parece que veio do nada.
É a categoria de bug mais difícil de rastrear em produção, e a mais fácil de prevenir: no TypeScript, ligue @typescript-eslint/no-floating-promises. Se você quer mesmo disparar sem esperar, deixe isso explícito:
void enviarMetrica(evento); // deliberado, e legível
registrarLog(evento).catch(e => logger.warn(e)); // melhor ainda: trate
Encadeando a causa raiz
Jogar fora o erro original para lançar um mais bonito destrói a informação:
catch (e) {
throw new Error('falha ao buscar usuário'); // ← e o ECONNREFUSED? perdeu
}
Desde o ES2022 existe cause:
async function buscarUsuario(id) {
try {
return await consultarBanco(id);
} catch (e) {
throw new Error(`falha ao buscar usuário ${id}`, { cause: e });
}
}
erro : falha ao buscar usuário 42
causa : ECONNREFUSED 127.0.0.1:5432
A camada de cima recebe uma mensagem com contexto de negócio, e a causa técnica continua acessível em e.cause para o log.
Rejeição sem tratamento derruba o processo
Promise.reject(new Error('ninguém me tratou'));
Error: ninguém me tratou
código de saída: 1
Desde o Node 15 esse é o comportamento padrão: promise rejeitada sem catch encerra o processo com código 1. Antes era só um aviso. Se você mantém código antigo rodando em Node novo, é um dos motivos mais comuns de "o container começou a reiniciar sozinho".
Em produção vale registrar antes de morrer:
process.on('unhandledRejection', (e) => {
logger.fatal({ erro: e }, 'rejeição não tratada');
process.exit(1); // morra mesmo: o estado é desconhecido
});
Não use esse handler para continuar rodando — a partir dali você não sabe o que ficou pela metade.
Parte 4 · Por baixo do capô
Até aqui, o suficiente para escrever código correto. Esta parte explica por que ele se comporta assim — e é o que separa quem usa async/await de quem entende.
As três filas
console.log('1 · síncrono');
setTimeout(() => console.log('6 · setTimeout (macrotask)'), 0);
setImmediate(() => console.log('7 · setImmediate (fase check)'));
Promise.resolve().then(() => console.log('4 · .then (microtask)'));
process.nextTick(() => console.log('3 · process.nextTick'));
(async () => {
console.log('2 · o corpo do async roda SÍNCRONO até o primeiro await');
await null;
console.log('5 · depois do await (microtask)');
})();
1 · síncrono
2 · o corpo do async roda SÍNCRONO até o primeiro await
3 · process.nextTick
4 · .then (microtask)
5 · depois do await (microtask)
7 · setImmediate (fase check)
6 · setTimeout (macrotask)

Quatro coisas para tirar daqui:
O corpo de uma função async roda de forma síncrona até o primeiro await. A linha 2 saiu antes de qualquer microtask. Chamar uma função async não a joga para o futuro — ela começa agora e só suspende no primeiro await. É por isso que validação de argumento no topo de uma função async acontece imediatamente.
process.nextTick fura a fila. No Node ele tem fila própria, drenada antes das microtasks de Promise. nextTick em recursão mata o resto de fome: rodei 100.000 deles e o setTimeout só conseguiu rodar depois de todos.
Microtasks drenam por completo antes da próxima macrotask. Todas as promises pendentes resolvem antes de qualquer setTimeout. Se você agendar microtasks infinitamente, os timers nunca rodam.
A numeração 6 e 7 saiu trocada — e isso não é erro meu.
O detalhe que quase todo tutorial erra
Rodando o mesmo arquivo dez vezes:
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
1: setTimeout setImmediate
2: setImmediate setTimeout
3: setImmediate setTimeout
...
10: setImmediate setTimeout
A ordem mudou entre execuções. Não é aleatório de verdade: setTimeout(fn, 0) vira setTimeout(fn, 1) internamente, e se o processo demorou mais de 1 ms para chegar ao loop, o timer já venceu e roda primeiro. Se foi mais rápido, o loop passa pela fase de timers antes do prazo vencer e o setImmediate sai na frente.
Agora dentro de um callback de I/O:
require('fs').readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
1: setImmediate setTimeout
2: setImmediate setTimeout
3: setImmediate setTimeout
4: setImmediate setTimeout
5: setImmediate setTimeout
Aqui é determinístico. Dentro de callback de I/O o loop já passou da fase de timers e está na fase poll; a próxima é check, onde o setImmediate mora. Ele sempre ganha.
A regra prática: nunca escreva código que dependa da ordem entre setTimeout(0) e setImmediate no nível do módulo. Precisa de "roda depois do I/O atual"? setImmediate. Precisa de "roda antes de tudo"? queueMicrotask.
Todo await custa um tick
console.log('1 · antes');
(async () => { await 42; console.log('3 · depois do "await 42"'); })();
Promise.resolve().then(() => console.log('4 · microtask agendada depois'));
console.log('2 · síncrono continua');
1 · antes
2 · síncrono continua
3 · depois do "await 42"
4 · microtask agendada depois
42 não é promise, mas a linha 3 ainda saiu depois da 2. Todo await suspende a função e agenda a retomada como microtask, mesmo sem nada para esperar. Em laço quente, await sobre valor síncrono é desperdício puro.
Parte 5 · As cinco armadilhas
1. forEach com async não espera nada
itens.forEach(async (i) => { await processar(i); });
console.log('terminei');
— forEach —
"terminei" (MENTIRA: nada foi processado ainda)
processado 1
processado 2
processado 3
forEach ignora o valor de retorno do callback. Você entrega uma promise, ele joga fora e segue. O resultado é código que "termina" antes de fazer o trabalho — e que engole todos os erros, porque ninguém está segurando aquelas promises.
O mesmo vale para filter, find, some, every e reduce. O único que se salva é o map, que ao menos devolve o array de promises para você passar ao Promise.all.
for (const i of itens) { await processar(i); } // em ordem, um por vez
await Promise.all(itens.map(i => processar(i))); // todos ao mesmo tempo
2. await no laço quando não há dependência
O caso da Parte 2: 506 ms contra 103 ms. A pergunta é sempre a mesma — a iteração N precisa do resultado da N‑1?
3. Promise.all sem limite
200 itens = 200 conexões simultâneas. Use o pool.
4. Race condition: duas operações no mesmo estado
await cria um buraco no tempo. Entre ler e gravar, o mundo pode mudar:
async function sacar(valor) {
const atual = await lerSaldo(); // ← lê 100
await gravar(atual - valor); // ← grava, mas o outro saque já leu 100 também
}
await Promise.all([sacar(30), sacar(50)]);
saldo final: 50 · esperado: 20
perdeu uma operação? true
Os dois saques leram 100. Um gravou 70, o outro gravou 50 — e o 70 sumiu. JavaScript ser single-thread não te protege disso: não há duas threads, mas há duas execuções intercaladas.
Como resolver, do mais simples ao mais robusto:
- Não leia e grave em passos separados.
UPDATE contas SET saldo = saldo - $1resolve no banco, atomicamente. - Trave o recurso:
SELECT ... FOR UPDATEdentro de transação. - Serialize por chave: uma fila por conta, para que dois saques da mesma conta nunca rodem juntos.
5. Esquecer o await
Já visto na Parte 3: a função responde sucesso e o erro aparece solto no log, minutos depois. É a armadilha que mais custa em produção e a mais barata de evitar com lint.
Parte 6 · Ferramentas
Três flags que revelam problema de assincronia sem adivinhação:
# promisses penduradas quando o processo tentou sair
node --trace-warnings app.js
# rastrear rejeições não tratadas com stack completa
node --unhandled-rejections=strict app.js
# ver o que está segurando o event loop
node --trace-event-categories node.async_hooks app.js
No ESLint, duas regras que pegam a maior parte disso antes de subir: require-atomic-updates (pega exatamente a race condition da armadilha 4) e no-await-in-loop — esta como aviso, não erro, já que há casos legítimos. Em TypeScript, acrescente @typescript-eslint/no-floating-promises, que é a mais valiosa das três.
Não ligue no-return-await: foi descontinuada e trabalha contra você.
Os scripts
Os 36 scripts que geraram cada saída deste guia estão em provas/, um por afirmação, incluindo a API local usada nos exemplos. Rode qualquer um com node provas/01-ordem.js. Se algum resultado divergir na sua máquina, o mais provável é diferença de versão — os números aqui são do Node 22.23.
Fechando
async/await não deixou o JavaScript síncrono. Ele deixou o código assíncrono parecido com síncrono — ótimo para ler, péssimo para intuir desempenho, porque a forma esconde exatamente o que importa: quando cada coisa começa.
Se você levar três coisas deste guia, que sejam estas:
awaitpausa a função, não o programa.- A pergunta que decide desempenho: esta operação depende do resultado da anterior?
awaitcria um buraco no tempo — e no meio dele o estado compartilhado pode mudar.
Se esse tipo de conteúdo te ajuda, tem mais no canal do CodeInit no YouTube — e por aqui vale o guia de como configurar uma API Node.js com TypeScript e o de funções puras em JavaScript, que é onde a separação entre lógica e efeito colateral começa.
Perguntas frequentes
O que é async/await no JavaScript?
São duas palavras-chave que deixam código assíncrono com a forma de código síncrono. async marca uma função que lida com operações demoradas e faz ela devolver sempre uma Promise; await pausa a função até a Promise resolver e entrega o valor de dentro dela. Por baixo, é açúcar sintático sobre Promise — nada de novo acontece no motor.
Qual a diferença entre async/await e Promise?
Nenhuma no comportamento: await faz o mesmo que .then(). A diferença é de legibilidade — com await você usa try/catch normal, if e laços no meio do fluxo, em vez de encadear funções.
O await bloqueia o Node.js?
Não. Ele pausa apenas a função onde está e devolve o controle ao event loop, que continua processando outras tarefas. Por isso um único processo Node atende milhares de conexões. O que bloqueia é código síncrono pesado de CPU.
Por que minha função async devolve Promise em vez do valor?
Porque toda função async devolve Promise, sempre — mesmo async function f() { return 2 } devolve Promise { 2 }. Você precisa de await (ou .then()) na chamada para pegar o valor.
Por que meu forEach com async não espera?
Porque forEach ignora o valor de retorno do callback: recebe a Promise e a descarta. Use for...of com await para processar em ordem, ou Promise.all(array.map(...)) para processar em paralelo. O mesmo vale para filter, find, some e reduce.
Como rodar várias promises ao mesmo tempo?
Dispare todas antes de esperar: await Promise.all(ids.map(id => buscar(id))). Em teste com cinco tarefas de 100 ms, o laço sequencial levou 506 ms e o Promise.all levou 103 ms.
Qual a diferença entre Promise.all e Promise.allSettled?
Promise.all rejeita assim que a primeira falha, descartando os demais resultados. Promise.allSettled espera todas terminarem e nunca rejeita: devolve um array com status fulfilled ou rejected para cada uma.
Promise.all cancela as outras promises quando uma falha?
Não, ele só para de esperar. As restantes continuam executando e consumindo recursos. Para cancelar de verdade é preciso AbortController com uma operação que aceite signal.
Como limitar quantas promises rodam ao mesmo tempo?
Com um pool: crie N trabalhadores que consomem de uma fila compartilhada e rode os N com Promise.all. Em teste com 200 tarefas de 20 ms, o sequencial levou 4.256 ms e um pool de 10 levou 422 ms.
Como dar timeout numa requisição?
fetch(url, { signal: AbortSignal.timeout(3000) }). Ao contrário de Promise.race com um timer, o AbortSignal cancela a operação de verdade em vez de apenas desistir de esperar.
Como converter uma função de callback em Promise?
Prefira a versão em promise que o próprio módulo oferece (fs/promises). Se não houver, util.promisify funciona quando a assinatura segue o padrão do Node — callback por último, erro no primeiro parâmetro. Fora desse padrão, envolva em new Promise na mão.
Por que devo usar return await dentro de try/catch?
Sem o await, a função devolve a promise antes de ela rejeitar, então o try/catch já saiu de cena quando o erro acontece e ele vaza. Com return await, a rejeição acontece dentro do bloco e o catch a captura.
return await deixa o código mais lento?
Não, hoje é o contrário. Em medição no Node 22, return await promise resolveu um tick de microtask antes de return promise, porque devolver promise de dentro de função async obriga o motor a assimilá-la. A regra no-return-await do ESLint foi descontinuada na versão 8.46 por isso.
O que acontece se eu esquecer o await?
A função continua sem esperar e devolve sucesso mesmo que a operação falhe. O erro vira uma rejeição não tratada que aparece depois, desacoplada da requisição — e no Node 15+ derruba o processo. Em TypeScript, a regra @typescript-eslint/no-floating-promises pega isso.
Existe race condition em JavaScript sendo single-thread?
Existe. Não há duas threads, mas há execuções intercaladas: entre um await e o próximo, outra execução pode alterar o mesmo estado. Em teste, dois saques concorrentes leram o mesmo saldo e uma das operações se perdeu. Resolve-se com operação atômica no banco, trava, ou serialização por chave.
O que é microtask e macrotask?
Microtasks são callbacks de Promise e queueMicrotask; macrotasks são setTimeout, setInterval e I/O. A fila de microtasks é drenada por completo antes de o event loop rodar a próxima macrotask. No Node existe ainda a fila do process.nextTick, com prioridade sobre as microtasks de Promise.
Por que setTimeout(0) às vezes roda antes do setImmediate e às vezes depois?
Porque no nível do módulo a ordem depende de quanto o processo demorou para chegar ao event loop: setTimeout(fn, 0) vira 1 ms internamente. Dentro de um callback de I/O a ordem é determinística e o setImmediate sempre vem primeiro.
Posso usar async no constructor de uma classe?
Não, é erro de sintaxe — new precisa devolver a instância, não uma Promise. O padrão é um método estático static async criar() que faz o trabalho assíncrono e devolve a instância pronta.
Top-level await funciona em qualquer arquivo?
Funciona em módulos ES. No Node 22, um .js sem package.json também funciona, porque o runtime detecta a sintaxe de módulo. Mas se o package.json declarar "type": "commonjs", volta a dar SyntaxError.
Como testar código assíncrono?
Com o runner nativo do Node (node:test), a função de teste precisa ser async e você precisa dar await — sem isso o teste passa antes de verificar. Para testar rejeição, use assert.rejects em vez de try/catch, que passa silenciosamente quando nada falha.