TIL: Five Async Node.js Patterns That Changed How I Write Promises
By Jorge Morais · · 9 min read
Five async patterns in Node.js that I keep reaching for in production code: Promise.allSettled for fault-tolerant fan-outs, AbortController for cancellable fetches, async iterators for backpressure-aware streaming, concurrency limiters for not hammering third-party APIs, and structured error boundaries that actually make try/catch useful again.
Node.js JavaScript Async Promises Performance

Porque os Padrões Async Ainda Causam Problemas
O modelo async do JavaScript não é difícil, mas tem superfície suficiente para que a maioria dos developers se instale numa zona de conforto pequena de async/await e Promise.all sem explorar mais. O código de produção, porém, tem requisitos que estes básicos não cobrem completamente: falhas parciais, cancelamento, backpressure, rate limiting. Estes cinco padrões endereçam exatamente essas lacunas.
Padrão 1: Promise.allSettled para Fan-Outs Tolerantes a Falhas
Promise.all rejeita imediatamente quando qualquer promise rejeita. Isso é comportamento correto para transações onde tudo-ou-nada é o requisito. Para operações de fan-out, chamar múltiplos serviços independentes e recolher o que for possível, é a ferramenta errada.
Promise.allSettled espera que todas as promises se resolvam ou rejeitem, depois retorna um array de objetos de resultado. Nenhuma falha parcial mata toda a operação.
// MAU: uma falha perde tudo
async function getDashboard(userId) {
const [profile, orders, notifications] = await Promise.all([
fetchProfile(userId),
fetchOrders(userId),
fetchNotifications(userId),
]);
return { profile, orders, notifications };
}
// BOM: resultados parciais são melhores que nada
async function getDashboard(userId) {
const results = await Promise.allSettled([
fetchProfile(userId),
fetchOrders(userId),
fetchNotifications(userId),
]);
return {
profile: results[0].status === 'fulfilled' ? results[0].value : null,
orders: results[1].status === 'fulfilled' ? results[1].value : [],
notifications: results[2].status === 'fulfilled' ? results[2].value : [],
errors: results.filter(r => r.status === 'rejected').map(r => r.reason),
};
}
Padrão 2: AbortController para Operações Canceláveis
Pedidos de rede que sobrevivem à sua utilidade continuam a consumir recursos do servidor. Um utilizador a navegar para outra página, um timeout a disparar, ou um pedido mais recente a substituir um mais antigo, todos são razões válidas para cancelar trabalho em curso.
// Cancelamento por timeout
async function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(
new Error(`Pedido expirou após ${ms}ms`)
), ms);
try {
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') throw new Error(`Timeout: ${url}`);
throw err;
}
}
// Padrão supersede, cancelar pedido anterior quando chega um novo
class SearchService {
#controller = null;
async search(query) {
this.#controller?.abort();
this.#controller = new AbortController();
const res = await fetch(`/api/search?q=${query}`, {
signal: this.#controller.signal,
});
return res.json();
}
}
Padrão 3: Async Iterators para Streaming com Backpressure
Ao processar grandes datasets, carregar tudo na memória de uma vez é um caminho para erros OOM. Os async iterators permitem processar dados à medida que chegam, pausando a produção quando o consumo fica para trás.
// Processar um resultado grande da DB sem carregar tudo na memória
async function* streamUsers(batchSize = 100) {
let offset = 0;
while (true) {
const batch = await db.query(
'SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2',
[batchSize, offset]
);
if (batch.length === 0) return;
for (const user of batch) yield user;
offset += batchSize;
}
}
// O consumidor controla o ritmo
async function sendWelcomeEmails() {
for await (const user of streamUsers(50)) {
await emailService.send(user.email, 'welcome');
}
}
Padrão 4: Limitador de Concorrência para APIs Externas
Lançar mil promises concorrentes para chamar uma API externa é uma boa forma de ser rate-limited ou sobrecarregar um serviço externo. Um limitador de concorrência processa uma fila de trabalho com um número fixo de slots paralelos.
// Processar items com no máximo N operações concorrentes
async function withConcurrency(items, fn, limit = 5) {
const results = [];
const queue = [...items];
const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
while (queue.length > 0) {
const item = queue.shift();
results.push(await fn(item));
}
});
await Promise.all(workers);
return results;
}
// Uso: enviar 200 emails, máximo 10 ao mesmo tempo
const users = await db.query('SELECT * FROM users');
await withConcurrency(users, async (user) => {
await emailService.send(user.email, 'newsletter');
}, 10);
Padrão 5: Error Boundaries Estruturados
O tratamento de erros async é onde a maioria das codebases acumula falhas silenciosas. Rejeições de promises não tratadas, exceções engolidas em chamadas fire-and-forget, blocos catch que apenas fazem log mas nunca re-lançam, transformam bugs em mistérios.
// Tipo Result: erros são valores, não exceções
const ok = (value) => ({ ok: true, value });
const err = (error) => ({ ok: false, error });
async function safeAsync(promise) {
try {
return ok(await promise);
} catch (e) {
return err(e);
}
}
// Uso: sem try/catch no local da chamada
async function processOrder(orderId) {
const { ok: fetched, value: order, error } = await safeAsync(fetchOrder(orderId));
if (!fetched) {
logger.error('Falha ao buscar order', { orderId, error });
return null;
}
const { ok: charged, error: chargeError } = await safeAsync(chargeCard(order));
if (!charged) {
await safeAsync(sendFailureNotification(order));
throw new PaymentError(chargeError.message, { orderId });
}
return order;
}
// Nunca perder rejeições não tratadas
process.on('unhandledRejection', (reason, promise) => {
logger.error('Rejeição não tratada', { reason, promise });
});
O Fio Comum
Estes cinco padrões resolvem problemas diferentes mas partilham uma filosofia: tornar a superfície de falha explícita e o fluxo de controlo previsível. allSettled torna as falhas parciais visíveis. AbortController torna o cancelamento uma preocupação de primeira classe. Os async iterators tornam a pressão na memória controlável. Os limitadores de concorrência tornam o uso de recursos limitado. Os error boundaries estruturados tornam o caminho de erro tão legível quanto o caminho feliz.
Nenhum destes requer bibliotecas externas. Todos estão integrados no Node.js 16+ e browsers modernos. Os padrões estão disponíveis, usá-los é uma escolha.