¿Todavía cometes estos errores con Async/Await? Guía definitiva 2026
Llevas años usando async/await. Las Promises ya las dominas con los ojos cerrados. Y aun así, ese bug de producción a las 3 AM terminó siendo culpa de una función async que escribiste tú. ¿Te resulta familiar?
El problema con async/await es que parece demasiado simple. Se ve como código síncrono, y por eso repetimos los mismos errores una y otra vez. En esta guía vamos a desmenuzar las trampas que siguen causando problemas en 2026.
Esto no es una introducción. Es para quienes ya conocen async/await pero quieren usarlo correctamente.
Por qué await dentro de un loop te arruina el rendimiento
El asesino de performance #1 en código async es este:
async function fetchUserData(userIds) { const users = []; for (const id of userIds) { const user = await fetchUser(id); users.push(user); } return users; }
Parece inofensivo, ¿verdad? Pero si tienes 10 usuarios y cada fetchUser tarda 100ms, esta función tarda 1 segundo. En paralelo serían 100ms.
¿Cuál es el problema?
await detiene la ejecución hasta que la Promise se resuelve. Dentro de un loop, cada petición espera a que termine la anterior. Estás serializando algo que podría ir en paralelo.
La solución: Promise.all
async function fetchUserData(userIds) { const userPromises = userIds.map(id => fetchUser(id)); const users = await Promise.all(userPromises); return users; }
Ahora todas las peticiones salen a la vez y solo esperas por la más lenta.
Pero a veces necesitas que sea secuencial
async function processPayments(payments) { const results = []; for (const payment of payments) { // El saldo cambia después de cada pago const result = await processPayment(payment); results.push(result); } return results; }
La clave es la intención. No serialices por accidente lo que puede ir en paralelo.
Nivel avanzado: controlar la concurrencia
Promise.all no siempre es la respuesta. Si disparas 1000 peticiones API de golpe, vas a tumbar el servidor. Hay que limitar las conexiones simultáneas:
async function fetchWithConcurrency(urls, concurrency = 5) { const results = []; const executing = new Set(); for (const url of urls) { const promise = fetch(url).then(response => { executing.delete(promise); return response.json(); }); executing.add(promise); results.push(promise); if (executing.size >= concurrency) { await Promise.race(executing); } } return Promise.all(results); }
También puedes usar un patrón de semáforo:
class Semaphore { #queue = []; #running = 0; constructor(concurrency) { this.concurrency = concurrency; } async acquire() { if (this.#running >= this.concurrency) { const { promise, resolve } = Promise.withResolvers(); this.#queue.push(resolve); await promise; } this.#running++; } release() { this.#running--; if (this.#queue.length > 0) { const next = this.#queue.shift(); next(); } } async run(fn) { await this.acquire(); try { return await fn(); } finally { this.release(); } } } // Uso const semaphore = new Semaphore(5); const results = await Promise.all( urls.map(url => semaphore.run(() => fetch(url))) );
Los Unhandled Rejections matan servidores
Desde Node.js 22, los Promise rejections sin manejar terminan el proceso. Este cambio solo ha tumbado más servidores de producción que cualquier otro.
La muerte silenciosa
async function riskyOperation() { const result = await fetchData(); // Puede fallar return result; } // Peligro: sin manejo de errores riskyOperation();
Si fetchData() falla, ¿a dónde va el error? A ningún lado. Sin try/catch, sin .catch(). Antes de Node.js 15, solo salía un warning. Ahora el servidor se muere directamente.
Lo peor es que cuesta detectarlo
app.get('/users', async (req, res) => { const users = await getUsers(); // Si esto falla... res.json(users); });
En Express 4.x esto no envía automáticamente una respuesta de error. La petición se queda colgada hasta el timeout. Express 5 lo arregló, pero muchos proyectos siguen en 4.x.
Cómo protegerte bien
1. Handler global como última línea de defensa:
process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection:', reason); Sentry.captureException(reason); });
2. En Express, usa un wrapper:
const asyncHandler = (fn) => (req, res, next) => { Promise.resolve(fn(req, res, next)).catch(next); }; app.get('/users', asyncHandler(async (req, res) => { const users = await getUsers(); res.json(users); }));
3. try/catch obligatorio en los puntos de entrada:
En rutas API, event handlers, cron jobs... siempre captura los errores:
async function cronJob() { try { await performScheduledTask(); } catch (error) { await notifyOnCall(error); // No relanzar. Este es el límite. } }
Fire-and-forget también necesita .catch()
// ❌ Mal async function saveAndNotify(data) { await saveToDatabase(data); sendNotification(data); // Sin await, ok, pero... } // ✅ Bien async function saveAndNotify(data) { await saveToDatabase(data); sendNotification(data).catch(err => { console.error('Fallo al enviar notificación:', err); }); }
Memory leaks: por qué el servidor se cae después de días
El código async puede filtrar memoria de formas sutiles. Solo lo notas después de días de ejecución.
Cuando un closure retiene datos enormes
async function processLargeFile(filePath) { const hugeData = await readEntireFile(filePath); // 500MB return async function getSlice(start, end) { return hugeData.slice(start, end); }; }
La función retornada mantiene una referencia a hugeData. Aunque solo necesites pequeños fragmentos, los 500MB siguen en memoria.
Solución: WeakRef
async function processLargeFile(filePath) { let hugeData = await readEntireFile(filePath); const dataRef = new WeakRef(hugeData); hugeData = null; // Liberar la referencia return async function getSlice(start, end) { const data = dataRef.deref(); if (!data) { throw new Error('Los datos fueron recolectados por el GC'); } return data.slice(start, end); }; }
Event listeners que no se limpian
Este es muy común y pasa desapercibido:
class DataProcessor { constructor() { this.eventEmitter = new EventEmitter(); } async processWithUpdates(data) { return new Promise((resolve, reject) => { const onProgress = (progress) => { console.log(`Progress: ${progress}%`); }; const onComplete = (result) => { // ⚠️ No eliminamos onProgress! resolve(result); }; this.eventEmitter.on('progress', onProgress); this.eventEmitter.on('complete', onComplete); this.eventEmitter.on('error', reject); this.startProcessing(data); }); } }
Cada llamada añade listeners que nunca se eliminan. Después de 1000 llamadas, tienes 3000 listeners zombie.
Siempre limpia los recursos
async processWithUpdates(data) { return new Promise((resolve, reject) => { const cleanup = () => { this.eventEmitter.off('progress', onProgress); this.eventEmitter.off('complete', onComplete); this.eventEmitter.off('error', onError); }; const onProgress = (progress) => { console.log(`Progress: ${progress}%`); }; const onComplete = (result) => { cleanup(); resolve(result); }; const onError = (error) => { cleanup(); reject(error); }; this.eventEmitter.on('progress', onProgress); this.eventEmitter.on('complete', onComplete); this.eventEmitter.on('error', onError); this.startProcessing(data); }); }
El patrón AbortController
En JavaScript moderno, esto es más limpio:
async function fetchWithTimeout(url, timeoutMs = 5000) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); return await response.json(); } finally { clearTimeout(timeoutId); } }
Race conditions: funciona en desarrollo, falla en producción
Las race conditions ocurren cuando el comportamiento depende del timing de operaciones async. Son un infierno porque solo aparecen en producción.
El clásico en React
function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { async function loadUser() { const userData = await fetchUser(userId); setUser(userData); // ¿Y si userId ya cambió? } loadUser(); }, [userId]); return <div>{user?.name}</div>; }
Si el usuario hace clic rápido en dos enlaces, ambas peticiones salen a la vez. La primera puede llegar segunda, dejando datos obsoletos en pantalla.
Solución: cancelar la petición anterior
function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { const controller = new AbortController(); async function loadUser() { try { const userData = await fetchUser(userId, { signal: controller.signal }); setUser(userData); } catch (error) { if (error.name !== 'AbortError') { console.error('Error cargando usuario:', error); } } } loadUser(); return () => controller.abort(); }, [userId]); return <div>{user?.name}</div>; }
Doble clic en el backend
app.post('/orders', async (req, res) => { const { userId, productId } = req.body; // Verificar si ya existe el pedido const existing = await Order.findOne({ userId, productId, status: 'pending' }); if (existing) { return res.status(400).json({ error: 'Ya existe el pedido' }); } // Crear pedido const order = await Order.create({ userId, productId, status: 'pending' }); res.json(order); });
Si el usuario hace doble clic en el botón, llegan dos peticiones casi simultáneas. Ambas verifican, ambas no encuentran nada, ambas crean el pedido.
Solución: unique constraint en la base de datos
// En el schema Order.createIndex({ userId: 1, productId: 1, status: 1 }, { unique: true }); // En la ruta app.post('/orders', async (req, res) => { try { const order = await Order.create({ userId: req.body.userId, productId: req.body.productId, status: 'pending' }); res.json(order); } catch (error) { if (error.code === 11000) { // Clave duplicada return res.status(400).json({ error: 'Ya existe el pedido' }); } throw error; } });
Con varios servidores: lock distribuido
import Redis from 'ioredis'; const redis = new Redis(); async function withDistributedLock(key, ttlMs, fn) { const lockKey = `lock:${key}`; const lockValue = crypto.randomUUID(); const acquired = await redis.set(lockKey, lockValue, 'PX', ttlMs, 'NX'); if (!acquired) { throw new Error('No se pudo obtener el lock'); } try { return await fn(); } finally { const script = ` if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end `; await redis.eval(script, 1, lockKey, lockValue); } }
No puedes usar await en el constructor
A veces se ve código como este:
class DatabaseConnection { constructor() { // ❌ Error de sintaxis! await this.connect(); } async connect() { this.connection = await mongodb.connect(); } }
Esto es un error de sintaxis. Algunos lo esquivan así:
class DatabaseConnection { constructor() { this.ready = this.connect(); } async connect() { this.connection = await mongodb.connect(); } async query(sql) { await this.ready; return this.connection.query(sql); } }
Funciona, pero es incómodo. Tienes que poner await this.ready en todos los métodos.
El patrón factory es la solución
class DatabaseConnection { static async create() { const instance = new DatabaseConnection(); await instance.connect(); return instance; } async connect() { this.connection = await mongodb.connect(); } async query(sql) { return this.connection.query(sql); } } // Uso const db = await DatabaseConnection.create();
Promise.all vs Promise.allSettled
Elegir mal puede hacer que se traguen errores o que un fallo tumbe todo.
Promise.all falla si una sola falla
const results = await Promise.all([ fetchUser(1), fetchUser(2), fetchUser(3) ]);
Si el fetch del usuario 2 falla, los resultados de 1 y 3 (aunque fueron exitosos) se pierden.
Promise.allSettled espera a todas
const results = await Promise.allSettled([ fetchUser(1), fetchUser(2), fetchUser(3) ]); const users = results .filter(r => r.status === 'fulfilled') .map(r => r.value); const errors = results .filter(r => r.status === 'rejected') .map(r => r.reason);
¿Cuándo usar cada una?
- Promise.all: cuando todo debe funcionar o nada (transacciones)
- Promise.allSettled: cuando puedes procesar lo que funcione (notificaciones masivas)
Híbrido: manejo de errores individual
const results = await Promise.all([ fetchUser(1).catch(err => ({ error: err, id: 1 })), fetchUser(2).catch(err => ({ error: err, id: 2 })), fetchUser(3).catch(err => ({ error: err, id: 3 })) ]); results.forEach(result => { if (result.error) { console.log(`Error cargando usuario ${result.id}`); } });
Async Generators: una joya poco conocida
Para procesar datos enormes, los async generators son geniales:
async function* readLargeFile(path) { const stream = fs.createReadStream(path, { encoding: 'utf8' }); for await (const chunk of stream) { yield chunk; } } // Procesar sin explotar la memoria for await (const chunk of readLargeFile('huge.txt')) { await processChunk(chunk); }
Tips de debugging
1. Usa funciones con nombre
// ❌ En el stack trace no sabes qué es const result = await somePromise.then(x => x.map(y => y.value)); // ✅ Sabes inmediatamente dónde falló const result = await somePromise.then(function extractValues(items) { return items.map(function getValue(item) { return item.value; }); });
2. Añade IDs de seguimiento
async function debuggableOperation(input) { const startTime = performance.now(); const operationId = crypto.randomUUID().slice(0, 8); console.log(`[${operationId}] Iniciando:`, input); try { const result = await doSomething(input); console.log(`[${operationId}] Completado (${performance.now() - startTime}ms)`); return result; } catch (error) { console.error(`[${operationId}] Falló (${performance.now() - startTime}ms):`, error); throw error; } }
3. Inspeccionar el estado de una Promise
No puedes ver directamente el estado de una Promise, pero puedes usar race:
async function getPromiseState(promise) { const sentinel = Symbol('pending'); const result = await Promise.race([ promise, Promise.resolve(sentinel) ]); if (result === sentinel) { return 'pending'; } return 'resolved'; }
4. Tracing con async_hooks
El módulo async_hooks de Node.js te permite rastrear operaciones async:
import async_hooks from 'async_hooks'; import fs from 'fs'; const contexts = new Map(); const hook = async_hooks.createHook({ init(asyncId, type, triggerAsyncId) { fs.writeSync(1, `${type} creado: ${asyncId} (padre: ${triggerAsyncId})\n`); contexts.set(asyncId, { type, parent: triggerAsyncId }); }, destroy(asyncId) { contexts.delete(asyncId); } }); hook.enable();
Errores comunes en tests
Si olvidas el await, el test pasa cuando no debería
// ❌ Siempre pasa it('debería obtener el usuario', async () => { fetchUser(1).then(user => { expect(user.name).toBe('Alice'); }); }); // ✅ Correcto it('debería obtener el usuario', async () => { const user = await fetchUser(1); expect(user.name).toBe('Alice'); });
Testear rejections
it('debería lanzar error con input inválido', async () => { await expect(fetchUser(-1)).rejects.toThrow('Invalid ID'); });
Fake timers y async juntos
// Los fake timers pueden romper tests async jest.useFakeTimers(); it('debería hacer timeout después de 5 segundos', async () => { const promise = fetchWithTimeout('/slow'); // Avanzar los timers jest.advanceTimersByTime(5000); // Hay que procesar también la cola de microtasks await jest.runAllTimersAsync(); // Jest 29+ await expect(promise).rejects.toThrow('Timeout'); });
Problemas de rendimiento que pasan desapercibidos
Saturación de la cola de microtasks
Cada await crea una microtask. En loops ajustados, esto puede bloquear I/O:
// Satura la cola de microtasks async function processItems(items) { for (const item of items) { await processItem(item); // Crea microtask cada vez } }
Si mezclas trabajo de CPU con I/O, hay que ceder el control:
async function processItems(items) { for (let i = 0; i < items.length; i++) { await processItem(items[i]); // Ceder a I/O cada 100 items if (i % 100 === 0) { await new Promise(resolve => setImmediate(resolve)); } } }
Limitaciones de optimización de V8
V8 no puede optimizar funciones async tan bien como las síncronas. Para hot paths:
// Hot path: síncrono cuando sea posible function getValue(cache, key) { const cached = cache.get(key); if (cached !== undefined) { return cached; // Retorno síncrono } return fetchValue(key).then(value => { cache.set(key, value); return value; }); }
Retornando síncronamente en cache hits, evitas el overhead de Promise.
Conclusión: mentalidad async
Dominar async/await no es memorizar patrones, sino entender el flujo.
Los puntos clave:
- Por defecto, paraleliza: usa
Promise.allsi no hay razón para no hacerlo - Siempre captura errores: en cada punto de entrada, decide quién los maneja
- Limpia tus recursos: listeners, timers y conexiones no desaparecen solos
- Las race conditions están en todas partes: diseña pensando en acceso concurrente
- Testea los casos de fallo: rejections, timeouts, fallos parciales
Los bugs de esta guía vienen de incidentes reales. Todos parecían correctos al principio. Eso es lo peligroso de async: la sintaxis esconde la complejidad.
Pero una vez que lo entiendes, lo controlas. Escribe código que funcione a las 3 AM mientras tú duermes tranquilo.
Escribe código con intención. Testea los edge cases. Y siempre maneja los rejections.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit