Tutorial para manejar errores asíncronos con try/catch en promesas

  • Comprender el motor de JavaScript, la pila de llamadas y las colas de tareas es clave para predecir cómo y cuándo se ejecutan los manejadores de errores en código asíncrono.
  • Las promesas introducen un “try/catch invisible” que captura excepciones y permite encadenar then y catch para gestionar errores de forma centralizada y legible.
  • La sintaxis async/await simplifica el manejo de errores usando bloques try/catch clásicos y favorece una escritura más secuencial del código asíncrono.
  • Eventos globales como unhandledrejection y patrones de composición correctos ayudan a evitar rechazos no manejados y a diseñar una estrategia robusta de logging y notificación de errores.

manejar errores asíncronos con try/catch

Manejar bien los errores asíncronos en JavaScript marca la diferencia entre una aplicación profesional y una que se rompe a la mínima. Cuando empiezas a usar Promises y async/await, lo normal es tirar de try/catch “porque toca”, pero sin una estrategia clara es fácil acabar con errores silenciosos, mensajes poco útiles para el usuario y advertencias de “Unhandled promise rejection” por toda la consola.

En este artículo vamos a ver, con calma y con ejemplos prácticos, cómo estructurar un tutorial completo para manejar errores asíncronos con try/catch en promesas, cómo funciona el motor de JavaScript por debajo, qué papel juegan las colas de tareas y microtareas, y cómo combinar correctamente .then(), .catch() y async/await para no dejar ningún error suelto, ni en el navegador ni en Node.js.

¿Cómo funciona internamente JavaScript y por qué afecta al manejo de errores?

Antes de entrar de lleno al manejo de errores con promesas, conviene entender un poco el motor de JavaScript, su pila de llamadas y el entorno de ejecución. Esto explica por qué ciertos errores aparecen “después” o por qué las promesas se comportan distinto a los callbacks clásicos.

cómo solucionar el Thermal Framework en Android
Artículo relacionado:
Las mejores aplicaciones para aprender a programar desde el móvil

Entorno de ejecución, contexto y pila de llamadas

JavaScript se ejecuta en un único hilo (al menos conceptualmente), lo que implica que el motor solo puede estar haciendo una cosa al mismo tiempo en la pila de llamadas. Para no bloquear toda la web mientras esperamos a la red, al disco o al DOM, los navegadores exponen Web APIs que permiten hacer operaciones asíncronas y que, más adelante, devuelven el resultado mediante callbacks, promesas o async/await.

Todo el código de JavaScript vive en algún contexto de ejecución. Tenemos el contexto global (el primero que se crea al cargar el script o la página) y, cada vez que se invoca una función, se crea un nuevo contexto de función con sus variables, su this y un enlace al contexto exterior. Estos contextos se apilan en la call stack (pila de llamadas) siguiendo una política LIFO: la última función que entra es la primera que sale.

Cuando llamas a una función como first(), esta se coloca en la pila; si dentro llama a second() y esta a third(), verás cómo la pila va añadiendo y sacando contextos, y cada mensaje por consola refleja el orden de entrada y salida de la pila. Herramientas como el depurador del navegador, los breakpoints o console.trace() permiten inspeccionar esa pila en tiempo real.

Colas de tareas y microtareas: por qué las promesas se ejecutan “después”

El motor de JavaScript también trabaja con varias colas de tareas. Las más relevantes aquí son la cola de macrotareas y la de microtareas. En la cola de macrotareas entran cosas como setTimeout, setInterval o eventos del DOM. En la cola de microtareas entran, entre otras, las callbacks de las promesas y los observadores de mutación del DOM.

El ciclo de eventos funciona así: se ejecuta el código síncrono, luego se atiende una macrotarea, después se vacía la cola de microtareas (todas las pendientes), y solo entonces se procede a la siguiente macrotarea. Por eso un then() o un catch() asociado a una promesa se dispara antes que un setTimeout(..., 0), aunque ambas parezcan “inmediatas”.

