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
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:
- 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);
}
- 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;
}
}
}
- 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.