Concurrencia con JS

¿Sabes como optimizar este bloque de código a mitad del tiempo?

async function getPageData() {
  const user = await fetchUser()
  const product = await fetchProduct()
}

Como puedes observar en el bloque de código, esperamos a que se obtengan los datos del usuario para poder obtener los datos del producto de una forma secuencial.

Pero uno no depende del otro, por lo tanto, no deberíamos esperar una petición que termine para continuar con la otra.

En vez de eso, podemos hacer las dos peticiones juntas y esperar ambas por concurrencia.

Usando Promise All

Una manera para solucionar eso, es utilizar promise all

async function getPageData() {
  const [user, product] = await Promise.all([
    fetchUser(), fetchProduct()
  ])
}

Y ahora imagínate que cada petición dura 1 segundo en responder, mientras que en nuestra función original esperaríamos ambos en una fila totalizando 2 segundos para que se complete nuestra función, en esta nueva función esperamos ambos simultáneamente para que nuestra función se complete en 1 segundo, ¡la mitad del tiempo!

Pero, hay un problema

Here is the problem...

Primero, no estamos capturando ningun tipo de error.

Asi que puedes decir, pondre un gran bloque de try y catch

async function getPageData() {
  try {
    const [user, product] = await Promise.all([
      fetchUser(), fetchProduct()
    ])
  } catch (err) {
    // 🚩 this has a big problem...
  }
}

Pero esto tiene un gran problema.

Supongamos que el método fechUser() marca un error, eso nos llevara un error en tiempo de ejecución y se pasara al catch y bloqueara el flujo continuo de la aplicación.

Pero aquí está el truco: si fetchProducts luego se equivoca, esto no activará el bloque de captura. Esto se debe a que nuestra función ya ha continuado. El código de captura se ha ejecutado, la función se ha completado, hemos seguido adelante.

Así que esto resultará en un rechazo de la promesa no manejado. Ack.

Entonces, si tenemos algún tipo de lógica de manejo, eso le pregunta al usuario o guarda en un servicio de registro de errores, así:

// ...
} catch (err) {
  handle(err)
}
// ...

function handle(err) {
  alertToUser(err)
  saveToLoggingService(err)
}

Lamentablemente, solo seremos conscientes del primer error. El segundo error se perderá en el éter, sin comentarios de los usuarios, sin ser capturado en nuestros registros de errores, es efectivamente invisible (además de un poco de ruido en la consola del navegador).

Resolviendo con Catch

Una solución a nuestro problema anterior es pasar una función a .catch(), por ejemplo así:

function onReject(err) {
  handle(err)
  return err
}

async function getPageData() {
  const [user, product] = await Promise.all([
    fetchUser().catch(onReject), // ⬅️
    fetchProduct().catch(onReject) // ⬅️
  ])

  if (user instanceof Error) {
    handle(user) // ✅
  }
  if (product instanceof Error) {
    handle(product) // ✅
  }
}

En este caso, si nos sale un error, volvemos a manejar el error y lo devolvemos. Así que ahora nuestros objetos de usuario y producto resultantes son un error, que podemos verificar con instanceof, o de lo contrario nuestro buen resultado real.

Esto no es tan malo, y resuelve nuestros problemas anteriores.

Pero, el principal inconveniente aquí es que debemos asegurarnos de que siempre estamos proporcionando ese .catch(onReject), religiosamente, en todo nuestro código. Lamentablemente, esto es bastante fácil de pasar por alto, y tampoco es el más fácil de escribir una regla de eslint a prueba de balas.

Cachando errores por separado

Como nota al margen, es útil tener en cuenta que no siempre necesitamos esperar inmediatamente una promesa después de crearla. Otra técnica que podemos utilizar aquí que es prácticamente la misma es esta:

async function getPageData() {
  // Fire both requests together
  const userPromise = fetchUser().catch(onReject)
  const productPromise = fetchProduct().catch(onReject)

  // Await together
  const user = await userPromise
  const product = await productPromise

  // Handle individually 
  if (user instanceof Error) {
    handle(user)
  }
  if (product instanceof Error) {
    handle(product)
  }
}

Debido a que disparamos cada búsqueda antes de esperar a cualquiera de ellas, esta versión tiene los mismos beneficios de rendimiento que nuestros ejemplos anteriores que usan Promise.all .

Además, en este formato, podemos usar de forma segura try/catch si lo deseamos sin los problemas que tuvimos anteriormente:

async function getPageData() {
  const userPromise = fetchUser().catch(onReject)
  const productPromise = fetchProduct().catch(onReject)

  // Try/catch each
  try {
    const user = await userPromise
  } catch (err) {
    handle(err)
  }
  try {
    const product = await productPromise
  } catch (err) {
    handle(err)
  }
}

Entre estos tres, personalmente me gusta la versión de Promise.all, ya que se siente más idiomático decir "espera estas dos cosas juntas". Pero dicho esto, creo que esto se reduce a la preferencia personal.

Resolviendo con Promise.allSettled

Otra solución, que está integrada en JavaScript, es usar Promise.allSettled.

Con Promise.allSettled, en lugar de recuperar el usuario y el producto directamente, obtenemos un objeto de resultado que contiene el valor o error de cada resultado de la promesa.

async function getPageData() {
  const [userResult, productResult] = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])
}

Los objetos resultantes tienen 3 propiedades:

  • status: "fulfilled" or "rejected"
  • value: Solo está presente si el estado se "cumple". El valor con el que se cumplió la promesa
  • reason: Solo está presente si el estado es "rechazado". La razón por la que la promesa fue rechazada con.

Así que ahora podemos leer cuál era el estado de cada promesa y procesar cada error individualmente, sin perder nada de esta información crítica:

async function getPageData() {
  // Fire and await together
  const [userResult, productResult] = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])

  // Process user
  if (userResult.status === 'rejected') {
    const err = userResult.reason
    handle(err)
  } else {
    const user = userResult.value
  }

  // Process product
  if (productResult.status === 'rejected') {
    const err = productResult.reason
    handle(err)
  } else {
    const product = productResult.value
  }
}

Pero, eso es un poco repetitivo. Así que vamos a abstraer esto:

async function getPageData() {
  const results = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])

  // Nicer on the eyes
  const [user, product] = handleResults(results)
}

Y podemos implementar una función simple handleResults así:

// Generic function to throw if any errors occured, or return the responses
// if no errors happened
function handleResults(results) {
  const errors = results
    .filter(result => result.status === 'rejected')
    .map(result => result.reason)

  if (errors.length) {
    // Aggregate all errors into one
    throw new AggregateError(errors)
  }

  return results.map(result => result.value)
}

Podemos usar un truco ingenioso aquí, la clase AggergateError, para lanzar un error que puede contener múltiples dentro. De esta manera, cuando se detecta, obtenemos un solo error con todos los detalles, a través de la propiedad .errors en un AggregateError que incluye todos los errores incluidos:

async function getPageData() {
  const results = await Promise.allSettled([
    fetchUser(), fetchProduct()
  ])

  try {
    const [user, product] = handleResults(results)
  } catch (err) {
    for (const error of err.errors) {
      handle(error)
    }
  }
}

Y oye, esto es bastante simple, agradable y genérico. Me gusta.