TIL: Five Async Node.js Patterns That Changed How I Write Promises

By · · 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

TIL: Five Async Node.js Patterns That Changed How I Write Promises

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.