Manejo de errores en Node.js con async/await

Cómo hacer el manejo de errores en Node.js con async/await: try/catch, errores propios, promesas sin atrapar y qué cambia en Express 4 y 5.

El código que no falla no existe. El archivo no está, la base de datos no responde, la API externa devuelve basura. Lo que cambia de un programa a otro es qué pasa cuando eso ocurre: en uno el usuario ve un mensaje claro y la aplicación sigue viva, y en otro se cae todo el servidor a las tres de la mañana.

Aquí vemos el manejo de errores en Node.js cuando usas async/await: cómo atrapar errores con try/catch, cómo crear los tuyos sin perder el original, qué pasa con una promesa rechazada que nadie atrapa, y cómo se comporta Express según la versión. Si todavía no dominas las bases, empieza por async/await en Node.js, y si trabajas con varias tareas a la vez, te va a servir saber cómo funciona Promise.all.

Probé todo el código con Node.js 22, Express 5.2.1 y Express 4.22.3. Los ejemplos usan módulos ES, así que pon "type": "module" en tu package.json (o usa archivos .mjs).

Atrapar errores con try/catch

Con async/await los errores se atrapan con try/catch, igual que en código normal. Si una promesa se rechaza, el await lanza el error y cae en tu catch. También puedes usar .catch() directamente sobre la promesa:

async function fallar() {
  throw new Error('algo salió mal');
}

// Opción 1: await dentro de try/catch
try {
  await fallar();
} catch (err) {
  console.log('try/catch atrapó:', err.message);
}

// Opción 2: .catch() en la promesa
fallar().catch((err) => console.log('.catch atrapó:', err.message));
try/catch atrapó: algo salió mal
.catch atrapó: algo salió mal

Las dos funcionan. El try/catch es mejor cuando tienes varios pasos y quieres atrapar el error de cualquiera en un solo lugar. El .catch() es práctico para una promesa suelta.

Crear tus propios errores y conservar el original

Dos cosas mejoran mucho el resultado: tener errores propios que puedas distinguir, y no perder el error original cuando lo envuelves en uno nuevo.

import { readFile } from 'node:fs/promises';

class ConfigError extends Error {
  constructor(mensaje, opciones) {
    super(mensaje, opciones);
    this.name = 'ConfigError';
  }
}

async function cargarConfig(ruta) {
  try {
    const texto = await readFile(ruta, 'utf8');
    return JSON.parse(texto);
  } catch (err) {
    // Conservamos el error original en "cause"
    throw new ConfigError(`No se pudo cargar la configuración (${ruta})`, { cause: err });
  } finally {
    console.log('finally: esto se ejecuta siempre');
  }
}

try {
  await cargarConfig('config-que-no-existe.json');
} catch (err) {
  console.log(err.name, '-', err.message);
  console.log('causa:', err.cause.code);
  console.log('es ConfigError:', err instanceof ConfigError);
}

Salida:

finally: esto se ejecuta siempre
ConfigError - No se pudo cargar la configuración (config-que-no-existe.json)
causa: ENOENT
es ConfigError: true
  • La opción cause guarda el error original. Tu mensaje dice qué intentabas hacer y todavía puedes ver qué pasó realmente (ENOENT, un JSON mal formado, lo que sea).
  • Una clase propia te deja distinguir errores con instanceof, por ejemplo para responder 400 en uno y 500 en otro.
  • finally se ejecuta tanto si hubo error como si no. Aquí solo imprime, pero es el lugar para cerrar conexiones o liberar recursos.

Qué pasa con una promesa rechazada que nadie atrapa

Desde Node 15, un rechazo que nadie atrapa termina el proceso:

async function fallar() {
  throw new Error('algo salió mal');
}

fallar(); // nadie atrapa el error
setTimeout(() => console.log('esto nunca se imprime'), 1000);

Node imprime el error con su traza, el mensaje de setTimeout nunca sale y el código de salida es 1. En un servidor eso significa que una petición mal manejada tumba a todos los usuarios.

Como red de seguridad puedes registrar un manejador global:

process.on('unhandledRejection', (razon) => {
  console.error('Rechazo sin atrapar:', razon.message);
  // lo normal es registrar el error y cerrar el proceso
  process.exit(1);
});

Promise.reject(new Error('nadie me atrapó'));

Pero es solo eso, una red de seguridad. Úsala para registrar el error y cerrar con calma, no para seguir como si nada: después de un error inesperado, el estado de tu aplicación puede estar a medias.

Resultado: manejo de errores en Node.js con Express

Aquí es donde más se nota la diferencia, y si usas Express te toca de cerca. Una ruta async que falla, con el mismo código en las dos versiones:

import express from 'express';
import { readFile } from 'node:fs/promises';

const app = express();

app.get('/config', async (req, res) => {
  // si esto falla, Express 5 manda el error al manejador de errores
  const texto = await readFile('no-existe.json', 'utf8');
  res.json(JSON.parse(texto));
});

// Manejador de errores: lleva 4 parámetros
app.use((err, req, res, next) => {
  console.error('Error atrapado por Express:', err.code);
  res.status(500).json({ error: 'Error interno' });
});

const servidor = app.listen(3055, async () => {
  const r = await fetch('http://localhost:3055/config');
  console.log('Estado:', r.status, await r.json());
  servidor.close();
});

Con Express 5, el error de la ruta llega al manejador de errores y el cliente recibe un 500, con el servidor vivo:

Error atrapado por Express: ENOENT
Estado: 500 { error: 'Error interno' }

Con Express 4, el mismo código tumba el servidor completo. Termina con el error ENOENT y código de salida 1, porque Express 4 no sabe qué hacer con una promesa rechazada. Ninguna petición vuelve a recibir respuesta.

Si sigues en Express 4, envuelve tus rutas async con una función que mande el error a next:

import express from 'express';
import { readFile } from 'node:fs/promises';

const app = express();

// Envuelve el handler para mandar los errores a next()
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/config', asyncHandler(async (req, res) => {
  const texto = await readFile('no-existe.json', 'utf8');
  res.json(JSON.parse(texto));
}));

app.use((err, req, res, next) => {
  console.error('Error atrapado por Express:', err.code);
  res.status(500).json({ error: 'Error interno' });
});

const servidor = app.listen(3056, async () => {
  const r = await fetch('http://localhost:3056/config');
  console.log('Estado:', r.status, await r.json());
  servidor.close();
});
Error atrapado por Express: ENOENT
Estado: 500 { error: 'Error interno' }

Con ese envoltorio, Express 4 también responde un 500 y el servidor sigue vivo. En Express 5 ya no lo necesitas. En las dos versiones conviene tener un manejador de errores como el de arriba, con cuatro parámetros (err, req, res, next), para decidir tú qué recibe el cliente.

Errores comunes

1. Olvidar el await dentro de un try/catch

Parece inofensivo, pero el catch no atrapa nada si no esperas la promesa:

import { readFile } from 'node:fs/promises';

async function leerSinAwait() {
  try {
    return readFile('no-existe.txt', 'utf8'); // falta el await
  } catch (err) {
    console.log('Atrapado dentro de la función:', err.code);
  }
}

async function leerConAwait() {
  try {
    return await readFile('no-existe.txt', 'utf8');
  } catch (err) {
    console.log('Atrapado dentro de la función:', err.code);
  }
}

console.log('--- sin await ---');
try {
  await leerSinAwait();
} catch (err) {
  console.log('Se escapó al que la llamó:', err.code);
}

console.log('--- con await ---');
await leerConAwait();
--- sin await ---
Se escapó al que la llamó: ENOENT
--- con await ---
Atrapado dentro de la función: ENOENT

Sin el await, la función devuelve la promesa y se sale del try antes de que falle. El error ya no cae en tu catch, sino en quien llamó a la función. Si dentro de un try devuelves una promesa, ponle return await.

2. Lanzar un texto en lugar de un Error

JavaScript te deja lanzar cualquier cosa, incluso un texto. Es mala idea, porque pierdes la traza:

try {
  throw 'algo salió mal';           // mal: lanza un texto
} catch (err) {
  console.log('es Error:', err instanceof Error, '| stack:', err.stack);
}

try {
  throw new Error('algo salió mal'); // bien: lanza un Error
} catch (err) {
  console.log('es Error:', err instanceof Error, '| stack:', err.stack.split('\n')[0]);
}
es Error: false | stack: undefined
es Error: true | stack: Error: algo salió mal

Con el texto no hay stack, así que cuando algo falle en producción no sabrás de dónde vino. Lanza siempre un Error (o una clase que herede de él).

3. Atrapar el error y no hacer nada con él

Un catch vacío, o uno que solo hace console.log(err) y deja que el programa siga, esconde el problema. El programa continúa con datos incompletos y el fallo aparece después, lejos de su causa. Si atrapas un error, haz algo útil con él: responder al usuario, reintentar, agregar contexto con cause, o volver a lanzarlo.

(Si tienes alguna anécdota real con un error asíncrono en producción, este es el lugar para contarla en uno o dos párrafos. Por ejemplo, un servidor que se caía por una promesa sin atrapar o una ruta que se quedó colgada esperando.)

Recomendaciones

  • Atrapa el error donde puedas hacer algo útil con él. Si solo lo vas a volver a lanzar, déjalo subir; si puedes agregar contexto, usa cause.
  • Lanza siempre objetos Error, y crea clases propias cuando necesites distinguir tipos de error.
  • Para el manejo de errores en Node.js en una API, define un solo manejador de errores central y que las rutas le pasen los errores, en lugar de repetir la lógica en cada ruta.
  • No dejes unhandledRejection como único manejo de errores. Es el último recurso, no el plan.
  • Si estás en Express 4, usa un asyncHandler o considera pasar a la versión 5.

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *