Entendiendo await, return await y return

Descubre cuándo usar await, return y return await en funciones async de JavaScript para mejorar la legibilidad, depuración y manejo de errores.


TL;DR:

Usa return await en funciones async para obtener mejores stack traces durante la depuración; su impacto en el rendimiento es insignificante en la mayoría de los casos.


La programación asíncrona puede ser complicada, especialmente cuando se trabaja con funciones async y el uso de await, return await y return. Hoy profundizaremos en estos conceptos, entenderemos sus diferencias y aprenderemos cuándo usar cada uno de ellos.

Introducción a las funciones asíncronas

Una breve introducción para asegurarnos de que todos estamos en la misma página:

JavaScript es un lenguaje de un solo hilo, lo que significa que puede ejecutar una tarea a la vez. Sin embargo, las aplicaciones modernas a menudo necesitan realizar operaciones que consumen tiempo, como obtener datos de un servidor o leer archivos. Para evitar bloquear el hilo principal y mantener la aplicación receptiva, JavaScript utiliza la programación asíncrona.

Las funciones asíncronas te permiten escribir código que no espera a que una tarea se complete antes de pasar a la siguiente.

Antes de async y await, usábamos callbacks y Promises para manejar operaciones asíncronas. Aunque funcionales, estos métodos podían llevar a estructuras de código complejas y difíciles de leer, a menudo referidas como "callback hell".

JS
// Callback Hell
fetchData(function (error, data) {
  if (error) {
    handleError(error);
  } else {
    processData(data, function (error, processedData) {
      if (error) {
        handleError(error);
      } else {
        // Further processing or final actions
      }
    });
  }
});

// Using Promises
fetchData()
  .then(data => processData(data))
  .catch(error => handleError(error))

    

Con la introducción de async y await, JavaScript proporciona una forma más sencilla y legible de manejar código asíncrono, haciéndolo parecer y comportarse más como código síncrono.

Entendiendo async y await

La palabra clave async

  • Propósito: Declara una función como asíncrona.
  • Comportamiento: Toda función async devuelve una Promise. Si la función devuelve un valor, la Promise resolverá ese valor. Si la función lanza un error —esto es crucial— la Promise se rechaza con ese error. Hablaremos más sobre el manejo de errores más adelante.
JS
async function fetchData() {
  return 'Data fetched';
}

fetchData().then((data) => console.log(data)); // Outputs: Data fetched

async function fetchData() {
  throw Error('Error while fetching');
}

fetchData().catch((error) => console.log(error)); // Outputs: Error: 'Error while fetching'

    

La palabra clave await

  • Propósito: Pausa la ejecución de una función async hasta que una Promise devuelve un resultado (resolve o reject).
  • Comportamiento: Solo se puede usar dentro de funciones async. Hace que el código espere a que la Promise se resuelva y devuelve el valor resuelto.
JS
...

async function getData() {
  const data = await fetchData();
  console.log(data);
}

getData(); // Outputs: Data fetched

    

Al usar await, puedes escribir código asíncrono que se lee como código síncrono, lo que facilita su comprensión y mantenimiento.

Una pregunta antes de continuar: ¿qué sucederá si fetchData retorna un error?.

await y try/catch

La palabra clave await juega un papel fundamental si deseas manejar errores en tus funciones async.

Si usas try/catch dentro de una función async y await en tus resultados, cualquier rechazo de Promise se manejará de manera adecuada, transformándose en una excepción.

JS
const delayAndFail = (time = 2000) =>
  new Promise((resolve, reject) =>
    setTimeout(() => {
      reject('Something went wrong!');
    }, time),
  );

async function execute() {
  try {
    await delayAndFail();
  } catch (error) {
    console.log(error);
  }
}

execute(); // Outputs: 'Something went wrong!'

    

Pero, si no haces await a tus resultados dentro de un bloque try/catch, el rechazo de la Promise no será capturado.

JS
...

async function execute() {
  try {
    delayAndFail(); // No awaiting here
  } catch (error) {
    console.log(error);
  }
}

execute(); // Outputs: 'Uncaught (in promise) Something went wrong!'

    

Diferencias entre await, return await y return

Una de las partes más complicadas de entender (al menos para mí) es cuándo usar estas nuevas palabras clave, específicamente return y return await.

Hay muchos artículos que sugieren diferentes enfoques. También existen algunas reglas generales, que al final fueron descartadas…

Vamos a revisarlo de una vez por todas, porque entender las diferencias es crucial.

await

  • Uso: Se utiliza dentro de una función async para esperar a que una Promise se resuelva antes de proceder a la siguiente instrucción.
  • Efecto: Pausa la ejecución de la función hasta que la Promise esperada se resuelve, luego devuelve el valor resuelto.
JS
async function execute() {
    await delayAndFail();
}

    

Esto debería estar bastante claro a este punto. Pero ahora, return se une a la fiesta:

return await

  • Uso: Espera a que la Promise se resuelva y luego devuelve el valor resuelto.
  • Efecto: Similar a usar await y luego devolver el valor.
JS
async function execute() {
    return await delayAndFail();
}

    

return

  • Uso: Devuelve una Promise inmediatamente, sin esperar a que se resuelva dentro de la función.
  • Efecto: La función sale y devuelve una Promise pendiente. El que llama a la función puede manejar la Promise.

Ejemplo:

JS
async function execute() {
    return delayAndFail();
}

    

Por qué la diferencia importa

Como puedes ver, hacer await antes de return puede parecer redundante. Es más o menos lo mismo, ¿verdad?.

No, no lo es. Hay una razón por la que casi siempre deberías usar return await en lugar de solo return: para obtener mejor información de errores al manejar rechazos de Promises.

Cuando añadimos await después de return, estamos (prácticamente hablando) esperando dentro de la función a que la Promise se cumpla (resuelva o rechace) antes de hacer cualquier cosa. Si la Promise se rechaza, entonces una referencia a la función estará presente en el stack trace.

JS
async function fail() {
  try {
    await Promise.reject("I'm failing")
  } catch(error) {
    throw new Error(error.message)
  }
}

async function execute() {
  return await fail();
}

execute().catch(error => console.log(error.stack));

/* Outputs this trace
    Error
        at fail (<anonymous>:5:11)
        at async execute (<anonymous>:10:10) */

    

Pero si dejamos de hacer await antes del return, no hay rastro de la función execute.

JS
...

async function execute() {
  return fail();
}

execute().catch(error => console.log(error.stack));

/* Outputs this trace
    Error
        at fail (<anonymous>:5:11) */

    

Por cierto, todo esto lo trato más a fondo (con ejemplos reales) en el taller de JavaScript Moderno.

Pero es menos eficiente

Sí, usar return sin await puede ser ligeramente más eficiente porque no añade una microtarea extra al event loop. En mi experiencia todo lo que puedo decir es esto: para la mayoría de los escenarios prácticos, esta supuesta penalización de rendimiento ni siquiera importa. De hecho, parece ser que podrías estar haciéndote más daño que bien.

Conclusión

Usar await permite que tu código asíncrono se lea y se comporte como código síncrono, haciéndolo más intuitivo.

Cuando se trata de elegir entre return y return await, recuerda que return await puede proporcionar stack traces más completos, lo cual es vital para depurar. Aunque hay una pequeña penalización en rendimiento, en la mayoría de las aplicaciones del mundo real, la claridad y el mejor manejo de errores superan con creces su impacto.

Juan Andrés Núñez
Juan Andrés Núñez
Ingeniero Frontend Senior. Especialista en Vue.js. Speaker. Docente profesional. Estoico.