Este detalle es clave para entender cuándo se ejecutan los manejadores de errores de las promesas y por qué un error que ocurre dentro de un .then() no revienta tu script al instante, sino que aparece en una fase posterior del ciclo de eventos.

Callbacks, callback hell y el salto a Promises

Históricamente, las operaciones asíncronas en JavaScript se manejaban siempre con callbacks. Una función recibía otra función como argumento y la ejecutaba cuando la tarea terminaba: peticiones al servidor, temporizadores, lectura de ficheros con File API, operaciones con IndexedDB, etc.

Imagina una función fetchData(callback) que llama internamente a setTimeout para simular una petición de red y, pasados unos segundos, invoca el callback con los datos. Mientras tanto, el programa continúa su ejecución mostrando mensajes como “Data is being fetched…”. Esto ilustra que el callback se ejecuta después, cuando el evento llega a la cola y el motor lo procesa.

Callback hell: cuando los callbacks se vuelven inmanejables

Cuando empiezas a encadenar operaciones que dependen unas de otras, la cosa se complica. Por ejemplo, manejas el clic de un botón, haces una petición con XMLHttpRequest (sin promesas), usas setTimeout para diferir el procesamiento, lees un fichero de un <input type="file">, y después guardas el resultado en indexedDB. Cada una de estas APIs espera un callback distinto.

Si intentas coordinar todo con callbacks anidados, terminas con la famosa “pirámide de la perdición” o callback hell: mucha indentación, difícil de leer, casi imposible de mantener y un infierno a la hora de gestionar errores, porque tienes que repetir la lógica de error en cada nivel.

Este escenario fue uno de los grandes motivos para la introducción de las promesas en JavaScript: una forma más declarativa y manejable de representar operaciones asíncronas y sus posibles fallos.

Promesas: estados, encadenamiento y manejo de errores

manejar errores asíncronos con try/catch

Una Promise es un objeto que representa el resultado futuro de una operación asíncrona: puede completarse bien o fallar. Internamente tiene tres estados: pendiente, cumplida (resuelta) o rechazada. El constructor de Promise recibe una función “ejecutora” que a su vez recibe dos funciones: resolve y reject.

Por ejemplo, puedes crear una promesa que, tras un tiempo aleatorio, llame a resolve() o reject() según cierta condición. Luego, usando then(), defines qué hacer cuando la promesa se resuelve, y con catch() qué hacer cuando se rechaza. Este patrón te permite devolver un objeto de inmediato (la promesa) que actuará como “contenedor” de ese resultado futuro.

Consumiendo promesas: then, catch y encadenamiento

Las funciones modernas suelen devolver directamente una promesa. En lugar de pasar callbacks de éxito y error por separado, recibes una promise a la que le “cuelgas” callbacks con then() y catch(). Por ejemplo, una función crearArchivoAudioAsync() podría devolver una promesa en lugar de esperar callbacks al viejo estilo.

El verdadero poder llega con el encadenamiento de promesas. El método then() devuelve siempre una nueva promesa. Eso significa que puedes escribir cosas como:

hazAlgo() seguido de .then(hazAquello), que a su vez devuelve otra promesa a la que le encadenas .then(hazOtraCosa), y así sucesivamente. Cada paso representa la finalización del anterior, y si cualquiera de estos callbacks devuelve otra promesa, la cadena se ajusta automáticamente para esperar a que esa nueva promesa se resuelva.

Con este patrón, lo que antes era una pirámide de callbacks anidados se convierte en una cadena plana de operaciones asíncronas, mucho más legible. Además, los errores se pueden manejar de forma centralizada con un único .catch() al final de la cadena.

El “try/catch invisible” en las promesas

Uno de los puntos clave de las promesas es que el ejecutor interno y los manejadores (then, catch) llevan incorporado un “try/catch invisible”. Por ejemplo, al usar funciones flecha y this en JavaScript, cualquier excepción que se lance dentro de una promesa, o en el código de un manejador, se traduce automáticamente en un rechazo de la promesa.

Así, el siguiente código:

new Promise((resolve, reject) => { throw new Error("oops") })

