Async / Await

Experto
JavaScript
JavaScript
Actualizado: 19/05/2025

¡Desbloquea el curso completo!

IA
Ejercicios
Certificado
Entrar

Sintaxis declarativa: Transformación de código basado en promesas a estilo síncrono

La sintaxis async/await representa una evolución significativa en la forma de escribir código asíncrono en JavaScript. Esta característica, introducida en ES2017, permite estructurar operaciones asíncronas de manera que se lean y escriban como código síncrono tradicional, mejorando drásticamente la legibilidad y mantenibilidad del código.

Fundamentos de async/await

Async/await es una forma de escribir código asíncrono que nos permite evitar el anidamiento excesivo de callbacks y cadenas de promesas. Básicamente, nos permite trabajar con promesas como si estuviéramos escribiendo código síncrono.

La palabra clave async se utiliza para declarar una función asíncrona, mientras que await permite esperar a que una promesa se resuelva y obtener su valor resultante.

// Función que devuelve una promesa
function getUserData(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId > 0) {
        resolve({ id: userId, name: "Example User" });
      } else {
        reject(new Error("Invalid user ID"));
      }
    }, 1000);
  });
}

// Versión con promesas
function processUser(userId) {
  return getUserData(userId)
    .then(user => {
      console.log(`User found: ${user.name}`);
      return user;
    })
    .catch(error => {
      console.error("Error:", error.message);
      throw error;
    });
}

// Transformación a async/await
async function processUserAsync(userId) {
  try {
    const user = await getUserData(userId);
    console.log(`User found: ${user.name}`);
    return user;
  } catch (error) {
    console.error("Error:", error.message);
    throw error;
  }
}

Cuando utilizamos await, la ejecución de la función se pausa hasta que la promesa se resuelve, pero sin bloquear el hilo principal. Esto nos permite escribir código asíncrono que parece síncrono, lo que facilita su lectura y comprensión.

Reglas fundamentales

Para utilizar correctamente async/await, debemos tener en cuenta estas reglas esenciales:

  • La palabra clave await solo puede usarse dentro de funciones declaradas con async
// Incorrecto: await fuera de una función async
const user = await getUserData(1); // SyntaxError

// Correcto: await dentro de una función async
async function getUser() {
  const user = await getUserData(1);
  return user;
}
  • Una función async siempre devuelve una promesa

Aunque retornemos un valor normal desde una función async, JavaScript lo envolverá automáticamente en una promesa:

async function example() {
  return 123; // Se envuelve automáticamente en una promesa
}

// Equivalente a:
function exampleEquivalent() {
  return Promise.resolve(123);
}

// Para obtener el valor:
example().then(value => console.log(value)); // 123
  • El valor que devuelve la expresión await es el valor resuelto de la promesa
async function showValue() {
  const promise = Promise.resolve("Hello World");
  const value = await promise;
  console.log(value); // "Hello World"
}

Transformando cadenas de promesas

Una de las ventajas más evidentes de async/await es la simplificación de cadenas de promesas complejas. Veamos un ejemplo de transformación:

// Código basado en promesas con anidamiento
function getOrderDetails(userId) {
  return getUser(userId)
    .then(user => {
      return getOrders(user.id)
        .then(orders => {
          return getProductDetails(orders[0].productId)
            .then(product => {
              return { user, order: orders[0], product };
            });
        });
    });
}

// Transformado a async/await
async function getOrderDetails(userId) {
  const user = await getUser(userId);
  const orders = await getOrders(user.id);
  const product = await getProductDetails(orders[0].productId);
  return { user, order: orders[0], product };
}

La versión con async/await elimina la anidación y hace que el flujo de datos sea mucho más claro. El código se lee de arriba hacia abajo, similar a cómo pensamos en los pasos secuenciales.

Variables y ámbito

Otra ventaja importante es el manejo de variables y ámbito. Con promesas encadenadas, a menudo necesitamos declarar variables en un ámbito superior para compartirlas entre diferentes callbacks:

// Con promesas: necesitamos declarar variables externas
function processUserData(userId) {
  let userData;
  return fetchUserData(userId)
    .then(data => {
      userData = data; // Guardamos en variable externa
      return validateUser(userData);
    })
    .then(isValid => {
      if (isValid) {
        return transformUserData(userData);
      }
      throw new Error('Invalid user');
    });
}

// Con async/await: flujo natural de variables
async function processUserData(userId) {
  const userData = await fetchUserData(userId);
  const isValid = await validateUser(userData);
  
  if (!isValid) {
    throw new Error('Invalid user');
  }
  
  return transformUserData(userData);
}

Con async/await, las variables siguen el flujo natural del código. No necesitamos preocuparnos por compartir datos entre diferentes .then(), lo que reduce la complejidad y evita errores comunes.

Operaciones en paralelo

Aunque async/await favorece un estilo secuencial, también podemos ejecutar operaciones en paralelo cuando sea necesario:

// Ejecución secuencial (más lenta)
async function getResourcesSequential() {
  const users = await getUsers();        // Espera 1 segundo
  const products = await getProducts();  // Espera 1 segundo más
  return { users, products };            // Total: 2 segundos
}

// Ejecución en paralelo (más eficiente)
async function getResourcesParallel() {
  // Iniciamos ambas peticiones sin esperar
  const usersPromise = getUsers();
  const productsPromise = getProducts();
  
  // Ahora esperamos los resultados
  const users = await usersPromise;
  const products = await productsPromise;
  
  return { users, products };            // Total: ~1 segundo
}

También podemos utilizar Promise.all() para una sintaxis más concisa:

async function getResourcesParallel() {
  const [users, products] = await Promise.all([
    getUsers(),
    getProducts()
  ]);
  
  return { users, products };
}

¿Te está gustando esta lección?

Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.

Progreso guardado
Asistente IA
Ejercicios
Iniciar sesión gratis

Más de 25.000 desarrolladores ya confían en CertiDevs

Manejo de errores: Implementación de try/catch en funciones asíncronas

Una de las mayores ventajas de async/await es que podemos utilizar la estructura tradicional try/catch para manejar errores:

async function getUserData(userId) {
  try {
    // Cualquier error en estas operaciones será capturado
    const data = await userService.get(userId);
    const permissions = await permissionService.getForUser(userId);
    
    return {
      data,
      permissions,
      timestamp: new Date()
    };
  } catch (error) {
    console.error('Error al obtener datos del usuario:', error);
    
    // Podemos manejar diferentes tipos de errores
    if (error.name === 'UserNotFound') {
      return { error: 'user_not_found' };
    }
    
    // O propagar el error para manejarlo en un nivel superior
    throw error;
  }
}

El bloque try/catch nos permite capturar cualquier error que ocurra durante la ejecución de las operaciones asíncronas dentro de la función. Esto es mucho más claro que encadenar múltiples .catch() o manejar errores en cada paso.

Errores en operaciones paralelas

Cuando ejecutamos múltiples operaciones asíncronas en paralelo con Promise.all(), un error en cualquiera de ellas provocará que toda la operación falle. Podemos manejar esto con try/catch:

async function loadDashboard() {
  try {
    // Si cualquiera de estas promesas falla, se ejecutará el catch
    const [users, products, sales] = await Promise.all([
      getUsers(),
      getProducts(),
      getSales()
    ]);
    
    return { users, products, sales };
  } catch (error) {
    console.error('Error al cargar dashboard:', error);
    showErrorToUser('No pudimos cargar todos los datos del dashboard');
    
    // Podemos intentar una alternativa secuencial
    return loadDataIndividually();
  }
}

Para manejar errores individuales y continuar con los resultados exitosos, podemos usar Promise.allSettled():

async function loadTolerantDashboard() {
  const results = await Promise.allSettled([
    getUsers(),
    getProducts(),
    getSales()
  ]);
  
  // Transformamos los resultados
  const dashboard = {
    users: results[0].status === 'fulfilled' ? results[0].value : [],
    products: results[1].status === 'fulfilled' ? results[1].value : [],
    sales: results[2].status === 'fulfilled' ? results[2].value : []
  };
  
  // Registramos los errores para diagnóstico
  results.forEach((result, index) => {
    if (result.status === 'rejected') {
      console.error(`Error en la operación ${index}:`, result.reason);
    }
  });
  
  return dashboard;
}

Patrones avanzados de manejo de errores

Retry pattern (patrón de reintento)

Podemos implementar un patrón de reintento para operaciones que pueden fallar temporalmente:

async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let lastError;
  
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
      return await response.json();
    } catch (error) {
      console.warn(`Attempt ${attempt + 1} failed:`, error);
      lastError = error;
      
      // Espera exponencial entre reintentos
      if (attempt < maxRetries - 1) {
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }
  
  throw new Error(`All ${maxRetries} attempts failed. Last error: ${lastError.message}`);
}

Timeout pattern (patrón de tiempo de espera)

Podemos establecer un tiempo máximo de espera para operaciones asíncronas:

async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
  
  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });
    
    clearTimeout(timeoutId);
    
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    
    return await response.json();
  } catch (error) {
    clearTimeout(timeoutId);
    
    if (error.name === 'AbortError') {
      throw new Error(`Request timed out after ${timeoutMs}ms`);
    }
    
    throw error;
  }
}

Transformando callbacks a async/await

Muchas APIs antiguas de JavaScript utilizan callbacks. Podemos transformarlas en promesas y luego usar async/await:

// Función basada en callbacks
function readFile(path, callback) {
  setTimeout(() => {
    if (path.includes('not-exists')) {
      callback(new Error('File not found'));
      return;
    }
    callback(null, 'File content');
  }, 1000);
}

// Promisificación
function readFilePromise(path) {
  return new Promise((resolve, reject) => {
    readFile(path, (error, data) => {
      if (error) reject(error);
      else resolve(data);
    });
  });
}

// Uso con async/await
async function processFile() {
  try {
    const content = await readFilePromise('./data.txt');
    return processContent(content);
  } catch (error) {
    console.error('Error al procesar archivo:', error);
    throw error;
  }
}

Este proceso nos permite modernizar gradualmente una base de código antigua, reemplazando callbacks por promesas y async/await.

Función utilitaria para promisificar

Podemos crear una función auxiliar para convertir sistemáticamente funciones basadas en callbacks:

function promisify(fn) {
  return function(...args) {
    return new Promise((resolve, reject) => {
      fn(...args, (error, result) => {
        if (error) reject(error);
        else resolve(result);
      });
    });
  };
}

// Uso
const readFilePromise = promisify(readFile);
const searchInDatabasePromise = promisify(searchInDatabase);

// Con async/await
async function completeProcessing() {
  const fileContent = await readFilePromise('./config.txt');
  const searchResults = await searchInDatabasePromise(fileContent);
  return searchResults;
}

Consideraciones de rendimiento

Es importante entender que async/await es azúcar sintáctico sobre promesas. No mejora el rendimiento por sí mismo, pero puede hacer que el código sea más mantenible y menos propenso a errores. El motor de JavaScript sigue utilizando promesas internamente.

// Estas dos funciones son equivalentes en rendimiento
async function fetchData() {
  return await fetch('https://api.example.com/data');
}

function fetchData() {
  return fetch('https://api.example.com/data');
}

En el primer ejemplo, el await es innecesario ya que estamos simplemente devolviendo la promesa. Es importante usar async/await donde realmente aporta claridad y no por mera convención.

Patrones avanzados: Loops asíncronos, ejecución paralela y secuencial controlada

Los patrones avanzados de async/await nos permiten gestionar flujos de ejecución complejos que van más allá de simples operaciones secuenciales. Estos patrones son fundamentales para desarrollar aplicaciones JavaScript robustas que manejen eficientemente operaciones asíncronas múltiples, ya sea en paralelo o en secuencia controlada.

Loops asíncronos

Trabajar con bucles y operaciones asíncronas puede ser complicado si no se aplican los patrones correctos. Veamos diferentes enfoques para manejar colecciones de datos de forma asíncrona:

Iteración secuencial con for...of

El bucle for...of combinado con await permite procesar elementos secuencialmente, esperando a que cada operación termine antes de iniciar la siguiente:

async function processItemsSequentially(items) {
  const results = [];
  
  for (const item of items) {
    // Cada iteración espera a que termine la anterior
    const result = await processItem(item);
    results.push(result);
  }
  
  return results;
}

Este patrón es útil cuando el orden de procesamiento es importante o cuando necesitamos limitar la carga en recursos externos.

Iteración paralela controlada

Para procesar elementos en paralelo pero con un límite de concurrencia, podemos implementar un patrón de "pool" de promesas:

async function processWithConcurrencyLimit(items, concurrencyLimit = 3) {
  const results = [];
  const running = new Set();
  
  for (const item of items) {
    const promise = processItem(item).then(result => {
      running.delete(promise);
      return result;
    });
    
    running.add(promise);
    results.push(promise);
    
    if (running.size >= concurrencyLimit) {
      // Espera a que termine al menos una tarea antes de continuar
      await Promise.race(running);
    }
  }
  
  // Espera a que terminen todas las tareas pendientes
  return Promise.all(results);
}

Este patrón es ideal para balancear velocidad y carga, permitiendo paralelismo controlado.

Procesamiento por lotes (batching)

Cuando trabajamos con grandes colecciones, procesar los elementos en lotes puede mejorar significativamente el rendimiento:

async function processBatches(items, batchSize = 5) {
  const results = [];
  
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    // Procesa cada lote en paralelo internamente
    const batchResults = await Promise.all(
      batch.map(item => processItem(item))
    );
    results.push(...batchResults);
  }
  
  return results;
}

Este enfoque combina las ventajas de la ejecución secuencial y paralela, procesando lotes en secuencia pero los elementos dentro de cada lote en paralelo.

Ejecución paralela con control de errores

La ejecución paralela nos permite maximizar el rendimiento, pero necesitamos estrategias para manejar fallos parciales:

Promise.all con manejo de errores mejorado

Podemos mejorar Promise.all para que no falle completamente si una promesa es rechazada:

async function safeParallel(promises) {
  const results = await Promise.allSettled(promises);
  
  return results.map((result, index) => {
    if (result.status === 'fulfilled') {
      return { success: true, value: result.value, index };
    } else {
      return { success: false, error: result.reason, index };
    }
  });
}

// Uso
const operations = [fetchUsers(), fetchProducts(), fetchOrders()];
const results = await safeParallel(operations);

// Procesar resultados y errores
const successfulResults = results.filter(r => r.success).map(r => r.value);
const failedOperations = results.filter(r => !r.success);

Este patrón nos permite continuar con los resultados exitosos incluso cuando algunas operaciones fallan.

Ejecución paralela con timeout

Para evitar que operaciones lentas bloqueen todo el proceso, podemos implementar un timeout individual para cada promesa:

function withTimeout(promise, timeoutMs = 5000) {
  let timeoutId;
  
  const timeoutPromise = new Promise((_, reject) => {
    timeoutId = setTimeout(() => {
      reject(new Error(`Operation timed out after ${timeoutMs}ms`));
    }, timeoutMs);
  });
  
  return Promise.race([
    promise,
    timeoutPromise
  ]).finally(() => clearTimeout(timeoutId));
}

// Uso en paralelo
async function fetchDataWithTimeouts(urls) {
  return Promise.all(
    urls.map(url => withTimeout(fetch(url).then(r => r.json()), 3000))
  );
}

Este patrón garantiza que las operaciones no se extiendan indefinidamente, estableciendo límites de tiempo claros.

Ejecución secuencial controlada

La ejecución secuencial nos permite controlar el flujo de operaciones con mayor precisión:

Cadena de promesas dinámica

Podemos construir una cadena de promesas dinámicamente a partir de un array de tareas:

async function executeSequentially(tasks) {
  return tasks.reduce(async (previousPromise, task) => {
    // Espera a que termine la tarea anterior
    const previousResult = await previousPromise;
    
    // Ejecuta la tarea actual con el resultado anterior
    const currentResult = await task(previousResult);
    
    return currentResult;
  }, Promise.resolve(null)); // Valor inicial
}

// Ejemplo de uso
const tasks = [
  () => fetchUserProfile(userId),
  userProfile => fetchUserPermissions(userProfile.id),
  permissions => validateAccess(permissions, resourceId)
];

const accessResult = await executeSequentially(tasks);

Este patrón es útil para flujos de trabajo donde cada paso depende del resultado del anterior.

Ejecución secuencial con control de flujo

Podemos implementar un patrón que permita decisiones condicionales entre pasos:

async function workflowEngine(initialData) {
  let state = { ...initialData, errors: [] };
  
  // Paso 1: Validación
  try {
    state = await validateUserData(state);
    if (!state.isValid) {
      return { ...state, status: 'validation_failed' };
    }
  } catch (error) {
    state.errors.push({ step: 'validation', error: error.message });
    return { ...state, status: 'error' };
  }
  
  // Paso 2: Procesamiento
  try {
    state = await processUserData(state);
  } catch (error) {
    state.errors.push({ step: 'processing', error: error.message });
    // Continuamos al siguiente paso a pesar del error
  }
  
  // Paso 3: Almacenamiento
  try {
    state = await saveUserData(state);
    return { ...state, status: 'success' };
  } catch (error) {
    state.errors.push({ step: 'storage', error: error.message });
    return { ...state, status: 'partial_success' };
  }
}

Este patrón implementa un flujo de trabajo con decisiones basadas en resultados intermedios y manejo de errores específico para cada paso.

Patrones de cancelación

La capacidad de cancelar operaciones asíncronas es crucial en aplicaciones interactivas:

async function fetchWithCancellation(url) {
  const controller = new AbortController();
  const { signal } = controller;
  
  // Expone el método de cancelación
  const promise = fetch(url, { signal }).then(r => r.json());
  promise.cancel = () => controller.abort();
  
  return promise;
}

// Uso
const dataPromise = fetchWithCancellation('https://api.example.com/data');

// En algún evento (por ejemplo, el usuario navega a otra página)
dataPromise.cancel();

Este patrón nos permite detener operaciones en curso cuando ya no son necesarias, ahorrando recursos y evitando actualizaciones de estado innecesarias.

Sincronización y exclusión mutua

En algunos casos, necesitamos garantizar que ciertas operaciones asíncronas no se ejecuten simultáneamente:

class AsyncMutex {
  constructor() {
    this.locked = false;
    this.waitingQueue = [];
  }
  
  async acquire() {
    if (!this.locked) {
      this.locked = true;
      return;
    }
    
    // Si ya está bloqueado, espera en la cola
    return new Promise(resolve => {
      this.waitingQueue.push(resolve);
    });
  }
  
  release() {
    if (this.waitingQueue.length > 0) {
      // Desbloquea el siguiente en la cola
      const nextResolve = this.waitingQueue.shift();
      nextResolve();
    } else {
      this.locked = false;
    }
  }
  
  // Método de utilidad para ejecutar código con exclusión mutua
  async withLock(fn) {
    await this.acquire();
    try {
      return await fn();
    } finally {
      this.release();
    }
  }
}

// Ejemplo: Garantizar acceso exclusivo a un recurso
const cacheMutex = new AsyncMutex();

async function getOrFetchData(key) {
  return cacheMutex.withLock(async () => {
    // Verificar caché
    if (cache.has(key)) {
      return cache.get(key);
    }
    
    // Obtener datos y actualizar caché
    const data = await fetchData(key);
    cache.set(key, data);
    return data;
  });
}

Este patrón es útil para sincronizar acceso a recursos compartidos como caché, almacenamiento local o cualquier estado que requiera operaciones atómicas.

Composición de patrones

Los patrones más potentes surgen de la combinación de técnicas básicas. Por ejemplo, podemos crear un sistema de tareas en segundo plano con prioridades:

class TaskQueue {
  constructor() {
    this.highPriority = [];
    this.normalPriority = [];
    this.lowPriority = [];
    this.running = false;
  }
  
  addTask(task, priority = 'normal') {
    switch (priority) {
      case 'high': this.highPriority.push(task); break;
      case 'low': this.lowPriority.push(task); break;
      default: this.normalPriority.push(task);
    }
    
    if (!this.running) {
      this.processQueue();
    }
  }
  
  async processQueue() {
    this.running = true;
    
    try {
      while (
        this.highPriority.length || 
        this.normalPriority.length || 
        this.lowPriority.length
      ) {
        // Procesar primero tareas de alta prioridad
        const task = this.highPriority.shift() || 
                     this.normalPriority.shift() || 
                     this.lowPriority.shift();
        
        await task();
      }
    } finally {
      this.running = false;
    }
  }
}

Este patrón combina colas de prioridad con ejecución secuencial para gestionar tareas en segundo plano de manera eficiente.

Los patrones avanzados de async/await nos permiten construir sistemas asíncronos sofisticados y robustos, adaptados a las necesidades específicas de nuestras aplicaciones. La clave está en entender las ventajas y limitaciones de cada enfoque para aplicar el patrón más adecuado a cada situación.

Buenas prácticas y consideraciones finales

1. No usar await innecesariamente

// Innecesario: la función ya devuelve una promesa
async function getData() {
  return await fetchData();
}

// Mejor: retornar la promesa directamente
async function getData() {
  return fetchData();
}

2. Ejecutar operaciones independientes en paralelo

Cuando las operaciones no dependen entre sí, considera ejecutarlas en paralelo:

// Ineficiente: operaciones secuenciales
async function initApp() {
  const config = await loadConfig();
  const translations = await loadTranslations();
  const user = await checkSession();
  return { config, translations, user };
}

// Eficiente: operaciones paralelas
async function initApp() {
  const [config, translations, user] = await Promise.all([
    loadConfig(),
    loadTranslations(),
    checkSession()
  ]);
  return { config, translations, user };
}

3. Recuerda que async/await es azúcar sintáctico

Es importante entender que async/await es simplemente una forma más elegante de trabajar con promesas. No mejora el rendimiento por sí mismo, pero hace que el código sea más mantenible y menos propenso a errores.

// Estas dos funciones son equivalentes en rendimiento
async function getData() {
  const result = await fetchData();
  return processData(result);
}

function getData() {
  return fetchData()
    .then(result => processData(result));
}

4. Manejo de errores consistente

Establece una estrategia consistente para manejar errores en tu aplicación:

async function criticalOperation() {
  try {
    // Intentar la operación
    const result = await performOperation();
    return result;
  } catch (error) {
    // Registrar el error
    logError('criticalOperation', error);
    
    // Decidir si re-lanzarlo o manejarlo aquí
    if (isRecoverableError(error)) {
      return defaultValue;
    } else {
      throw error; // Re-lanzar errores críticos
    }
  }
}

Aprendizajes de esta lección

  • Comprender el concepto de funciones asíncronas (async) en JavaScript y su relación con las promesas.
  • Aprender a utilizar la palabra clave async para declarar funciones asíncronas.
  • Entender cómo el valor retornado dentro de una función async se envuelve automáticamente en una promesa.
  • Conocer el operador await y cómo se utiliza para pausar la ejecución hasta que una promesa se resuelva o rechace.
  • Aprender a manejar errores utilizando try/catch en funciones async.
  • Comprender cómo async y await simplifican y mejoran la legibilidad del código en situaciones con operaciones asíncronas.

Completa JavaScript y certifícate

Únete a nuestra plataforma y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.

Asistente IA

Resuelve dudas al instante

Ejercicios

Practica con proyectos reales

Certificados

Valida tus conocimientos

Más de 25.000 desarrolladores ya se han certificado con CertiDevs

⭐⭐⭐⭐⭐
4.9/5 valoración