Back

Ainda comete esses erros com Async/Await? Guia definitivo 2026

Você já usa async/await há anos. Promises já domina de olhos fechados. E mesmo assim, aquele bug de produção das 3h da manhã acabou sendo culpa de uma função async que você mesmo escreveu. Reconhece a situação?

O problema do async/await é que parece simples demais. Tem cara de código síncrono, e por isso repetimos os mesmos erros. Neste guia vou destrinchar as armadilhas que ainda causam problemas em 2026.

Não é tutorial de iniciante. É para quem já conhece async/await mas quer usar direito.

Por que await dentro de loop acaba com a performance

O assassino de performance #1 em código async é esse aqui:

async function fetchUserData(userIds) { const users = []; for (const id of userIds) { const user = await fetchUser(id); users.push(user); } return users; }

Parece inofensivo, né? Mas se você tem 10 usuários e cada fetchUser demora 100ms, essa função leva 1 segundo. Em paralelo seriam 100ms.

Qual o problema?

O await para a execução até a Promise resolver. Dentro de um loop, cada requisição espera a anterior terminar. Você tá serializando algo que poderia ir em paralelo.

A solução: Promise.all

async function fetchUserData(userIds) { const userPromises = userIds.map(id => fetchUser(id)); const users = await Promise.all(userPromises); return users; }

Agora todas as requisições saem de uma vez e você só espera pela mais lenta.

Mas às vezes precisa ser sequencial mesmo

async function processPayments(payments) { const results = []; for (const payment of payments) { // O saldo muda depois de cada pagamento const result = await processPayment(payment); results.push(result); } return results; }

O importante é a intenção. Não serialize sem querer o que pode ir em paralelo.

Nível avançado: controlar a concorrência

Promise.all não é sempre a resposta. Se você dispara 1000 requisições de uma vez, vai derrubar o servidor. Precisa limitar as conexões simultâneas:

async function fetchWithConcurrency(urls, concurrency = 5) { const results = []; const executing = new Set(); for (const url of urls) { const promise = fetch(url).then(response => { executing.delete(promise); return response.json(); }); executing.add(promise); results.push(promise); if (executing.size >= concurrency) { await Promise.race(executing); } } return Promise.all(results); }

Também dá pra usar um padrão de semáforo:

class Semaphore { #queue = []; #running = 0; constructor(concurrency) { this.concurrency = concurrency; } async acquire() { if (this.#running >= this.concurrency) { const { promise, resolve } = Promise.withResolvers(); this.#queue.push(resolve); await promise; } this.#running++; } release() { this.#running--; if (this.#queue.length > 0) { const next = this.#queue.shift(); next(); } } async run(fn) { await this.acquire(); try { return await fn(); } finally { this.release(); } } } // Uso const semaphore = new Semaphore(5); const results = await Promise.all( urls.map(url => semaphore.run(() => fetch(url))) );

Unhandled Rejection mata servidor

A partir do Node.js 22, rejection de Promise sem tratamento encerra o processo. Essa mudança sozinha já derrubou mais servidores de produção do que qualquer outra coisa.

A morte silenciosa

async function riskyOperation() { const result = await fetchData(); // Pode falhar return result; } // Perigo: sem tratamento de erro riskyOperation();

Se fetchData() falhar, pra onde vai o erro? Pra lugar nenhum. Sem try/catch, sem .catch(). Antes do Node.js 15 só aparecia um warning. Agora o servidor morre de vez.

O pior é que é difícil detectar

app.get('/users', async (req, res) => { const users = await getUsers(); // Se isso falhar... res.json(users); });

No Express 4.x isso não manda resposta de erro automaticamente. A requisição fica pendurada até dar timeout. O Express 5 corrigiu, mas muita gente ainda usa o 4.x.

Como se proteger direito

1. Handler global como última linha de defesa:

process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection:', reason); Sentry.captureException(reason); });

2. No Express, use um wrapper:

const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; app.get('/users', asyncHandler(async (req, res) => { const users = await getUsers(); res.json(users); }));

3. try/catch obrigatório nos pontos de entrada:

Em rotas de API, event handlers, cron jobs... sempre capture os erros:

async function cronJob() { try { await performScheduledTask(); } catch (error) { await notifyOnCall(error); // Não relance. Aqui é o limite. } }

Fire-and-forget também precisa de .catch()

// ❌ Errado async function saveAndNotify(data) { await saveToDatabase(data); sendNotification(data); // Sem await, ok, mas... } // ✅ Certo async function saveAndNotify(data) { await saveToDatabase(data); sendNotification(data).catch(err => { console.error('Falha ao enviar notificação:', err); }); }

Memory leaks: por que o servidor cai depois de dias

Código async pode vazar memória de formas sutis. Você só percebe depois de dias rodando.

Quando um closure segura dados enormes

async function processLargeFile(filePath) { const hugeData = await readEntireFile(filePath); // 500MB return async function getSlice(start, end) { return hugeData.slice(start, end); }; }

A função retornada mantém referência pra hugeData. Mesmo que você só precise de pedacinhos, os 500MB ficam na memória.

Solução: WeakRef

async function processLargeFile(filePath) { let hugeData = await readEntireFile(filePath); const dataRef = new WeakRef(hugeData); hugeData = null; // Soltar a referência return async function getSlice(start, end) { const data = dataRef.deref(); if (!data) { throw new Error('Dados foram coletados pelo GC'); } return data.slice(start, end); }; }

Event listeners que não são limpos

Esse é bem comum e passa batido:

class DataProcessor { constructor() { this.eventEmitter = new EventEmitter(); } async processWithUpdates(data) { return new Promise((resolve, reject) => { const onProgress = (progress) => { console.log(`Progress: ${progress}%`); }; const onComplete = (result) => { // ⚠️ Não remove o onProgress! resolve(result); }; this.eventEmitter.on('progress', onProgress); this.eventEmitter.on('complete', onComplete); this.eventEmitter.on('error', reject); this.startProcessing(data); }); } }

Cada chamada adiciona listeners que nunca são removidos. Depois de 1000 chamadas, você tem 3000 listeners zumbis.

Sempre limpe os recursos

async processWithUpdates(data) { return new Promise((resolve, reject) => { const cleanup = () => { this.eventEmitter.off('progress', onProgress); this.eventEmitter.off('complete', onComplete); this.eventEmitter.off('error', onError); }; const onProgress = (progress) => { console.log(`Progress: ${progress}%`); }; const onComplete = (result) => { cleanup(); resolve(result); }; const onError = (error) => { cleanup(); reject(error); }; this.eventEmitter.on('progress', onProgress); this.eventEmitter.on('complete', onComplete); this.eventEmitter.on('error', onError); this.startProcessing(data); }); }

O padrão AbortController

No JavaScript moderno, isso é mais limpo:

async function fetchWithTimeout(url, timeoutMs = 5000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); return await response.json(); } finally { clearTimeout(timeoutId); } }

Race conditions: funciona em dev, quebra em prod

Race conditions acontecem quando o comportamento depende do timing das operações async. São um inferno porque só aparecem em produção.

O clássico no React

function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { async function loadUser() { const userData = await fetchUser(userId); setUser(userData); // E se userId já mudou? } loadUser(); }, [userId]); return <div>{user?.name}</div>; }

Se o usuário clicar rápido em dois links, as duas requisições saem juntas. A primeira pode chegar por último, deixando dados antigos na tela.

Solução: cancelar a requisição anterior

function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { const controller = new AbortController(); async function loadUser() { try { const userData = await fetchUser(userId, { signal: controller.signal }); setUser(userData); } catch (error) { if (error.name !== 'AbortError') { console.error('Erro ao carregar usuário:', error); } } } loadUser(); return () => controller.abort(); }, [userId]); return <div>{user?.name}</div>; }

Duplo clique no backend

app.post('/orders', async (req, res) => { const { userId, productId } = req.body; // Verificar se já existe o pedido const existing = await Order.findOne({ userId, productId, status: 'pending' }); if (existing) { return res.status(400).json({ error: 'Pedido já existe' }); } // Criar pedido const order = await Order.create({ userId, productId, status: 'pending' }); res.json(order); });

Se o usuário der duplo clique no botão, chegam duas requisições quase juntas. As duas verificam, as duas não encontram nada, as duas criam o pedido.

Solução: unique constraint no banco

// No schema Order.createIndex({ userId: 1, productId: 1, status: 1 }, { unique: true }); // Na rota app.post('/orders', async (req, res) => { try { const order = await Order.create({ userId: req.body.userId, productId: req.body.productId, status: 'pending' }); res.json(order); } catch (error) { if (error.code === 11000) { // Chave duplicada return res.status(400).json({ error: 'Pedido já existe' }); } throw error; } });

Com vários servidores: lock distribuído

import Redis from 'ioredis'; const redis = new Redis(); async function withDistributedLock(key, ttlMs, fn) { const lockKey = `lock:${key}`; const lockValue = crypto.randomUUID(); const acquired = await redis.set(lockKey, lockValue, 'PX', ttlMs, 'NX'); if (!acquired) { throw new Error('Não conseguiu obter o lock'); } try { return await fn(); } finally { const script = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end `; await redis.eval(script, 1, lockKey, lockValue); } }

Não dá pra usar await no constructor

Às vezes vejo código assim:

class DatabaseConnection { constructor() { // ❌ Erro de sintaxe! await this.connect(); } async connect() { this.connection = await mongodb.connect(); } }

Isso é erro de sintaxe. Alguns contornam assim:

class DatabaseConnection { constructor() { this.ready = this.connect(); } async connect() { this.connection = await mongodb.connect(); } async query(sql) { await this.ready; return this.connection.query(sql); } }

Funciona, mas é chato. Tem que colocar await this.ready em todo método.

O padrão factory resolve

class DatabaseConnection { static async create() { const instance = new DatabaseConnection(); await instance.connect(); return instance; } async connect() { this.connection = await mongodb.connect(); } async query(sql) { return this.connection.query(sql); } } // Uso const db = await DatabaseConnection.create();

Promise.all vs Promise.allSettled

Escolher errado pode engolir erros ou derrubar tudo por causa de uma falha.

Promise.all falha se uma só falhar

const results = await Promise.all([ fetchUser(1), fetchUser(2), fetchUser(3) ]);

Se o fetch do usuário 2 falhar, os resultados do 1 e 3 (mesmo que tenham dado certo) são perdidos.

Promise.allSettled espera todas

const results = await Promise.allSettled([ fetchUser(1), fetchUser(2), fetchUser(3) ]); const users = results .filter(r => r.status === 'fulfilled') .map(r => r.value); const errors = results .filter(r => r.status === 'rejected') .map(r => r.reason);

Quando usar cada uma?

  • Promise.all: quando tudo precisa funcionar ou nada (transações)
  • Promise.allSettled: quando você pode processar o que der certo (notificações em massa)

Híbrido: tratamento individual de erros

const results = await Promise.all([ fetchUser(1).catch(err => ({ error: err, id: 1 })), fetchUser(2).catch(err => ({ error: err, id: 2 })), fetchUser(3).catch(err => ({ error: err, id: 3 })) ]); results.forEach(result => { if (result.error) { console.log(`Erro ao carregar usuário ${result.id}`); } });

Async Generators: uma joia pouco conhecida

Pra processar dados enormes, async generators são demais:

async function* readLargeFile(path) { const stream = fs.createReadStream(path, { encoding: 'utf8' }); for await (const chunk of stream) { yield chunk; } } // Processa sem estourar a memória for await (const chunk of readLargeFile('huge.txt')) { await processChunk(chunk); }

Dicas de debugging

1. Use funções com nome

// ❌ No stack trace não sabe o que é const result = await somePromise.then(x => x.map(y => y.value)); // ✅ Sabe na hora onde deu errado const result = await somePromise.then(function extractValues(items) { return items.map(function getValue(item) { return item.value; }); });

2. Coloque IDs de rastreamento

async function debuggableOperation(input) { const startTime = performance.now(); const operationId = crypto.randomUUID().slice(0, 8); console.log(`[${operationId}] Iniciando:`, input); try { const result = await doSomething(input); console.log(`[${operationId}] Completou (${performance.now() - startTime}ms)`); return result; } catch (error) { console.error(`[${operationId}] Falhou (${performance.now() - startTime}ms):`, error); throw error; } }

3. Inspecionar o estado de uma Promise

Não dá pra ver diretamente o estado de uma Promise, mas dá pra usar race:

async function getPromiseState(promise) { const sentinel = Symbol('pending'); const result = await Promise.race([ promise, Promise.resolve(sentinel) ]); if (result === sentinel) { return 'pending'; } return 'resolved'; }

4. Tracing com async_hooks

O módulo async_hooks do Node.js permite rastrear operações async:

import async_hooks from 'async_hooks'; import fs from 'fs'; const contexts = new Map(); const hook = async_hooks.createHook({ init(asyncId, type, triggerAsyncId) { fs.writeSync(1, `${type} criado: ${asyncId} (pai: ${triggerAsyncId})\n`); contexts.set(asyncId, { type, parent: triggerAsyncId }); }, destroy(asyncId) { contexts.delete(asyncId); } }); hook.enable();

Erros comuns nos testes

Se esquecer o await, o teste passa quando não deveria

// ❌ Sempre passa it('deve obter o usuário', async () => { fetchUser(1).then(user => { expect(user.name).toBe('Alice'); }); }); // ✅ Correto it('deve obter o usuário', async () => { const user = await fetchUser(1); expect(user.name).toBe('Alice'); });

Testar rejections

it('deve lançar erro com input inválido', async () => { await expect(fetchUser(-1)).rejects.toThrow('Invalid ID'); });

Fake timers e async juntos

// Fake timers podem quebrar testes async jest.useFakeTimers(); it('deve dar timeout depois de 5 segundos', async () => { const promise = fetchWithTimeout('/slow'); // Avançar os timers jest.advanceTimersByTime(5000); // Precisa processar a fila de microtasks também await jest.runAllTimersAsync(); // Jest 29+ await expect(promise).rejects.toThrow('Timeout'); });

Problemas de performance que passam batido

Saturação da fila de microtasks

Cada await cria uma microtask. Em loops apertados, isso pode bloquear I/O:

// Satura a fila de microtasks async function processItems(items) { for (const item of items) { await processItem(item); // Cria microtask toda vez } }

Se você mistura trabalho de CPU com I/O, precisa ceder o controle:

async function processItems(items) { for (let i = 0; i < items.length; i++) { await processItem(items[i]); // Cede pra I/O a cada 100 itens if (i % 100 === 0) { await new Promise(resolve => setImmediate(resolve)); } } }

Limitações de otimização do V8

V8 não consegue otimizar funções async tão bem quanto síncronas. Pra hot paths:

// Hot path: síncrono quando possível function getValue(cache, key) { const cached = cache.get(key); if (cached !== undefined) { return cached; // Retorno síncrono } return fetchValue(key).then(value => { cache.set(key, value); return value; }); }

Retornando síncrono em cache hits, você evita o overhead de Promise.

Conclusão: mentalidade async

Dominar async/await não é decorar padrões, é entender o fluxo.

Os pontos principais:

  1. Paralelize por padrão: use Promise.all se não tiver motivo pra não usar
  2. Sempre capture erros: em cada ponto de entrada, decida quem trata
  3. Limpe seus recursos: listeners, timers e conexões não somem sozinhos
  4. Race conditions estão em todo lugar: projete pensando em acesso simultâneo
  5. Teste os casos de falha: rejections, timeouts, falhas parciais

Os bugs dessa guia vêm de incidentes reais. Todos pareciam corretos no começo. Esse é o perigo do async: a sintaxe esconde a complexidade.

Mas uma vez que você entende, você controla. Escreva código que funcione às 3h da manhã enquanto você dorme tranquilo.

Escreva código com intenção. Teste os edge cases. E sempre trate os rejections.

JavaScriptAsync/AwaitDebuggingNode.jsPerformanceBest Practices

Explore ferramentas relacionadas

Experimente estas ferramentas gratuitas do Pockit