Combinadores Promise: qué falla y qué nunca se asienta
Dos sondas sobre diez casos: all se detiene en el primer rechazo, allSettled informa cada resultado en orden de entrada, any agrega errores y una race vacía nunca se asienta.
BotBrowser Team
Quieres la documentación estructurada de Huella digital?
Este artículo forma parte de la biblioteca editorial. Para pasos de configuración, material de referencia y actualizaciones continuas, entra en la sección de docs.
BotBrowser soporta repetir un fixture autorizado del mismo origen bajo un perfil declarado y conservar un registro acotado de qué promise se asentó primero y cuál no se asentó nunca, pero no puede cambiar cómo un entorno ordena los asentamientos, no puede convertir un rechazo en una resolución y no puede hacer que una promise sin entradas se asiente. Esta revisión ejecuta dos sondas sobre diez casos medidos y registra qué hace cada uno de los cuatro combinadores cuando una entrada se rechaza, cuando las entradas terminan en un orden distinto del que se les dio, cuando todas las entradas se rechazan, cuando la lista de entradas está vacía y cuando un rechazo no se atiende nunca.
Resumen
Promise.allse rechaza con el primer rechazo y deja de esperar; la sonda imprimefirst; delivered_so_far=0.- Las demás entradas siguen en marcha después de ese rechazo, así que la línea siguiente muestra
delivered=slow,later. Promise.allSettledespera a cada entrada y conserva el orden de entrada, así que un cumplimiento lento se informa primero y uno rápido al final.Promise.alltambién conserva el orden de entrada, así quea,b,cllega en ese orden aunquebhaya terminado primero.Promise.anyse resuelve con el primer cumplimiento; cuando todas las entradas se rechazan, lanza unAggregateErrorcuyo arregloerrorsconserva el orden de entrada.Promise.racetoma el primer asentamiento, así que un rechazo rápido vence a un cumplimiento más lento.- Una entrada vacía es donde los cuatro se separan:
allyallSettledse cumplen,anyse rechaza conAggregateErroryraceno se asienta nunca. - Un rechazo que nadie atiende se informa al hook de rechazos no atendidos del entorno, que la sonda observa como
orphan.
Contenido
- Qué promete cada combinador sobre la finalización
- Por qué Promise.all se detiene en el primer rechazo
- Cómo allSettled informa cada resultado en orden de entrada
- En qué se diferencia any de race
- Qué hace una entrada vacía con cada combinador
- Cómo aparece un rechazo que nadie atiende
- Qué deja en marcha un combinador ya asentado
- Registra una lectura de un combinador
Qué promete cada combinador sobre la finalización
Los cuatro combinadores toman la misma entrada, un iterable de valores y promises, y difieren solo en qué asentamiento entregan a quien llama. Esa única diferencia decide cada modo de falla de abajo, así que vale la pena enunciar los cuatro contratos en un solo lugar antes de leer cualquier salida.
Promise.all se cumple cuando cada entrada se cumple, con un arreglo en orden de entrada, y se rechaza en cuanto una entrada se rechaza. Promise.allSettled se cumple cuando cada entrada se asienta, con un registro por entrada en orden de entrada, y no se rechaza nunca porque una entrada se rechazó. Promise.any se cumple con el primer cumplimiento y se rechaza solo después de que todas las entradas se rechazaron, con un AggregateError. Promise.race se asienta con la entrada que se asiente primero, sea cumplimiento o rechazo, y no tiene opinión sobre las demás.
De ahí se siguen dos consecuencias directas. Primero, solo all y race pueden rechazarse por una sola entrada; allSettled no puede rechazarse por esa razón en absoluto, y any se rechaza solo cuando la última entrada se rechaza. Segundo, tres de los cuatro conservan el orden de entrada en su resultado, mientras que race devuelve un único valor sin arreglo que ordenar, y por eso quien llama y necesita saber qué entrada ganó tiene que unir la etiqueta a la entrada misma y no al resultado.
La sonda impulsa cada contrato con las mismas tres entradas, un cumplimiento lento, un rechazo y un cumplimiento rápido, y etiqueta el resultado. Nada en la sonda depende de un retardo en particular más allá del orden relativo de los tres temporizadores, así que la forma se puede reproducir en cualquier entorno que implemente los cuatro métodos.
node=v26.9.0
all-rejects-early -> first; delivered_so_far=0
all-later-resolution-still-runs -> delivered=slow,later
allSettled-order -> slow,boom,fast
all-input-order -> a,b,c
any-first-fulfilled -> fast
any-all-reject -> AggregateError; errors=e1,e2
race-first-settlement-wins -> fast-reject
empty-input -> all=fulfilled; allSettled=fulfilled:0; any=rejected:AggregateError; race=still-pending
unhandled-rejection-observed -> orphan
non-promise-input -> [1,"two",{"three":3}]
La última línea es la silenciosa. Promise.all acepta valores simples, así que una lista que mezcla números, cadenas y objetos se resuelve con esos valores sin cambios, y ninguna entrada tiene que ser una promise para que el combinador funcione. Quien envuelve cada valor en Promise.resolve() antes de pasarlo hace un trabajo que el combinador ya hace.
Por qué Promise.all se detiene en el primer rechazo
Promise.all se asienta en el momento en que su primera entrada se rechaza, y el valor que entrega es el motivo de ese rechazo, no un arreglo. La sonda lo captura e informa tanto el mensaje como cuántas otras entradas habían entregado para entonces:
all-rejects-early -> first; delivered_so_far=0
El conteo es cero porque el rechazo llegó antes del cumplimiento de diez milisegundos. La mitad importante de esa línea es lo que no dice: el combinador se rindió y las entradas no. Cada entrada que ya estaba en marcha sigue hasta su propio final, lo que la sonda comprueba al esperar y luego imprimir lo que llegó después:
all-later-resolution-still-runs -> delivered=slow,later
Ambas entradas restantes terminaron, una de ellas mucho después del rechazo que puso fin a la espera. Este es el modo de falla que más sorprende a quien llama. Un Promise.all rechazado no cancela sus entradas, porque una promise no tiene operación de cancelación en el lenguaje; el combinador simplemente deja de observarlas. El trabajo que quien llama quería detener tiene que detenerlo el código que lo posee, normalmente mediante un AbortController pasado a cada operación, y el combinador no participa en eso.
El mismo comportamiento fija la expectativa para la limpieza. Un bloque finally en la llamada a Promise.all se ejecuta cuando el combinador se asienta, que en el caso de rechazo ocurre temprano, así que la limpieza escrita ahí se ejecuta mientras las otras entradas siguen en vuelo. Quien cierra un recurso compartido en ese bloque puede cerrarlo debajo de trabajo que todavía lo usa. El orden seguro es detener primero las entradas, esperar a que confirmen la detención y solo entonces liberar el recurso.
Promise.all sí conserva el orden de entrada cuando se cumple, y lo hace aunque las entradas terminen en un orden distinto. La sonda entrega tres cumplimientos que terminan a los veinticinco, cinco y quince milisegundos y lee de vuelta el arreglo:
all-input-order -> a,b,c
La entrada más rápida fue b, y el arreglo sigue empezando por a. Por lo tanto, quien empareja resultados con solicitudes por índice está a salvo, y quien quiere el orden de finalización tiene que registrarlo dentro de cada entrada en lugar de inferirlo del resultado.
Cómo allSettled informa cada resultado en orden de entrada
Quien llama a Promise.allSettled no recibe ninguna excepción de una entrada que falla. Cada registro trae un status de fulfilled o rejected y además un value o un reason, así que un fallo llega como entrada y no como error lanzado. La sonda pasa las mismas tres formas e imprime los mensajes en el orden en que vuelven:
allSettled-order -> slow,boom,fast
El cumplimiento lento va primero porque era la primera entrada, el rechazo va segundo porque era la segunda entrada, y el cumplimiento rápido va último aunque terminó antes que los otros dos. Esta es la propiedad que hace de allSettled la elección correcta para un lote cuyas fallas no deberían ocultar sus éxitos: quien llama recibe un informe completo y cada elemento sigue unido a la entrada de la que provino.
El costo es el tiempo. allSettled no puede asentarse antes de que se asiente la entrada más lenta, así que un lote con una entrada que no se asienta nunca no produce informe alguno. Las entradas de la sonda terminan todas, lo que oculta ese riesgo; quien necesita un límite tiene que competir la llamada a allSettled contra un temporizador y tratar un tiempo de espera agotado como un informe parcial, lo que significa que también tiene que decidir qué hacer con la entrada que sigue en marcha. Los elementos en sí son objetos simples, así que structuredClone frente a JSON explica cuáles sobreviven a una copia hacia un registro del historial y cuál se descarta en silencio.
Hay una segunda diferencia que vale nombrar. allSettled informa un rechazo como dato, así que una entrada rechazada no se convierte en excepción salvo que quien llama la lance. Quien registra el informe y sigue adelante ha atendido cada falla por construcción, y quien quiere recuperar el comportamiento anterior de all tiene que recorrer el arreglo y lanzar el primer rechazo que encuentre. Los dos combinadores son el mismo cálculo con dos políticas sobre qué hacer con una falla, y la política vive en quien llama.
En qué se diferencia any de race
Promise.any y Promise.race toman varias entradas y devuelven un resultado, y difieren en qué asentamiento aceptan. any ignora los rechazos hasta que todas las entradas se rechazaron, así que se resuelve con el primer cumplimiento incluso si un rechazo llega antes. race acepta el primer asentamiento de cualquiera de los dos tipos, así que un rechazo que llega primero gana. La sonda muestra ambos:
any-first-fulfilled -> fast
race-first-settlement-wins -> fast-reject
El caso de any tenía un cumplimiento lento y uno rápido, y se resolvió con el rápido, que es lo que sugiere el nombre. El caso de race tenía un cumplimiento lento y un rechazo rápido, y se rechazó. Por lo tanto, quien recurre a race para implementar un tiempo de espera agotado recibe un rechazo cuando el temporizador se dispara, que es lo que suele querer, y quien recurre a any para implementar "el primer espejo que responda" recibe un cumplimiento, que también suele ser lo que quiere. Intercambiarlos cambia la semántica de falla de toda la llamada.
Cuando todas las entradas de any se rechazan, el combinador se rechaza con un AggregateError cuyo arreglo errors guarda los motivos en orden de entrada:
any-all-reject -> AggregateError; errors=e1,e2
Ese arreglo es la razón por la que any vale más que un bucle manual. Un bucle manual que guarda el último rechazo pierde los motivos anteriores, mientras que AggregateError los conserva todos y da a quien llama un orden estable para informar. Quien registra solo error.message ve un mensaje genérico y pierde el detalle; el campo útil es error.errors.
race no tiene tal agregado, porque se asienta con una entrada y no se entera nunca de las demás. Un race rechazado no dice nada a quien llama sobre las otras entradas, y quien necesita saber si también fallaron tiene que inspeccionarlas por separado. Esa asimetría no es un defecto de ninguno de los dos métodos; es la diferencia entre un método que espera una categoría de asentamiento y uno que toma el primero de cualquier tipo.
Qué hace una entrada vacía con cada combinador
Un iterable vacío es la entrada más pequeña que aceptan los cuatro métodos, y es el caso donde sus contratos dejan de parecer similares:
empty-input -> all=fulfilled; allSettled=fulfilled:0; any=rejected:AggregateError; race=still-pending
Promise.all([]) se cumple de inmediato con un arreglo vacío, porque la condición "cada entrada se ha cumplido" ya es verdadera cuando no hay entradas. Promise.allSettled([]) hace lo mismo y devuelve un arreglo vacío, así que quien suma un informe sobre un lote vacío obtiene cero en lugar de un error. Promise.any([]) se rechaza con un AggregateError cuyo arreglo errors está vacío, porque la condición "al menos una entrada se cumplió" no puede volverse verdadera nunca. Promise.race([]) devuelve una promise que no se asienta nunca, porque ninguna entrada puede asentarla.
El caso de race es el que puede colgar a quien llama para siempre. La sonda no puede esperar esa promise directamente, así que la compite contra un temporizador e imprime still-pending cuando gana el temporizador, que es la única forma de observar una promise sin asentamiento. Quien construye su lista de entradas a partir de un filtro, y ese filtro puede no devolver nada, tiene una ruta de código que no se resuelve nunca. La corrección es comprobar la longitud antes de llamar a race, o incluir siempre al menos una entrada que se asiente con seguridad, como un temporizador.
Los cuatro resultados se siguen de los contratos de la primera sección una vez que el caso vacío se lee como un límite. all necesita que cada entrada se cumpla y no tiene nada que esperar, allSettled necesita que cada entrada se asiente y no tiene nada que esperar, any necesita un cumplimiento y no puede obtenerlo nunca, y race necesita un asentamiento y no puede obtenerlo nunca. Nada del caso vacío está tratado como caso especial en la especificación; se deduce de las definiciones, y por eso quien razona desde las definiciones predice los cuatro correctamente.
Cómo aparece un rechazo que nadie atiende
Un rechazo al que no se le une ningún manejador no desaparece. El entorno lo informa a lo que esté escuchando rechazos no atendidos, y la sonda escucha exactamente un turno para observarlo:
unhandled-rejection-observed -> orphan
La sonda une un listener, crea una promise rechazada sin catch, espera treinta milisegundos y quita el listener. El mensaje llega, lo que significa que el rechazo seguía sin atender cuando terminó el turno. En un navegador el mismo evento es unhandledrejection en la ventana, y en Node es el evento de proceso que usa la sonda, así que la forma de la observación es la misma aunque el nombre de la API difiera.
Un combinador no crea esa situación, y la sonda lo comprueba de forma directa. Una segunda sonda asienta un Promise.race en un cumplimiento y deja que una entrada más lenta se rechace después; por separado deja que un Promise.all se rechace temprano mientras otra entrada se rechaza más tarde. Ninguno de esos rechazos posteriores llega al listener de rechazos no atendidos, mientras que un rechazo desnudo sin manejador sí llega:
after-race-fail=false
after-all-fail=false
control-fail=true
La razón es que cada combinador une su propio manejador a cada entrada cuando se la pasa, así que una entrada que se rechaza después de que el combinador se asentó sigue siendo observada, solo que descartada. Esa es una diferencia real frente a la expectativa ingenua: abandonar un lote no dispara rechazos no atendidos, y un entorno que se queda callado tras un lote fallido está diciendo la verdad.
Lo que quien llama todavía tiene que hacer es conservar el resultado. El combinador descarta el asentamiento posterior, así que un rechazo que llega después de los hechos se pierde a menos que la propia entrada lo registre. Un catch en cada entrada que registra la falla y devuelve un valor centinela conserva ese registro y deja intacta la semántica del combinador, y cuesta una línea por entrada. Quien lo omite obtiene un resultado limpio y ningún rastro de la falla abandonada, que es más silencioso y más difícil de depurar de lo que habría sido un rechazo no atendido.
Qué deja en marcha un combinador ya asentado
La segunda línea de la sonda es la evidencia de una afirmación fácil de enunciar y difícil de recordar en una revisión de código: un combinador asentado ha dejado de mirar, no ha dejado el trabajo. Se iniciaron tres entradas, una se rechazó de inmediato y las otras dos llegaron a su propio final. La promise del combinador estuvo asentada todo el tiempo.
Esa brecha tiene un costo en un lote que comparte un recurso. Diez solicitudes que comparten un mismo grupo de conexiones, un descriptor de archivo o un presupuesto de límite de tasa seguirán consumiéndolo después de que el combinador ya se rechazó, porque cada una sigue dentro de su propia operación. El presupuesto que quien llama planeó para un lote lo gasta un lote que ya abandonó, y el siguiente lote empieza con menos de lo que espera.
La forma de cerrar la brecha es dar a cada entrada una manera de detenerse. Un AbortController por lote, con su signal pasado a cada operación, convierte el asentamiento temprano del combinador en una detención explícita:
const controller = new AbortController();
try {
await Promise.all(tasks.map(task => task.run(controller.signal)));
} catch (error) {
controller.abort();
throw error;
}
La llamada a abort ocurre en el mismo turno que el rechazo, así que las entradas restantes se enteran en su siguiente comprobación y no en su final natural. Esa es la diferencia entre un lote que se detiene y un lote que solo deja de ser esperado, y es la única parte del patrón que el combinador no hace por quien llama.
El mismo razonamiento se aplica a race. Una race usada como tiempo de espera agotado se asienta con el temporizador, y el trabajo real sigue en marcha sin que nadie lo espere, así que quien aborta el controlador cuando gana el temporizador acierta las dos mitades: el plazo se hace cumplir y el trabajo abandonado se detiene. Quien solo compite y sigue adelante ha hecho cumplir el plazo y ha dejado el trabajo atrás. El turno en que ese temporizador se dispara es en sí una cuestión de orden, que cubre microtareas y temporizadores.
Registra una lectura de un combinador
Una lectura de un combinador respalda una afirmación estrecha: este entorno, estas entradas, este orden de llegada produjeron estos asentamientos. Cinco campos la mantienen comprobable:
- El entorno, porque tanto el evento de rechazo no atendido como la forma de
AggregateErrorlos proporciona el entorno. - El combinador y el número de entradas, porque el caso vacío y el caso de una sola entrada se comportan distinto del general.
- El orden de llegada de las entradas, porque
allyallSettledinforman el orden de entrada y un registro sin él no puede explicar por qué una entrada lenta llegó primero. - Qué entradas seguían en marcha cuando el combinador se asentó, porque esa es la diferencia entre una espera detenida y un trabajo detenido.
- Si un rechazo se atendió, porque uno no atendido aparece en un canal distinto del resultado.
Fuentes públicas
- MDN: Promise.all()
- MDN: Promise.allSettled()
- MDN: Promise.any()
- MDN: Promise.race()
- MDN: AggregateError
- Node.js: evento unhandledRejection
- BotBrowser: referencia de consistencia de automatización
Conclusión práctica
Elige el combinador por la política de falla que necesita quien llama, no por la forma de la entrada: all cuando una falla debe poner fin a la espera, allSettled cuando cada resultado debe informarse, any cuando un éxito basta y race cuando la respuesta es el primer asentamiento de cualquier tipo. Recuerda que un combinador asentado no detiene sus entradas, así que detenlas tú y atiende cada rechazo que estás a punto de dejar de mirar. Protege la lista vacía antes de llamar a race, porque una race sin entradas no se asienta nunca. Para el orden en que llegan esos asentamientos, lee microtareas y temporizadores; para saber qué sobrevive a una copia de los datos que lleva una promise asentada, lee structuredClone frente a JSON.
Este artículo forma parte de la guía: ¿Qué es la huella digital del navegador?
Artículos Relacionados
Microtareas y temporizadores: el orden real de tu código
PlataformaComprobaciones DRM: sistema de claves, robustez y salida
Huella digitalUnicode: cuatro identidades de texto que lee una página
Huella digitalOrden de claves: enteros ordenan, cadenas se quedan
Huella digitalstructuredClone frente a JSON: qué conserva una copia profunda
Huella digitalICU: alias de zona horaria y la TZ que informas
Lleva BotBrowser de la investigación a producción
Usa estas guías para entender el modelo y después avanzar hacia validación multiplataforma, contextos aislados y despliegue de navegador preparado para escalar.