Ir al contenido principal

Patrones comunes con async/await

Intoducción

La sintaxis async/await nos ha permitido escribir código asíncrono de forma más legible y estructurada, similar a la programación síncrona. Sin embargo, para aprovechar al máximo estas características, es importante conocer y aplicar patrones comunes que nos ayuden a resolver problemas frecuentes de una manera eficiente y elegante.

En este artículo exploraremos los patrones más utilizados con async/await, desde operaciones secuenciales y paralelas hasta técnicas para manejar la concurrencia, implementar tiempos de espera y estrategias de reintentos. Estos patrones te ayudarán a escribir código más robusto y mantenible.

Operaciones secuenciales vs. paralelas

Uno de los aspectos más importantes al trabajar con código asíncrono es decidir si las operaciones deben ejecutarse en secuencia (una después de otra) o en paralelo (todas a la vez).

Ejecución secuencial

Cuando necesitamos que una operación asíncrona se complete antes de iniciar la siguiente, utilizamos un patrón secuencial. Esto es útil cuando una operación depende del resultado de la anterior.

async function procesarDatosSecuencialmente() {
  try {
    // Primero obtenemos los datos del usuario
    const usuario = await obtenerDatosUsuario(123);
    
    // Con el ID del usuario, obtenemos sus pedidos
    const pedidos = await obtenerPedidosUsuario(usuario.id);
    
    // Con los pedidos, calculamos estadísticas
    const estadisticas = await calcularEstadisticas(pedidos);
    
    return estadisticas;
  } catch (error) {
    console.error("Error en el procesamiento secuencial:", error);
    throw error;
  }
}

// Funciones asíncronas de ejemplo
async function obtenerDatosUsuario(id) {
  // Simulamos una petición a base de datos o API
  return new Promise(resolve => {
    setTimeout(() => {
      resolve({ id: id, nombre: "Ana", email: "ana@ejemplo.com" });
    }, 1000);
  });
}

async function obtenerPedidosUsuario(userId) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { id: 1, producto: "Teclado", precio: 50 },
        { id: 2, producto: "Monitor", precio: 200 }
      ]);
    }, 1000);
  });
}

async function calcularEstadisticas(pedidos) {
  return new Promise(resolve => {
    setTimeout(() => {
      const total = pedidos.reduce((sum, pedido) => sum + pedido.precio, 0);
      resolve({ 
        cantidad: pedidos.length, 
        total: total, 
        promedio: total / pedidos.length 
      });
    }, 500);
  });
}

En este ejemplo, cada operación debe esperar a que la anterior termine porque necesita su resultado. El tiempo total será la suma de los tiempos individuales (unos 2,5 segundos en este caso).

Ejecución paralela

Cuando las operaciones asíncronas son independientes entre sí, podemos ejecutarlas en paralelo para mejorar el rendimiento.

async function obtenerDatosEnParalelo() {
  try {
    // Iniciamos todas las peticiones a la vez sin esperar
    const promesaUsuarios = obtenerUsuarios();
    const promesaProductos = obtenerProductos();
    const promesaCategorias = obtenerCategorias();
    
    // Esperamos a que todas terminen
    const [usuarios, productos, categorias] = await Promise.all([
      promesaUsuarios,
      promesaProductos,
      promesaCategorias
    ]);
    
    return {
      usuarios,
      productos,
      categorias
    };
  } catch (error) {
    console.error("Error al obtener datos en paralelo:", error);
    throw error;
  }
}

// Funciones asíncronas de ejemplo
async function obtenerUsuarios() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { id: 1, nombre: "Carlos" },
        { id: 2, nombre: "Laura" }
      ]);
    }, 1000);
  });
}

async function obtenerProductos() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { id: 101, nombre: "Portátil" },
        { id: 102, nombre: "Smartphone" }
      ]);
    }, 1200);
  });
}

async function obtenerCategorias() {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve([
        { id: 1, nombre: "Electrónica" },
        { id: 2, nombre: "Hogar" }
      ]);
    }, 800);
  });
}

En este caso, las tres peticiones se inician inmediatamente sin esperar a que terminen las anteriores. La función Promise.all() espera a que todas terminen, y el tiempo total será aproximadamente el de la operación más lenta (unos 1,2 segundos en este ejemplo).

Procesamiento en lote

A menudo necesitamos procesar grandes cantidades de datos que, si los procesáramos de golpe, podrían saturar la memoria o sobrecargar un servicio. El patrón de procesamiento por lotes nos permite dividir los datos en grupos más pequeños y procesarlos en secuencia.

async function procesarPorLotes(items, tamanoLote = 3) {
  const resultados = [];
  
  // Dividimos el array en lotes
  for (let i = 0; i < items.length; i += tamanoLote) {
    const lote = items.slice(i, i + tamanoLote);
    console.log(`Procesando lote ${i/tamanoLote + 1} con ${lote.length} elementos`);
    
    // Procesamos cada lote de forma paralela
    const resultadosLote = await Promise.all(
      lote.map(item => procesarItem(item))
    );
    
    // Agregamos los resultados
    resultados.push(...resultadosLote);
  }
  
  return resultados;
}

async function procesarItem(item) {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(`Resultado de procesar ${item}`);
    }, 500);
  });
}

// Ejemplo de uso
async function ejecutarProcesamiento() {
  const items = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
  const resultados = await procesarPorLotes(items);
  console.log("Resultados completos:", resultados);
}

En este ejemplo, procesamos los elementos en lotes de 3, lo que permite controlar la carga de trabajo y evitar sobrecargar los recursos.

Manejo de concurrencia

En algunas situaciones, queremos limitar el número de operaciones asíncronas que se ejecutan simultáneamente, para evitar saturar servicios o consumir demasiados recursos. Esto es diferente del procesamiento por lotes, ya que no esperamos a que todos los elementos de un lote terminen antes de continuar.

async function ejecutarConConcurrenciaLimitada(tareas, concurrenciaMaxima = 2) {
  const resultados = [];
  const tareasEnProceso = new Set();
  const tareasRestantes = [...tareas];
  
  // Mientras haya tareas por procesar
  while (tareasRestantes.length > 0 || tareasEnProceso.size > 0) {
    // Mientras no superemos la concurrencia máxima y haya tareas pendientes
    while (tareasEnProceso.size < concurrenciaMaxima && tareasRestantes.length > 0) {
      const tarea = tareasRestantes.shift();
      
      // Creamos una promesa para esta tarea específica
      const promesaTarea = (async () => {
        try {
          return await tarea();
        } finally {
          // Al terminar (con éxito o error), eliminamos la tarea de las que están en proceso
          tareasEnProceso.delete(promesaTarea);
        }
      })();
      
      // Guardamos la referencia a esta promesa
      tareasEnProceso.add(promesaTarea);
      
      // Capturamos el resultado cuando esté disponible
      promesaTarea.then(resultado => {
        resultados.push(resultado);
      }).catch(error => {
        console.error("Error en tarea:", error);
      });
    }
    
    // Esperamos a que al menos una tarea termine
    if (tareasEnProceso.size >= concurrenciaMaxima || tareasRestantes.length === 0) {
      await Promise.race(tareasEnProceso);
    }
  }
  
  return resultados;
}

// Ejemplo de uso
async function pruebaDeConurrencia() {
  const tareas = [
    () => simularTarea("Tarea 1", 2000),
    () => simularTarea("Tarea 2", 1000),
    () => simularTarea("Tarea 3", 3000),
    () => simularTarea("Tarea 4", 800),
    () => simularTarea("Tarea 5", 1500)
  ];
  
  console.log("Iniciando tareas con concurrencia limitada...");
  const resultados = await ejecutarConConcurrenciaLimitada(tareas, 2);
  console.log("Todos los resultados:", resultados);
}

async function simularTarea(nombre, duracion) {
  console.log(`Iniciando ${nombre}`);
  await new Promise(resolve => setTimeout(resolve, duracion));
  console.log(`Completada ${nombre} después de ${duracion}ms`);
  return `Resultado de ${nombre}`;
}

Este patrón es útil para escenarios como descargar archivos o hacer peticiones a un API con límites de velocidad, donde queremos mantener un flujo constante de operaciones sin sobrecargar los recursos.

Conversión entre promesas y async/await

A veces necesitamos trabajar con código basado en promesas tradicionales y código que usa async/await. Es importante entender cómo integrar ambos estilos.

// Función basada en promesas
function obtenerDatosConPromesas(id) {
  return fetch(`https://api.ejemplo.com/datos/${id}`)
    .then(respuesta => {
      if (!respuesta.ok) {
        throw new Error(`Error HTTP: ${respuesta.status}`);
      }
      return respuesta.json();
    })
    .then(datos => {
      return procesarDatos(datos);
    });
}

// La misma función usando async/await
async function obtenerDatosConAsync(id) {
  const respuesta = await fetch(`https://api.ejemplo.com/datos/${id}`);
  if (!respuesta.ok) {
    throw new Error(`Error HTTP: ${respuesta.status}`);
  }
  const datos = await respuesta.json();
  return procesarDatos(datos);
}

// Usar una función async dentro de una cadena de promesas
function combinarEstilos(id) {
  return obtenerDatosConPromesas(id)
    .then(async (datos) => {
      // Aquí podemos usar await dentro de un then
      const datosAdicionales = await obtenerDatosExtraCon(datos.id);
      return { ...datos, ...datosAdicionales };
    });
}

// Función asíncrona que devuelve una promesa
async function procesarDatos(datos) {
  // Cualquier función async siempre devuelve una promesa
  return { procesado: true, datos };
}

La clave aquí es recordar que cualquier función async devuelve implícitamente una promesa, lo que permite mezclar ambos estilos según sea necesario.

Loop asíncronos

Los bucles tradicionales como for y forEach no esperan a que las operaciones asíncronas dentro de ellos se completen antes de continuar con la siguiente iteración. Esto puede llevar a resultados inesperados.

Bucle for con await

async function procesarElementosSecuenciales(elementos) {
  const resultados = [];
  
  for (let i = 0; i < elementos.length; i++) {
    // Esperamos a que cada operación termine antes de continuar
    const resultado = await procesarElemento(elementos[i]);
    resultados.push(resultado);
  }
  
  return resultados;
}

// Con for...of
async function procesarConForOf(elementos) {
  const resultados = [];
  
  for (const elemento of elementos) {
    const resultado = await procesarElemento(elemento);
    resultados.push(resultado);
  }
  
  return resultados;
}

En ambos casos, los elementos se procesan en secuencia, uno tras otro.

Procesamiento en paralelo con map

async function procesarElementosParalelos(elementos) {
  // Mapeamos cada elemento a una promesa y esperamos a que todas terminen
  const promesas = elementos.map(elemento => procesarElemento(elemento));
  return Promise.all(promesas);
}

Este enfoque ejecuta todas las operaciones en paralelo y espera a que todas terminen.

Bucle asíncrono con control de flujo

async function procesarConControl(elementos) {
  const resultados = [];
  
  for (const elemento of elementos) {
    try {
      // Podemos usar lógica condicional
      if (elemento.requiereProcesamiento) {
        const resultado = await procesarElemento(elemento);
        resultados.push(resultado);
      } else {
        resultados.push({ saltado: true, elemento });
      }
    } catch (error) {
      console.error(`Error al procesar ${elemento}:`, error);
      resultados.push({ error: true, elemento, mensaje: error.message });
      // Podemos decidir si continuar o romper el bucle
      if (error.esCritico) break;
    }
  }
  
  return resultados;
}

Este patrón nos permite tener un control más granular del flujo, incluyendo manejo de errores específico para cada elemento.

Implementación de timeout

A veces necesitamos establecer un límite de tiempo para las operaciones asíncronas, especialmente cuando dependemos de servicios externos que podrían no responder.

function agregarTimeout(promesa, tiempoLimite) {
  // Creamos una promesa que se rechaza después del tiempo límite
  const promesaTimeout = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error(`Operación excedió el tiempo límite de ${tiempoLimite}ms`));
    }, tiempoLimite);
  });
  
  // Devolvemos la primera promesa que se resuelva o rechace
  return Promise.race([promesa, promesaTimeout]);
}

async function obtenerDatosConTimeout(url, tiempoLimite = 5000) {
  try {
    const respuesta = await agregarTimeout(fetch(url), tiempoLimite);
    return await respuesta.json();
  } catch (error) {
    console.error("Error al obtener datos:", error);
    throw error; // Relanzamos el error para que lo gestione el llamador
  }
}

// Ejemplo de uso
async function ejemploTimeout() {
  try {
    const datos = await obtenerDatosConTimeout('https://api.ejemplo.com/datos', 3000);
    console.log("Datos obtenidos:", datos);
  } catch (error) {
    console.error("La operación falló:", error.message);
    // Manejamos el timeout
    if (error.message.includes('tiempo límite')) {
      console.log("Se ha producido un timeout, mostrando datos en caché...");
    }
  }
}

Este patrón es especialmente útil para mejorar la experiencia del usuario, evitando que espere indefinidamente cuando hay un problema con un servicio externo.

Retry patterns (reintentos)

Muchas operaciones asíncronas, como las peticiones de red, pueden fallar por razones temporales. Implementar una estrategia de reintentos puede hacer que nuestras aplicaciones sean más robustas.

async function operacionConReintentos(operacion, opciones = {}) {
  const {
    intentosMaximos = 3,
    retrasoPrimerIntento = 1000,
    factorBackoff = 2,
    condicionReintento = error => true
  } = opciones;
  
  let ultimoError;
  let retraso = retrasoPrimerIntento;
  
  for (let intento = 1; intento <= intentosMaximos; intento++) {
    try {
      if (intento > 1) {
        console.log(`Reintento ${intento-1} de ${intentosMaximos-1}...`);
      }
      
      return await operacion();
    } catch (error) {
      ultimoError = error;
      
      // Verificamos si debemos reintentar según la condición
      if (intento === intentosMaximos || !condicionReintento(error)) {
        break;
      }
      
      console.log(`Error en intento ${intento}. Reintentando en ${retraso}ms...`, error.message);
      
      // Esperamos antes del siguiente intento con backoff exponencial
      await new Promise(resolve => setTimeout(resolve, retraso));
      retraso *= factorBackoff;
    }
  }
  
  throw ultimoError;
}

// Ejemplo de uso
async function obtenerDatosConReintentos(url) {
  return operacionConReintentos(
    async () => {
      const respuesta = await fetch(url);
      if (!respuesta.ok) {
        throw new Error(`Error HTTP: ${respuesta.status}`);
      }
      return respuesta.json();
    },
    {
      intentosMaximos: 4,
      retrasoPrimerIntento: 1000,
      factorBackoff: 2,
      // Solo reintentamos en ciertos errores HTTP
      condicionReintento: (error) => {
        // Reintentar en errores de red o códigos 5xx o 429 (rate limit)
        return !error.message.includes('HTTP') || 
               error.message.includes('5') || 
               error.message.includes('429');
      }
    }
  );
}

// Probando la función
async function probarReintentos() {
  try {
    const datos = await obtenerDatosConReintentos('https://api.ejemplo.com/datos');
    console.log("Datos obtenidos con éxito:", datos);
  } catch (error) {
    console.error("Error después de todos los reintentos:", error);
  }
}

Este patrón implementa una estrategia de backoff exponencial, donde cada reintento espera más tiempo que el anterior, lo que puede ayudar en situaciones de saturación temporal de un servicio.

Buenas prácticas para un código mantenible

Al aplicar estos patrones, es importante seguir algunas prácticas que harán que nuestro código sea más fácil de mantener y depurar:

  1. Separar la lógica de negocio de la gestión de asincronía:
// Mal ejemplo
async function cargarYProcesarDatos() {
  try {
    const respuesta = await fetch('/api/datos');
    const datos = await respuesta.json();
    
    // Mezcla de lógica de negocio con gestión de asincronía
    const datosTransformados = datos.map(item => ({
      ...item,
      fecha: new Date(item.timestamp),
      valorCalculado: item.valor * 1.21
    }));
    
    const resultado = await guardarEnBaseDeDatos(datosTransformados);
    return resultado;
  } catch (error) {
    console.error("Error:", error);
    throw error;
  }
}

// Buen ejemplo
async function cargarYProcesarDatos() {
  try {
    const datos = await obtenerDatos();
    const datosTransformados = transformarDatos(datos);
    return await guardarDatos(datosTransformados);
  } catch (error) {
    console.error("Error en el proceso:", error);
    throw error;
  }
}

// Funciones con responsabilidad única
async function obtenerDatos() {
  const respuesta = await fetch('/api/datos');
  return respuesta.json();
}

function transformarDatos(datos) {
  return datos.map(item => ({
    ...item,
    fecha: new Date(item.timestamp),
    valorCalculado: item.valor * 1.21
  }));
}

async function guardarDatos(datos) {
  return await guardarEnBaseDeDatos(datos);
}
  1. Gestionar errores de forma específica:
async function procesarArchivo(ruta) {
  try {
    const contenido = await leerArchivo(ruta);
    const datos = await parsearContenido(contenido);
    return await procesarDatos(datos);
  } catch (error) {
    // Manejar diferentes tipos de errores
    if (error instanceof ArchivoNoEncontradoError) {
      console.error(`El archivo ${ruta} no existe.`);
      return valorPorDefecto();
    } else if (error instanceof FormatoInvalidoError) {
      console.error(`El formato del archivo ${ruta} es inválido.`);
      throw new ErrorProcesamiento(`No se pudo procesar ${ruta} por formato inválido`);
    } else {
      console.error(`Error desconocido al procesar ${ruta}:`, error);
      throw error;
    }
  }
}
  1. Usar clases y métodos para organizar el código asíncrono:
class GestorDeDatos {
  constructor(configuracion) {
    this.config = configuracion;
    this.cache = new Map();
  }
  
  async obtenerDatos(id) {
    // Verificar caché
    if (this.cache.has(id)) {
      return this.cache.get(id);
    }
    
    try {
      const datos = await this.fetchDatos(id);
      this.cache.set(id, datos);
      return datos;
    } catch (error) {
      throw new Error(`Error al obtener datos para ID ${id}: ${error.message}`);
    }
  }
  
  async fetchDatos(id) {
    const respuesta = await fetch(`${this.config.apiUrl}/datos/${id}`);
    if (!respuesta.ok) {
      throw new Error(`Error HTTP: ${respuesta.status}`);
    }
    return respuesta.json();
  }
  
  // Más métodos para procesar, actualizar, etc.
}

// Uso
const gestor = new GestorDeDatos({
  apiUrl: 'https://api.ejemplo.com',
  cacheTime: 60000
});

async function ejemplo() {
  const datos = await gestor.obtenerDatos(123);
  console.log(datos);
}

Resumen

Hemos explorado los patrones más comunes y útiles al trabajar con async/await en JavaScript. Desde la gestión de operaciones secuenciales y paralelas, hasta técnicas más avanzadas como el procesamiento por lotes, la limitación de concurrencia, implementación de timeouts y estrategias de reintentos.

Estos patrones te ayudarán a crear aplicaciones más robustas, eficientes y mantenibles al enfrentarte a operaciones asíncronas. La combinación adecuada de estos patrones te permitirá resolver problemas complejos de forma elegante, mejorando tanto el rendimiento como la experiencia del usuario en tus aplicaciones JavaScript.

A medida que avances en tu viaje con JavaScript, encontrarás oportunidades para aplicar estos patrones y desarrollar los tuyos propios, adaptándolos a los requisitos específicos de cada proyecto.