Async/Await y Manejo de Errores en Node.js

1. Descripción general

async/await te ayuda a escribir código asincrónico en Node.js que se parece a código sincrónico, mucho más fácil de leer que los callbacks o cadenas de .then(). Pero si no entiendes bien el mecanismo subyacente, muy fácilmente puedes escribir mal la captura de errores, lo que lleva a UnhandledPromiseRejection que hace que toda la aplicación falle.

Este artículo comienza desde cómo funcionan async/await, los patrones correctos para manejar errores, hasta los errores comunes al combinar con Promise.all, loops y middleware Express. Después de leerlo, sabrás cómo escribir funciones async seguras y depurar errores asincrónico más rápidamente.

2. Cómo funcionan Async/Await

Una función declarada async siempre devuelve una Promise, sin importar qué valor return dentro. await solo “pausa” en el punto de llamada, cede el control al event loop, no bloquea el thread.

async function getUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  const user = await response.json();
  return user;
}

getUser(1).then(user => console.log(user.name));

La línea await fetch(...) espera a que la Promise de fetch se resuelva antes de continuar. Si fetch falla (por ejemplo, pérdida de conectividad), el error se lanzará directamente en el punto await — y si no lo capturas, rechazará la Promise que devuelve getUser.

Nota: async function siempre devuelve una Promise, incluso si no haces await de nada dentro. Llamarla pero olvidar await o .then() es la causa más común del bug “el código no se ejecuta en el orden correcto”.

3. Capturar errores con try/catch

La forma más correcta de manejar errores en una función async es envolver try/catch alrededor de los puntos await.

async function getOrder(orderId) {
  try {
    const order = await orderRepository.findById(orderId);
    if (!order) {
      throw new Error(`Order ${orderId} not found`);
    }
    return order;
  } catch (err) {
    console.error('Failed to get order:', err.message);
    throw err; // relanzar para que el llamador sepa y continúe con el manejo
  }
}
  • try envuelve toda la lógica que podría lanzar o rechazar.
  • catch (err) captura tanto errores lanzados manualmente como rechazo de Promises.
  • throw err al final para no “tragar” el error — el llamador aún recibe la Promise rechazada.

Nota: Si capturas un error pero no lo relanzas y no devuelves un valor válido, la función async se resolverá con undefined. El llamador creerá que todo está bien pero los datos faltarán — un bug muy difícil de detectar.

3.1. Clase de error personalizada

Para clasificar errores claramente (errores de negocio, errores del sistema…), debes crear clases de error propias.

class PaymentError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'PaymentError';
    this.code = code;
  }
}

async function chargeCard(order) {
  const result = await paymentGateway.charge(order.total);
  if (!result.success) {
    throw new PaymentError('Card declined', 'CARD_DECLINED');
  }
  return result;
}

Al capturar errores, puedes verificar err instanceof PaymentError para manejar diferentes tipos de errores, en lugar de comparar cadenas de mensaje que son propensas a errores.

4. Errores en Promise.all y Promise.allSettled

Promise.all ejecuta múltiples Promises en paralelo, pero basta que una falle para que todo se rechace inmediatamente, los Promises restantes seguirán ejecutándose “en segundo plano” pero se ignoran sus resultados.

async function notifyCustomers(customers) {
  const results = await Promise.all(
    customers.map(c => emailService.send(c.email))
  );
  return results;
}

Si emailService.send del cliente 2 falla, toda la función se rechaza inmediatamente — no sabes si los clientes 1, 3, 4 se enviaron exitosamente o no.

Cuando quieres conocer el resultado de todas las Promises, incluso si fallan, usa Promise.allSettled:

async function notifyCustomersSafely(customers) {
  const results = await Promise.allSettled(
    customers.map(c => emailService.send(c.email))
  );

  const failed = results.filter(r => r.status === 'rejected');
  if (failed.length > 0) {
    console.warn(`${failed.length} email(s) failed to send`);
  }
  return results;
}

Promise.allSettled nunca se rechaza. Cada elemento en results tiene la forma { status: 'fulfilled', value } o { status: 'rejected', reason }. Tú decides cómo manejar el resto.

[
  { status: 'fulfilled', value: true },
  { status: 'rejected', reason: Error: SMTP timeout },
  { status: 'fulfilled', value: true }
]

5. Errores comunes al usar Await en un Loop

Usar await en forEach es un error muy común — el callback de forEach no espera async.

// INCORRECTO: forEach no puede esperar async
async function processOrders(orders) {
  orders.forEach(async order => {
    await orderService.process(order);
  });
  console.log('Done'); // se ejecuta inmediatamente, sin esperar a que se procesen las órdenes
}

forEach llama al callback async e ignora la Promise devuelta, no espera a que se resuelva. console.log('Done') se ejecuta antes de que se procese ninguna order — si process lanza un error, ese error tampoco se captura en ningún lugar, causando UnhandledPromiseRejection.