es funcionalmente equivalente a llamar explícitamente a reject(new Error("oops")). Lo mismo ocurre en los callbacks de .then(): si haces throw dentro, no revienta todo tu script; en su lugar, la promesa resultante pasa al estado rechazado y el control salta al siguiente manejador de error de la cadena.

Manejo de errores: catch, propagación y re-lanzado

El método .catch() captura cualquier rechazo que se produzca en la promesa anterior o en cualquiera de las que haya por encima en la cadena, siempre que el error no haya sido ya manejado. Puedes colocar un .catch() al final de una cadena larga de .then() para centralizar el tratamiento de errores, igual que tendrías un único try..catch envolviendo varios bloques de código.

Dentro de .catch() puedes analizar el tipo de error (por ejemplo, distinguir entre un URIError u otros errores de programación), tratarlo si sabes cómo actuar, y, si no puedes gestionarlo, volver a lanzar el error con throw. Al hacer esto, el control saltará al siguiente manejador .catch() de la cadena, permitiendo diseñar estrategias de recuperación en capas.

También puedes hacer que un .catch() “cierre” el error de forma satisfactoria (sin volver a lanzarlo) y devuelva un valor de recuperación. En ese caso, la cadena continúa su curso normal y el siguiente then se ejecuta como si todo hubiera ido bien, recibiendo ese valor “alternativo”.

Ten en cuenta que .then() puede recibir dos argumentos: el primero para el éxito y el segundo para el error. Este segundo parámetro actúa como un manejador local de errores para esa etapa concreta. Sin embargo, en la práctica, es más habitual usar .catch() separado, ya que hace el flujo de errores más claro.

¿Qué pasa con los errores no manejados?

Si una promesa se rechaza y no existe ningún manejador de error (.catch() o segundo argumento de .then()) en la cadena, el error queda “atascado” y el motor lo considera un rechazo no manejado. Esto es bastante similar a lo que ocurre con una excepción síncrona que no está dentro de un bloque try..catch: el script “revienta” y ves el mensaje en la consola.

En los navegadores modernos puedes interceptar estos errores globalmente con el evento unhandledrejection. Este evento recibe un PromiseRejectionEvent con una propiedad promise (la promesa que falló) y una reason (la causa del rechazo). Con esto puedes registrar el error en un servidor, mostrar un mensaje amigable al usuario o, como mínimo, evitar que el fallo pase desapercibido.

Existe también el evento rejectionhandled, que se dispara cuando una promesa que había sido rechazada acaba recibiendo un manejador de error más tarde. Ambos eventos permiten montar una estrategia global de observación y tratamiento de errores de promesas.

En Node.js, el entorno emite eventos similares (unhandledRejection) que por defecto se registran en consola. Puedes engancharte a estos eventos para centralizar el logging de errores o impedir que se llene la salida estándar de trazas no controladas.

Patrones prácticos para manejar errores con Promises

Para que el manejo de errores con promesas sea robusto, es importante seguir algunos patrones de composición y evitar errores típicos que rompen la cadena de control.

Siempre encadenar y devolver promesas

Un error muy común es crear una promesa dentro de un .then() pero no devolverla. Si haces eso, la cadena se “parte” en dos: por un lado sigue la promesa original y por otro lado la nueva promesa interna, sin que esta influya en la secuencia principal.

Jotta-cli en Linux
Artículo relacionado:
Cómo programar tareas automáticas con Crontab en Linux

La regla de oro es “si creas una promesa dentro de un manejador, devuélvela”. Así, el then superior esperará a que esa promesa interna termine (sea resuelta o rechazada), y los errores seguirán una ruta única hacia un .catch() común en lugar de desperdigarse en manejadores separados.

También conviene terminar las cadenas con un .catch() final. De esta forma te aseguras de que cualquier error no manejado antes acabe siendo capturado en algún punto, evitando que terminen como rechazos no gestionados a nivel global.

Evitar el anidamiento innecesario de then y catch

