← Blog
3 min de lecturaES

Asincronía en JavaScript: Callbacks, Promesas y Async/Await

Entiende cómo funciona la asincronía en JavaScript desde cero: el Event Loop, callbacks, promesas, async/await y los patrones más comunes para manejar operaciones asíncronas.

Tabla de contenidos

JavaScript es un lenguaje single-threaded — solo puede ejecutar una cosa a la vez. Sin embargo, gestiona miles de operaciones concurrentes sin bloquearse: peticiones HTTP, lecturas de archivos, temporizadores... ¿Cómo es posible? La respuesta está en su modelo asíncrono. En este artículo vamos a desgranarlo desde los fundamentos hasta los patrones más modernos.


El Event Loop

El Event Loop es el corazón de la asincronía en JavaScript. Es el mecanismo que permite ejecutar código no bloqueante en un solo hilo.

¿Cómo funciona?

  1. JavaScript ejecuta código en el Call Stack (pila de ejecución), una función a la vez
  2. Cuando encuentra una operación asíncrona (setTimeout, fetch, etc.), la delega a las Web APIs del navegador
  3. Cuando la operación termina, su callback se coloca en una cola de espera
  4. El Event Loop comprueba constantemente: si el Call Stack está vacío, mueve tareas de las colas al stack
  5. Las microtareas (promesas) tienen prioridad sobre las macrotareas (setTimeout, eventos)

Ejemplo paso a paso

console.log("1 - Inicio"); setTimeout(() => { console.log("2 - Timeout"); }, 0); Promise.resolve().then(() => { console.log("3 - Promesa"); }); console.log("4 - Fin");

Salida:

1 - Inicio 4 - Fin 3 - Promesa 2 - Timeout

¿Por qué este orden? Porque:

  • "1 - Inicio" y "4 - Fin" son síncronos → se ejecutan primero
  • La promesa va a la microtask queue → tiene prioridad
  • El setTimeout va a la task queue → se ejecuta después de las microtareas

Callbacks — El origen

Los callbacks fueron la primera forma de manejar asincronía en JavaScript. Un callback es simplemente una función que se pasa como argumento y se ejecuta cuando la operación termina.

// Ejemplo básico: leer un archivo en Node.js const fs = require("fs"); fs.readFile("datos.txt", "utf8", (error, contenido) => { if (error) { console.error("Error leyendo archivo:", error); return; } console.log(contenido); }); console.log("Esto se ejecuta ANTES de leer el archivo");

Callback Hell

El problema de los callbacks es que, cuando encadenas operaciones asíncronas, el código crece horizontalmente y se vuelve ilegible:

// ❌ Callback Hell — la pirámide del terror obtenerUsuario(id, (error, usuario) => { if (error) return manejarError(error); obtenerPedidos(usuario.id, (error, pedidos) => { if (error) return manejarError(error); obtenerDetalles(pedidos[0].id, (error, detalles) => { if (error) return manejarError(error); obtenerEnvio(detalles.envioId, (error, envio) => { if (error) return manejarError(error); console.log("Estado del envío:", envio.estado); }); }); }); });

Cada operación depende de la anterior, así que se anida un nivel más. El resultado: código difícil de leer, mantener y depurar.

flowchart TD A["obtenerUsuario()"] --> B["obtenerPedidos()"] B --> C["obtenerDetalles()"] C --> D["obtenerEnvio()"] D --> E["✅ Resultado final"] A -->|"❌ Error"| ERR["manejarError()"] B -->|"❌ Error"| ERR C -->|"❌ Error"| ERR D -->|"❌ Error"| ERR style A fill:#fef3c7,stroke:#f59e0b,color:#92400e style E fill:#dcfce7,stroke:#22c55e,color:#166534 style ERR fill:#fee2e2,stroke:#ef4444,color:#991b1b

Las promesas llegaron para solucionar exactamente este problema.


Promesas

Una promesa es un objeto que representa el resultado futuro de una operación asíncrona. Puede estar en uno de tres estados:

stateDiagram-v2 [*] --> Pending: Se crea la promesa Pending --> Fulfilled: resolve(valor) Pending --> Rejected: reject(error) Fulfilled --> [*] Rejected --> [*]
  • Pending — En progreso, aún no tiene resultado
  • Fulfilled — La operación se completó con éxito
  • Rejected — La operación falló

Crear una promesa

const miPromesa = new Promise((resolve, reject) => { const exito = true; if (exito) { resolve("Operación completada"); } else { reject(new Error("Algo salió mal")); } });

Consumir promesas con .then() y .catch()

miPromesa .then(resultado => { console.log(resultado); // "Operación completada" }) .catch(error => { console.error(error); }) .finally(() => { console.log("Siempre se ejecuta"); });

Encadenar promesas — Adiós al callback hell

La magia de las promesas es que .then() devuelve una nueva promesa, permitiendo encadenarlas en vez de anidarlas:

// ✅ Promesas encadenadas — plano y legible obtenerUsuario(id) .then(usuario => obtenerPedidos(usuario.id)) .then(pedidos => obtenerDetalles(pedidos[0].id)) .then(detalles => obtenerEnvio(detalles.envioId)) .then(envio => { console.log("Estado del envío:", envio.estado); }) .catch(error => { // Un solo catch maneja TODOS los errores de la cadena console.error("Error:", error); });

Comparado con el callback hell, la mejora es enorme: el código es plano, secuencial y tiene un solo punto de manejo de errores.

Promesas en paralelo

// Promise.all — espera a que TODAS se resuelvan (falla si una falla) const [usuarios, productos, config] = await Promise.all([ fetch("/api/usuarios").then(r => r.json()), fetch("/api/productos").then(r => r.json()), fetch("/api/config").then(r => r.json()) ]); // Promise.allSettled — espera a TODAS, sin importar si fallan const resultados = await Promise.allSettled([ fetch("/api/servicio-a"), fetch("/api/servicio-b"), fetch("/api/servicio-c") ]); resultados.forEach(r => { if (r.status === "fulfilled") { console.log("Éxito:", r.value); } else { console.log("Error:", r.reason); } }); // Promise.race — devuelve la primera que se resuelva o rechace const masRapida = await Promise.race([ fetch("/api/servidor-1"), fetch("/api/servidor-2") ]); // Promise.any — devuelve la primera que se RESUELVA (ignora rechazos) const primeraExitosa = await Promise.any([ fetch("/api/principal"), fetch("/api/respaldo") ]);
Método Comportamiento
Promise.all Espera todas. Falla si una falla
Promise.allSettled Espera todas. Nunca falla
Promise.race Devuelve la primera (éxito o error)
Promise.any Devuelve la primera exitosa

Async/Await

Async/await es azúcar sintáctico sobre las promesas. Hace que el código asíncrono se lea como si fuera síncrono, manteniendo toda la potencia de las promesas por debajo.

// Con promesas function cargarDatos() { return fetch("/api/datos") .then(response => response.json()) .then(datos => { console.log(datos); return datos; }) .catch(error => console.error(error)); } // Con async/await — mismo resultado, más legible async function cargarDatos() { try { const response = await fetch("/api/datos"); const datos = await response.json(); console.log(datos); return datos; } catch (error) { console.error(error); } }

Reglas de async/await

  1. async antes de una función la convierte en una función que siempre devuelve una promesa
  2. await solo se puede usar dentro de una función async
  3. await pausa la ejecución de esa función hasta que la promesa se resuelva
  4. Los errores se manejan con try/catch en vez de .catch()

Patrones comunes

Ejecución secuencial vs paralela

// ❌ Secuencial — cada petición espera a la anterior (lento) async function secuencial() { const usuarios = await fetch("/api/usuarios").then(r => r.json()); const productos = await fetch("/api/productos").then(r => r.json()); const pedidos = await fetch("/api/pedidos").then(r => r.json()); // Tiempo total: suma de las tres peticiones } // ✅ Paralelo — todas las peticiones se lanzan a la vez (rápido) async function paralelo() { const [usuarios, productos, pedidos] = await Promise.all([ fetch("/api/usuarios").then(r => r.json()), fetch("/api/productos").then(r => r.json()), fetch("/api/pedidos").then(r => r.json()) ]); // Tiempo total: la petición más lenta }
sequenceDiagram participant A as App Note over A: ❌ Secuencial (3s total) A->>A: await usuarios (1s) A->>A: await productos (1s) A->>A: await pedidos (1s) Note over A: ✅ Paralelo (1s total) A->>A: Promise.all([usuarios, productos, pedidos]) Note over A: Las tres peticiones se ejecutan a la vez

Iterar sobre operaciones asíncronas

const urls = ["/api/1", "/api/2", "/api/3"]; // Secuencial — una tras otra (cuando el orden importa) for (const url of urls) { const data = await fetch(url).then(r => r.json()); console.log(data); } // Paralelo — todas a la vez (cuando el orden no importa) const resultados = await Promise.all( urls.map(url => fetch(url).then(r => r.json())) );

Reintentos con async/await

async function fetchConReintentos(url, intentos = 3) { for (let i = 0; i < intentos; i++) { try { const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (error) { console.warn(`Intento ${i + 1} fallido:`, error.message); if (i === intentos - 1) throw error; await new Promise(r => setTimeout(r, 1000 * (i + 1))); // backoff } } }

Timeout para promesas

function conTimeout(promesa, ms) { const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout después de ${ms}ms`)), ms) ); return Promise.race([promesa, timeout]); } // Uso try { const datos = await conTimeout(fetch("/api/lenta"), 5000); } catch (error) { console.error(error.message); // "Timeout después de 5000ms" }

Errores comunes

1. Olvidar el await

// ❌ Sin await — data es una Promesa, no los datos async function obtener() { const data = fetch("/api/datos").then(r => r.json()); console.log(data); // Promise {<pending>} } // ✅ Con await — data son los datos reales async function obtener() { const data = await fetch("/api/datos").then(r => r.json()); console.log(data); // { ... } }

2. await en un forEach (no funciona)

const ids = [1, 2, 3]; // ❌ forEach NO espera a los awaits ids.forEach(async (id) => { const data = await fetch(`/api/${id}`); console.log(data); // Se ejecuta en orden impredecible }); // ✅ Usa for...of para ejecución secuencial for (const id of ids) { const data = await fetch(`/api/${id}`).then(r => r.json()); console.log(data); } // ✅ O Promise.all para ejecución paralela const resultados = await Promise.all( ids.map(id => fetch(`/api/${id}`).then(r => r.json())) );

3. No manejar errores

// ❌ Si la promesa se rechaza, el error se pierde silenciosamente async function cargar() { const datos = await fetch("/api/datos"); } // ✅ Siempre maneja errores async function cargar() { try { const datos = await fetch("/api/datos"); } catch (error) { console.error("Fallo al cargar:", error); } }

Evolución de la asincronía en JavaScript

flowchart LR A["1995\nCallbacks"] --> B["2012\nPromesas\n(librerías)"] B --> C["2015 — ES6\nPromesas nativas"] C --> D["2017 — ES8\nasync / await"] D --> E["2022+\nTop-level await\nArray.fromAsync"] style A fill:#fee2e2,stroke:#ef4444,color:#991b1b style B fill:#fef3c7,stroke:#f59e0b,color:#92400e style C fill:#dbeafe,stroke:#3b82f6,color:#1e40af style D fill:#dcfce7,stroke:#22c55e,color:#166534 style E fill:#f3e8ff,stroke:#a855f7,color:#6b21a8
Año Feature Mejora
1995 Callbacks Primera forma de async
2012 Promesas (librerías) Evitan callback hell
2015 Promesas nativas (ES6) Estándar del lenguaje
2017 async/await (ES8) Sintaxis legible
2022+ Top-level await await fuera de funciones async

Conclusión

La asincronía es uno de los conceptos más importantes de JavaScript. Entender cómo funciona el Event Loop, las diferencias entre callbacks, promesas y async/await, y cuándo usar ejecución secuencial vs paralela te hará un desarrollador mucho más efectivo.

Resumen rápido:

  • Callbacks — Simples pero se anidan mal. Evítalos para operaciones encadenadas
  • Promesas — Encadenables, un solo punto de error. La base de todo
  • Async/await — Legible como código síncrono. Úsalo por defecto
  • Promise.all — Cuando necesitas ejecutar varias operaciones en paralelo
  • try/catch — Siempre maneja errores en funciones async

¡Happy coding!