Corrígelo con for...of si necesitas procesar secuencialmente:

async function processOrdersSequentially(orders) {
  for (const order of orders) {
    await orderService.process(order);
  }
  console.log('Done');
}

O usa Promise.all con map si quieres procesar en paralelo:

async function processOrdersParallel(orders) {
  await Promise.all(orders.map(order => orderService.process(order)));
  console.log('Done');
}

6. Manejo de errores en Express Middleware

Express por defecto no captura errores de funciones async en handlers de ruta — si lanzas un error adentro, la solicitud se colgará o el proceso fallará en versiones antiguas de Node (Express 4 no captura automáticamente el rechazo de Promise).

// RIESGOSO: los errores async no se capturan automáticamente
app.get('/orders/:id', async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  res.json(order);
});

La forma segura es escribir un wrapper que pase el error a next():

function asyncHandler(fn) {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
}

app.get('/orders/:id', asyncHandler(async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  if (!order) {
    const err = new Error('Order not found');
    err.statusCode = 404;
    throw err;
  }
  res.json(order);
}));

app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({ message: err.message });
});

asyncHandler envuelve el handler de ruta, si la Promise devuelta se rechaza, .catch(next) pasa el error al middleware de manejo de errores de Express en lugar de dejarla caer.

Nota: Express 5 (que está en versión beta en el momento de escribir este artículo) ya captura automáticamente el rechazo de async handlers, pero con Express 4 que es ampliamente usado, aún necesitas este wrapper.

7. Timeout y AbortController

Un error fácil de pasar por alto: hacer await a una Promise que nunca se resuelve hará que la función se cuelgue indefinidamente. Usa AbortController para limitar el tiempo de espera.

async function fetchWithTimeout(url, timeoutMs = 3000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

setTimeout llama a controller.abort() después de timeoutMs, haciendo que fetch se rechace con un error llamado AbortError. finally siempre limpia el timer para evitar fugas de memoria, ya sea éxito o fracaso.

8. Escribir pruebas para funciones Async

Usa Jest para probar casos de éxito y error.

<!-- package.json (sección dependencies) -->
npm install --save-dev jest
// orderService.js
async function getOrder(orderId, repository) {
  const order = await repository.findById(orderId);
  if (!order) {
    throw new Error(`Order ${orderId} not found`);
  }
  return order;
}

module.exports = { getOrder };
// orderService.test.js
const { getOrder } = require('./orderService');

describe('getOrder', () => {
  it('devuelve order cuando se encuentra', async () => {
    const mockRepo = { findById: jest.fn().mockResolvedValue({ id: 1, total: 100 }) };

    const order = await getOrder(1, mockRepo);

    expect(order).toEqual({ id: 1, total: 100 });
  });

  it('lanza error cuando no encuentra order', async () => {
    const mockRepo = { findById: jest.fn().mockResolvedValue(null) };

    await expect(getOrder(99, mockRepo)).rejects.toThrow('Order 99 not found');
  });
});

mockResolvedValue simula una Promise resuelta con un valor predeterminado. rejects.toThrow verifica que la Promise sea rechazada con el mensaje esperado — esta es la forma correcta de probar errores asincrónico, en lugar de envolver try/catch manualmente en la prueba.

PASS  ./orderService.test.js
  getOrder
    ✓ devuelve order cuando se encuentra (3 ms)
    ✓ lanza error cuando no encuentra order (2 ms)

9. Errores comunes a evitar

  • Olvidar await: llamar una función async pero sin await hace que el código se ejecute fuera de orden, los errores se “tragan” silenciosamente.
  • Mezclar .then() y await: fácil causar confusión, elige solo un estilo en la misma función.
  • try/catch demasiado amplio: envolver lógica no relacionada con async dificulta saber de dónde viene el error.
  • No relanzar en catch: hace que el llamador reciba undefined en lugar del error, causando bugs silenciosos en las capas superiores.
  • Usar forEach con callback async: no espera, usa for...of o Promise.all.

Nota: Si tu lógica no tiene ninguna I/O ni tarea asincrónica, no necesitas marcar la función como async — solo aumenta la sobrecarga de crear Promises innecesarias.

10. Conclusión

async/await hace que el código asincrónico en Node.js sea fácil de leer, pero manejar errores correctamente es la parte importante para que la aplicación funcione de forma estable. Siempre envuelve try/catch alrededor de los puntos await, relanza errores cuando sea necesario, usa Promise.allSettled cuando quieras conocer el resultado de todas las tareas paralelas, y no olvides el wrapper para los handlers de ruta en Express. Escribir pruebas para casos de éxito y error te da más confianza al refactorizar la lógica asincrónica más adelante.

nodejsasync-awaitjavascripterror-handlingpromise