Otro anti-patrón clásico es anidar .then() uno dentro de otro en lugar de encadenarlos. El anidamiento excesivo conduce a un alcance de errores confuso y suele ir de la mano del error anterior (no devolver las promesas internas).

El anidamiento solo tiene sentido cuando quieres que un manejador catch interno cubra únicamente una parte concreta de la cadena. Por ejemplo, puedes anidar un bloque que englobe una serie de pasos “opcionales” y tener un catch dedicado que solo capture fallos de esa sección, mientras que un catch exterior se reserva para errores críticos del resto del flujo.

En este patrón, los errores dentro del bloque opcional se gestionan en el catch interno y, una vez atendidos, la ejecución puede continuar con tareas críticas posteriores. Si un error se produce en una parte crítica anterior al bloque opcional, ese fallo lo absorbe únicamente el catch final de la cadena.

Envolver APIs antiguas de callbacks en promesas

Muchas APIs antiguas, como setTimeout() o algunas librerías heredadas, todavía esperan callbacks al estilo “antiguo” y no devuelven promesas. Si llamas directamente a estas funciones dentro de código basado en promesas, pierdes la capacidad de capturar errores de forma uniforme.

La solución es envolver esas APIs en un constructor de Promise. Por ejemplo, puedes crear delay(ms) que devuelva una promesa resuelta tras cierto tiempo usando internamente setTimeout. A partir de ahí, siempre usas la función envuelta que devuelve promesas, nunca la API antigua, de forma que todo tu código asíncrono sea “promise-friendly”.

Además de new Promise(), tienes atajos como Promise.resolve() y Promise.reject() para crear rápidamente promesas ya resueltas o rechazadas, útiles para composición o para pruebas.

Componer promesas en paralelo o en secuencia

Para ejecutar varias operaciones asíncronas en paralelo y esperar a que todas terminen, dispones de Promise.all(). Este método recibe un array de promesas y devuelve una promesa que se resuelve cuando todas han tenido éxito, o se rechaza en cuanto una de ellas falla.

Si tienes un array y quieres lanzar una promesa por cada elemento, puedes usar .map() para transformarlo en un array de promesas y pasarlo a Promise.all(). Este patrón es ideal cuando no te importa el orden de resolución, solo que se completen todas.

Para composición secuencial con arrays, puedes usar reduce() para construir una cadena de promesas equivalente a Promise.resolve().then(func1).then(func2).... Incluso es posible encapsular este patrón en una función de composición (composeAsync) que reciba varias funciones (síncronas o asíncronas) y devuelva una nueva función que encadena todas sobre un valor inicial.

En ECMAScript moderno, muchas de estas composiciones se vuelven más naturales usando async/await, como veremos ahora.

Async/Await: sintaxis más limpia con el mismo modelo de errores

Las palabras clave async y await aportan una forma más legible de trabajar con promesas. Una función marcada como async devuelve siempre una promesa, aunque dentro parezca que estás devolviendo un valor normal. Y await te permite “pausar” la ejecución de esa función hasta que una promesa se resuelva o se rechace.

Por ejemplo, una función async function getUser() puede llamar a await fetch(...) y luego a await response.json(), y finalmente devolver el objeto resultante. Por fuera, quien invoque getUser() recibe una promesa que se resuelve con esos datos.

Manejo de errores con try/catch en funciones async

El gran beneficio de async/await es que puedes usar bloques try/catch “de toda la vida” para manejar errores asíncronos. Si una promesa esperada con await se rechaza, se lanza una excepción dentro de la función async, que puedes atrapar con catch.

Esto hace que la gestión de errores se parezca mucho al código síncrono: agrupas los await críticos dentro de un try, analizas la excepción en el catch, registras detalles técnicos para depuración, muestras un mensaje entendible al usuario y, si procede, vuelves a lanzar el error para que capas superiores decidan qué hacer.

También puedes tener un bloque finally que se ejecuta siempre, tanto si ha habido error como si no, ideal para liberar recursos, resetear estados de carga o cerrar conexiones temporales.

Ejecución secuencial y dependencias entre llamadas

Cuando una operación depende de los resultados de otra, await facilita mucho el flujo. Puedes hacer una primera llamada para obtener información del usuario, extraer de ahí la URL de sus repositorios, y después lanzar una segunda petición con await, garantizando que la segunda solo se ejecuta cuando la primera ha terminado correctamente.

Este estilo evita la proliferación de .then() anidados y hace que el código parezca casi síncrono, aunque por debajo siga usando promesas y el mismo modelo de ejecución basado en colas de microtareas.

Top-level await en módulos

En los módulos ES modernos se permite usar await directamente a nivel superior, sin necesidad de envolver el código en una función async. Esto está pensado para casos como cargar configuración inicial, obtener datos necesarios para renderizar o hacer una petición fetch cuyo resultado se usa inmediatamente para poblar estructuras globales del módulo.

Con este enfoque puedes escribir algo como un const colors = await fetch(...) en la parte superior del archivo, y el módulo no se considerará completamente evaluado hasta que esa promesa se resuelva. El modelo de errores sigue siendo el mismo: si esa promesa se rechaza y no se maneja, se propagará como error del módulo.

Relación entre Promises y manejo global de errores

Las promesas resuelven uno de los problemas fundamentales del modelo basado en callbacks: capturar todos los errores, incluidas las excepciones de programación, en una estructura que facilita la composición funcional de tareas asíncronas.

Cuando una promesa se rechaza y nadie la gestiona, el tiempo de ejecución emite los eventos globales adecuados (unhandledrejection en navegador, unhandledRejection en Node.js). Esto es muy útil para hacer un último nivel de defensa: registrar el error, notificar a un sistema de monitorización, mostrar un mensaje general al usuario o, al menos, evitar que esos errores inunden la consola sin control.

Llamar a event.preventDefault() en estos manejadores puede desactivar el comportamiento por defecto del entorno (por ejemplo, imprimir la traza en la consola), siempre que hayas decidido gestionarlo tú mismo de otra forma. En cualquier caso, lo recomendable es revisar con cuidado qué promesas están siendo rechazadas y si se trata de errores reales de programación antes de ignorarlos.

Buenas prácticas para una estrategia sólida de manejo de errores asíncronos

Para cerrar el círculo, conviene resumir algunas pautas que ayudan a tener un sistema robusto de manejo de errores en código asíncrono con promesas y async/await:

  • Coloca .catch() justo donde sepas cómo reaccionar. No es obligatorio capturar todos los errores; hay fallos irrecuperables en los que lo mejor es dejar que el error suba y solo monitorizarlo de forma global.
  • Aprovecha el segundo argumento de .then() solo cuando necesites tratamiento muy local. En la mayoría de casos, un .catch() único por cadena es más legible y menos propenso a errores.
  • No satures al usuario con detalles técnicos. Muestra mensajes claros y humanos en la interfaz, y reserva el detalle técnico (stack traces, payloads, etc.) para el logging interno o los sistemas de monitorización.
  • Centraliza el registro de errores. Ya sea con eventos globales de promesas no manejadas, middlewares, interceptores HTTP o utilidades compartidas, intenta tener pocos puntos donde se decida qué se registra y cómo.
  • Devuelve siempre las promesas que crees y cierra las cadenas con catch. Esto reduce drásticamente los errores no capturados y hace que tu flujo de control sea predecible.
  • En funciones async, no olvides await. Una omisión puede provocar que una promesa rechazada no quede envuelta por el try/catch, escapando a lugares donde no esperabas manejarla.
manejar errores asíncronos con try/catch
Artículo relacionado:
Mejores IDEs para programar en Windows

Combinando promesas, try/catch y eventos globales como unhandledrejection, es posible construir aplicaciones donde los errores asíncronos están bajo control, el usuario recibe mensajes claros y el equipo de desarrollo cuenta con la información necesaria para depurar y mejorar el código sin que la aplicación “muera” sin explicación para quien la está usando. Comparte esta guía de programación y ayuda a otros usuarios a resolver errores asíncronos con Try/catch.