Asincronía en JavaScript

La programación asíncrona es el mecanismo que permite a JavaScript delegar tareas de larga duración (como peticiones a servidores, lectura de archivos o temporizadores) para continuar ejecutando el resto del código sin bloquear la aplicación. Para resolver el desafío de gestionar estas tareas en un lenguaje de un solo hilo, la comunidad y el propio lenguaje desarrollaron tres soluciones sucesivas: Callbacks, Promesas y Async/Await.

Callbacks, Promesas y Async / Await

Estos tres conceptos son pilares fundamentales para entender cómo se gestiona la asincronía en JavaScript. Representan los tres grandes hitos evolutivos del lenguaje y reflejan cómo han cambiado las soluciones técnicas a lo largo del tiempo.

La naturaleza de JavaScript

Javascript es, por definición, un lenguaje síncrono y de un solo hilo: Synchronous and Single-Threaded Languaje.

Al inicio... callbacks

Al principio, JavaScript dependía exclusivamente de los callbacks. Un callback es, sencillamente, una función que se pasa como argumento dentro de otra función, con el objetivo de que se ejecute una vez que la tarea asíncrona haya terminado.

El problema principal de los callbacks… surgió cuando se requería realizar múltiples tareas asíncronas secuenciales (por ejemplo, realizar la petición A, usar su resultado para la petición B, y luego disparar la C), el código comenzaba a anidarse profundamente hacia la derecha..

Necesidad de cambio: Este patrón dio lugar al famoso Callback Hell (Infierno de los Callbacks) o “Pirámide de la Muerte”. El código se volvía sumamente difícil de leer, mantener y, sobre todo, gestionar los errores en cada nivel de anidación. Era una pesadilla propensa a fallos.

Promesas (ES6 / 2015)

Para solucionar las deficiencias del callback hell, el estándar ECMAScript 6 introdujo las Promesas (objeto promise). En lugar de pasar funciones “hacia adentro”, una promesa devuelve un objeto que representa un valor que estará disponible en el futuro. Una promesa es, por tanto, un objeto que representa la eventual finalización (o el fracaso) de una operación asíncrona.

Una promesa puede transicionar por tres estados:

  1. Pending (Pendiente): Estado inicial, la operación no ha terminado.
  2. Fulfilled (Resuelta): La operación se completó con éxito.
  3. Rejected (Rechazada): La operación falló.

Podemos encadenar métodos a una promesa utilizando la palabra clave then y atrapar los errores con la palabra clave catch, lo que nos permite manejar los resultados exitosos y los errores de forma más clara y concisa.

Diferencia con los callbacks: Se introdujo el encadenamiento (.then().then().catch()). El código dejó de crecer hacia la derecha y empezó a crecer hacia abajo, de forma lineal.

La mejora clave: Un solo .catch al final de la cadena podía atrapar los errores de cualquier paso anterior eliminando la necesidad de duplicar código de control de errores.

async/await (ES8 / 2017)

A pesar de las ventajas de las promesas, los bloques extensos de .then() y funciones flecha anónimas aún generaban cierto “ruido visual”. Para optimizarlo, se introdujeron las palabras clave async y await.

¿Nueva tecnología?: En realidad, no. Es "azúcar sintáctico". En Informática se denomina azúcar sintáctico a una capa cosmética construida sobre una tecnología, en este caso, las promesas. async/await. Permite escribir código asincrónico pero que se lee e interpreta visualmente, como si fuera síncrono: (línea por línea).

La mejora clave: Desaparecen los métodos .then() y .catch(). El manejo de errores se realiza utilizando el clásico try {} catch (error) {} que se usa en el resto del lenguaje.

Al anteponer la palabra clave async a una función, indicamos que dicha función manejará asincronía (y que automáticamente retornará una promesa). Dentro de ella, la palabra clave await detiene pausadamente la ejecución de la función hasta que la promesa se resuelva, permitiendo un flujo de lectura más comprensible línea a línea.

Comparativa Estilo de Código Problema Principal
Callbacks obtenerUsuario(id, function(u) { obtenerPosts(u.id, function(p) { ... }) }) Código ilegible (Callback Hell)
Promesas obtenerUsuario(id).then(u => obtenerPosts(u.id)).then(p => ...) Demasiada sintaxis verbal (.then)
Async/Await const u = await obtenerUsuario(id); const p = await obtenerPosts(u.id); Ninguno, es limpio y fácil de leer

Ejemplo de la evolución en el manejo de la asincronía usando código:

Para ilustrar esta evolución usaremos un mismo caso de uso: Buscar un usuario y, si existe, buscar sus publicaciones. Incluye el manejo de errores en cada era.

"Base de Datos" ficticia

Solución con Callbacks

Se puede apreciar, cómo el manejo de errores obliga a pasar un parámetro error en cada función y cómo el código se va desplazando a la derecha.

Solución con Promesas

Aquí el código crece hacia abajo y usamos .then y .catch.

Solución con Async/Await

Usamos las mismas funciones que retornan promesas del ejemplo anterior, pero el consumo cambia radicalmente.

Tanto obtenerUsuarioPromise(1) como obtenerPostsPromise(1) son operaciones asíncronas, porque su objetivo principal es gestionar una tarea que toma tiempo (en este caso, el setTimeout que simula la espera de la red) y devolver una Promesa.

1. Funciones que retornan Promesas (Estilo ES6)

obtenerUsuarioPromise y obtenerPostsPromise son funciones normales cuyo “superpoder” es que retornan un objeto Promise. Nacen en 2015. Generan asincronía, pero el motor de JavaScript las ve como funciones comunes y corrientes que devuelven un objeto especial.

2. Funciones Asíncronas nativas (async) (Estilo ES8)

Los dos caminos posibles:
  1. Si hay éxito: Se ejecuta el try ➡️ se salta el catch ➡️ se ejecuta el finally
  2. Si hay error: Se ejecuta el try (hasta donde falle) ➡️ se ejecuta el catch ➡️ se ejecuta el finally.
Sobre las funciones asíncronas nativas:

En JavaScript, el término técnico “Función asíncrona” (Async Function) se reserva específicamente para aquellas funciones que llevan la palabra clave async por delante, como nuestro ejemplo:

async function ejecutarProceso() { ... }

Estas funciones son especiales porque:

Para resumir… :

obtenerUsuarioPromise es una función que gestiona asincronía devolviendo una Promesa.

ejecutarProceso es, técnicamente hablando, la Función Asíncrona (async) que consume esa promesa usando await.

IMPORTANTE
Solo se debe declarar una función como async si dentro de ella vamos a utilizar await para esperar al menos una promesa real (un proceso que verdaderamente tome tiempo en el futuro). Si no hay promesas de por medio, la función debe ser una función normal.


A continuación inserto un video que me gustó sobre el tema: