Nueve patrones de promesas para un JavaScript asíncrono fiable en producción
Aprenda patrones prácticos de Promise: solicitudes en paralelo, tiempos de espera, reintentos, límites de concurrencia y cancelación, para crear JavaScript asíncrono resistente y apto para producción.
Siempre recurren a Promises, pero unos pocos patrones menos conocidos pueden transformar código asíncrono complicado en algo predecible y fácil de comprender.
La mayoría de las personas aprenden sobre Promises a través de un ejemplo como este:
fetch("/api/users")
.then((res) => res.json())
.then((users) => console.log(users));
Y para scripts simples, realmente eso es todo lo que necesitan.
Los problemas comienzan cuando pasan a aplicaciones de nivel profesional.
De repente necesitan que varias solicitudes se ejecuten al mismo tiempo en lugar de una tras otra.
Necesitan una forma de cancelar tareas que ya no son relevantes.
Necesitan intentos repetidos cuando una solicitud falla.
Necesitan seguir adelante incluso cuando solo parte de una operación tiene éxito.
Necesitan evitar que la misma llamada a API se ejecute por error dos veces.
Y a veces necesitan limitar cuántas operaciones asíncronas se ejecutan simultáneamente para que su backend no se vea abrumado de golpe.
Este es el punto en el que las Promesas dejan de ser un tema para principiantes y comienzan a convertirse en una verdadera herramienta de diseño.
A continuación se presentan nueve patrones que vale la pena tener en su caja de herramientas al escribir JavaScript moderno.
1. Ejecutar solicitudes independientes en paralelo
Una de las ventajas más simples del código asíncrono es también una de las más fáciles de pasar por alto.
Supongamos que su página necesita tres cosas: datos del usuario, notificaciones y análisis.
Un intento común al principio sería este:
const users = await getUsers();
const notifications = await getNotifications();
const analytics = await getAnalytics();
Each request waits for the previous one.
Cada await bloquea hasta que se resuelva la llamada anterior, por lo que las llamadas se realizan de forma secuencial.
Supongamos que cada llamada tarda aproximadamente 500 ms; esa cadena secuencial podría generar unos 1,5 segundos de espera en total.
Si ninguna de estas llamadas depende realmente de las otras, no hay razón para esperar.
Exactamente para esto existe Promise.all():
const [users, notifications, analytics] = await Promise.all([
getUsers(),
getNotifications(),
getAnalytics(),
]);
Todas las tres solicitudes se envían ahora al mismo tiempo en lugar de hacerlo por turnos.
En tableros de control o cualquier pantalla que cargue múltiples fuentes de datos independientes, esto puede reducir significativamente el tiempo de carga.
La importante limitación
Promise.all() falla rápidamente: en el momento en que alguna de las promesas se rechaza, todo el grupo se rechaza junto con ella.
Eso está bien cuando todas las solicitudes son obligatorias, pero si algunos de los datos son opcionales, se necesitará un enfoque más flexible.
2. Utilice Promise.allSettled() cuando el fallo parcial está permitido
Imagínese un tablero de control administrativo que muestra:
- Ingresos
- Usuarios
- Notificaciones
- Salud del sistema
Si el servicio de notificaciones está inactivo, ¿debería quedar en blanco toda la pantalla?
Por lo general, no: perder un panel no debería afectar al resto de la página.
Promise.allSettled() resuelve exactamente este problema.
const results = await Promise.allSettled([
getRevenue(),
getUsers(),
getNotifications(),
getSystemHealth(),
]);
results.forEach((result) => {
if (result.status === "fulfilled") {
console.log("Success:", result.value);
} else {
console.error("Failed:", result.reason);
}
});
Una sola llamada fallida ya no anula los resultados exitosos que se encuentran a su lado.
Este patrón resulta útil en paneles de control, pantallas de análisis y herramientas de monitoreo, donde mostrar datos parciales es mejor que no mostrar nada.
3. Agregar un tiempo de espera a una Promise
Tarde o temprano te encontrarás con esta situación: ¿qué pasa si una solicitud simplemente no vuelve?
Sin una medida de protección, tu interfaz puede quedar atascada indefinidamente.
Puedes crear un wrapper pequeño y reutilizable que imponga un tiempo de espera:
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Operation timed out"));
}, ms);
});
return Promise.race([promise, timeout]);
}
Luego úsalo dondequiera que hagas una solicitud:
try {
const data = await withTimeout(fetch("/api/data"), 5000);
console.log(data);
} catch (error) {
console.error(error);
}
Si la llamada no finaliza en cinco segundos, la Promise envuelta se rechaza por sí sola.
Eso ofrece una experiencia mucho mejor que dejar a los usuarios mirando un indicador de carga que nunca se resuelve.
4. Volver a intentar las operaciones fallidas
Las redes interrumpen las conexiones.
Los servidores presentan problemas ocasionales.
Las APIs de terceros a veces no funcionan correctamente.
Nada de esto implica necesariamente que el usuario deba ver inmediatamente un mensaje de error.
Para los fallos que suelen ser temporales, un pequeño mecanismo de intentos repetidos ayuda mucho.
async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
}
}
throw lastError;
}
Se llamaría de esta manera:
const data = await retry(
() => fetch("/api/data"),
3
);
La operación ahora recibe algunas oportunidades adicionales antes de ser considerada un fallo real.
Pero no intente todo ciegamente.
Un estado 500 suele indicar un problema temporal en el servidor.
Por otro lado, un 401 Unauthorized no se solucionará con solo enviar la misma solicitud tres veces más.
Una lógica sólida de intentos repetidos permite distinguir entre los fallos que merecen ser reintentados y aquellos que no.
5. Añadir demoras entre los intentos
Ejecutar un intento de reintentar inmediatamente cuando una solicitud falla no siempre es la mejor opción.
Considere un servidor que ya está lidiando con una alta carga.
Si miles de clientes intentan reintentar al mismo tiempo, se añade más presión a un sistema que ya está sobrecargado.
Una función básica de retardo soluciona esto:
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
Puede integrarla directamente en su bucle de reintentos:
async function retry(fn, attempts = 3) {
let lastError;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < attempts - 1) {
await delay(1000);
}}}
throw lastError;
}
Los sistemas en producción suelen ir un paso más allá y utilizan retrocesos exponenciales, distribuyendo los reintentos de esta manera:
1 second
2 seconds
4 seconds
8 seconds
Distribuir los reintentos de esta forma permite que el servidor sobrecargado se recupere en lugar de provocar una tormenta de reintentos.
6. Control de concurrencia
Existe un problema que Promise.all() puede introducir de forma silenciosa.
Supongamos que necesita procesar 1,000 solicitudes API.
El enfoque ingenuo parece lo suficientemente inofensivo:
await Promise.all(
items.map((item) => processItem(item))
);
But now you’re potentially starting 1,000 operations at once.
Pero eso significa que es posible que se ejecuten las 1,000 operaciones al mismo momento exacto.
Rara vez es eso lo que realmente se desea.
A menudo es mejor establecer un límite para la cantidad de operaciones que se ejecutan en paralelo.
Imagínese algo así:
1000 tasks
↓
5 at a time
↓
5 at a time
↓
5 at a time
Crear un limitador básico de concurrencia no es difícil:
async function runWithLimit(items, limit, fn) {
const results = [];
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
results[currentIndex] = await fn(items[currentIndex]);
}
}
const workers = Array.from(
{ length: limit },
() => worker()
);
await Promise.all(workers);
return results;
}
Se utilizaría de la siguiente manera:
const results = await runWithLimit(
items,
5,
(item) => processItem(item)
);
Ahora usted decide cuántas tareas se ejecutan al mismo tiempo en lugar de dejar que el sistema operativo lo decida por usted.
Esta técnica resulta extremadamente útil cuando se trabaja con grandes conjuntos de datos, APIs externas, procesamiento de archivos o colas de tareas en segundo plano.
7. Evitar solicitudes duplicadas
Aquí hay un problema que aparece con más frecuencia de la esperada.
Un usuario carga una página de panel de control.
Tres componentes separados necesitan los mismos datos del perfil de usuario.
En lugar de realizar tres llamadas separadas:
Component A → /api/user
Component B → /api/user
Component C → /api/user
puedes hacer que compartan una sola Promise.
const pendingRequests = new Map();
function getUser(id) {
if (pendingRequests.has(id)) {
return pendingRequests.get(id);
}
const request = fetch(`/api/users/${id}`)
.then((res) => res.json())
.finally(() => {
pendingRequests.delete(id);
});
pendingRequests.set(id, request);
return request;
}
Con esta configuración, si tres componentes solicitan al mismo usuario más o menos al mismo tiempo, todos se conectan a una sola solicitud en curso en lugar de activar tres.
En otras palabras:
3 solicitudes → 1 solicitud
Esta técnica se conoce a menudo como deduplicación de solicitudes.
En bases de código más grandes, herramientas como TanStack Query ya implementan lógica de caché y deduplicación de este tipo por ti.
8. Usa Promise.any() cuando solo necesitas un resultado exitoso
A veces, el mismo dato está disponible en varios lugares diferentes.
Por ejemplo:
API Server A
API Server B
API Server C
Si tu aplicación solo necesita una respuesta exitosa de cualquiera de ellos, Promise.any() es la solución adecuada.
const result = await Promise.any([
fetchFromServerA(),
fetchFromServerB(),
fetchFromServerC(),
]);
Cualquier Promise que se cumpla primero gana la competencia.
Tenga en cuenta que esto se comporta de manera diferente a Promise.race().
Promise.race() resuelve o rechaza según el Promise que se establezca primero, ya sea con éxito o fracaso.
Promise.any(), en cambio, espera específicamente al primer Promise que se cumpla con éxito, ignorando los rechazos a menos que todos fallen.
Esa distinción puede parecer menor, pero puede cambiar por completo la forma en que maneja los errores.
9. Cancelar tareas que ya no necesita
Este patrón es uno de los más satisfactorios de aplicar.
Piense en un cuadro de búsqueda en tiempo real:
user types: react
user types: react dashboard
user types: react dashboard ui
Probablemente no quiera que las solicitudes generadas por cada tecla presionada anteriormente sigan ejecutándose en segundo plano una vez que estén desactualizadas.
Esta es exactamente la situación para la cual se creó AbortController.
const controller = new AbortController();
fetch("/api/search?q=react", {
signal: controller.signal,
});
Una vez que la solicitud ya no es necesaria, simplemente se llama a:
controller.abort();
The request can then be cancelled.
Este patrón aparece constantemente en escenarios como:
- Sugerencias de búsqueda
- Campos de autocompletado
- Transiciones de rutas o páginas
- Desmontaje/limpieza de componentes
- Solicitudes reemplazadas por otras más recientes
La verdadera habilidad aquí no consiste solo en llamar a abort().
Se trata de reconocer el momento en que el trabajo anterior deja de ser útil.
La lección más importante
Las promesas no parecen difíciles porque .then() es una API compleja.
Parecen difíciles porque las aplicaciones del mundo real involucran un comportamiento asíncrono complicado y en capas.
Constantemente es necesario resolver problemas como:
¿Deben ejecutarse estas operaciones al mismo tiempo?
¿Cuál es el plan si una de ellas falla?
¿Cuánto tiempo es demasiado para esperar?
¿Vale la pena intentarlo de nuevo en este caso?
¿Cuántas operaciones deben ejecutarse de forma concurrente?
¿Puedo evitar duplicar el trabajo que ya está en curso?
¿Sigue siendo importante esta solicitud?
Cuando se plantean este tipo de preguntas, las Promises dejan de ser solo una característica del lenguaje.
Se convierten en una verdadera Herramienta arquitectónica.
Mis 9 patrones de Promises de un vistazo
Promise.all() es la mejor opción para ejecutar tareas independientes al mismo tiempo. Promise.allSettled() maneja las fallas parciales de manera adecuada. Promise.race() es adecuado para tiempos de espera o para reaccionar ante la primera tarea que se resuelva. La lógica de intentos múltiples te ayuda a recuperarte de fallas temporales. Las estrategias de retraso y retroceso evitan que los intentos se vuelvan demasiado agresivos. La limitación de concurrencia mantiene bajo control las cargas de trabajo grandes. La deduplicación de solicitudes evita llamadas API duplicadas. Promise.any() te permite obtener el primer resultado exitoso entre varios. AbortController te permite cancelar tareas que ya no son necesarias.
No necesitas todos estos patrones en cada proyecto.
De hecho, forzar su uso en todos ellos sería contraproducente.
La verdadera habilidad radica en identificar qué problema estás resolviendo antes de recurrir a un patrón específico.
Pensamiento final
El código asíncrono más sólido no es aquel repleto de los trucos más ingeniosos con Promise.
Sino aquel en el que la gestión de errores, los tiempos de ejecución, la concurrencia y la cancelación se planificaron cuidadosamente antes de que se convirtieran en problemas en producción.
Comience con la versión más simple.
Preste atención a lo que realmente causa problemas.
Solo entonces introduzca el patrón específico que resuelva ese problema.
Porque a veces el paso más avanzado en JavaScript es reconocer cuándo no se necesita ningún patrón.
Lecturas relacionadas
- Concepciones comunes sobre async/await que causan problemas en producción — Explica nueve malentendidos sutiles sobre async/await, desde condiciones de carrera hasta rechazos no manejados, que dañan silenciosamente las aplicaciones JavaScript en el mundo real.
- Construyendo un manejo de errores de nivel producción en aplicaciones Node.js — Aprenda cómo clasificar los errores de Node.js, diseñar una jerarquía personalizada de errores, centralizar el manejo de errores asíncronos y proteger las trazas de pila para garantizar la resiliencia en producción.