- El estándar Web Streams fue diseñado para un streaming de datos consistente entre navegador y servidor, pero actualmente la complejidad y las limitaciones de rendimiento deterioran la experiencia de desarrollo
- La API existente genera cargas innecesarias tanto en uso como en implementación debido a restricciones de diseño como gestión de locks, BYOB y backpressure
- Cloudflare propone un nuevo modelo de streams basado en iteración asíncrona (async iteration), y este enfoque muestra un rendimiento 2 veces hasta 120 veces más rápido
- La nueva API mejora la eficiencia y la consistencia mediante una estructura simple de async iterable, políticas explícitas de backpressure y soporte paralelo para sincronía/asíncronía
- Este enfoque puede habilitar un modelo de streaming unificado en todos los runtimes, como Node.js, Deno, Bun y navegadores, y podría convertirse en el punto de partida para futuras discusiones de estandarización
Limitaciones estructurales de Web Streams
- El estándar WHATWG Streams fue desarrollado entre 2014 y 2016 con un diseño centrado en el navegador, y como en ese momento no existía la async iteration, se introdujo un modelo separado de reader/writer
- Esto generó procedimientos innecesarios como gestión de locks, bucles de lectura complejos y manejo de buffers BYOB
- El modelo de locking ocupa el stream de forma exclusiva e impide el consumo en paralelo, y si se omite
releaseLock(), puede ocurrir que el stream quede bloqueado permanentemente
- La función BYOB (Bring Your Own Buffer) buscaba reutilizar memoria, pero debido a un modelo complejo de separación y transferencia de buffers, su uso real es bajo y su implementación difícil
- El backpressure está soportado en teoría, pero su estructura no permite un control real, por ejemplo,
enqueue() sigue teniendo éxito incluso cuando desiredSize es negativo
- En cada llamada a
read() se fuerza la creación de un Promise, lo que en streams de alta frecuencia provoca caída de rendimiento y carga de GC
Problemas visibles en la práctica
- Si no se consume el cuerpo de respuesta de
fetch(), se produce agotamiento del pool de conexiones, y al usar tee() aparece buffering ilimitado en memoria
TransformStream procesa de inmediato sin importar si la lectura está lista, lo que en entornos con consumidores lentos provoca crecimiento explosivo del buffer
- En server-side rendering (SSR), el manejo de miles de chunks pequeños provoca GC thrashing, lo que reduce drásticamente el rendimiento
- Cada runtime (Node.js, Deno, Bun, Workers) introdujo rutas de optimización no estándar para mitigar esto, pero eso terminó deteriorando la compatibilidad y la consistencia
- Web Platform Tests exige más de 70 archivos de prueba complejos, lo que es resultado de un manejo excesivo del estado interno y comportamientos poco intuitivos
Principios de diseño de la nueva API de streams
- El stream se define como un simple async iterable, por lo que puede consumirse directamente con
for await...of
- Se adopta una transformación pull-through, de modo que el procesamiento solo ocurre cuando el consumidor solicita datos
- Se ofrecen políticas explícitas de backpressure (
strict, block, drop-oldest, drop-newest) para evitar explosiones de memoria
- Los datos se entregan en unidades de chunks por lote (
Uint8Array[]) para reducir el costo de crear Promises
- Se simplifica al enfocarse exclusivamente en procesamiento de bytes, eliminando BYOB y conceptos complejos de controlador
- El soporte para rutas sincrónicas elimina el overhead de Promise en tareas centradas en CPU
Ejemplos y características de la nueva API
- Con
Stream.push() se puede crear fácilmente un par writer/readable, y con Stream.text() se puede recopilar todo el texto
Stream.pull() construye un pipeline perezoso (lazy) que solo se ejecuta en el momento del consumo
Stream.share() y Stream.broadcast() permiten una gestión explícita de múltiples consumidores
- La API Sync/Async (
Stream.pullSync(), Stream.textSync()) maximiza el rendimiento en operaciones sin I/O
- Para interoperar con Web Streams, se puede convertir mediante funciones adaptadoras simples
Comparación de rendimiento y perspectivas
- En benchmarks sobre Node.js se confirmó una velocidad de procesamiento hasta 80~90 veces mayor, y en navegadores más de 100 veces mayor
- Ejemplo: en una cadena de transformación de 3 etapas, 275GB/s vs 3GB/s
- La mejora de rendimiento proviene de la eliminación del overhead asíncrono, el procesamiento por lotes y el diseño basado en pull
- Esta implementación fue escrita en TypeScript/JavaScript puro, y podría mejorar aún más con una implementación nativa
- Cloudflare presenta este enfoque como un punto de partida para discutir estándares y pide retroalimentación de la comunidad de desarrolladores
Conclusión
- Web Streams fue razonable dadas las limitaciones de su momento, pero no encaja con las funciones del lenguaje ni con los patrones de desarrollo del JavaScript moderno
- El nuevo modelo basado en async iterable cumple con simplicidad, rendimiento y control explícito, y plantea la posibilidad de construir un ecosistema de streaming consistente entre runtimes
- Cloudflare publicó la implementación de referencia, documentación y código de ejemplo en GitHub: jasnell/new-streams
- El objetivo no es definir de inmediato un nuevo estándar, sino establecer un punto de partida práctico para debatir una “mejor API de streams”
1 comentarios
Comentarios de Hacker News
Diseñé directamente una interfaz de Stream mejor que la API propuesta en este artículo
La propuesta actual tiene la forma de
async iterator of UInt8Array, pero yo propongo una estructura en la quenext()puede devolver resultados síncronos o asíncronosEsto permite
recorrer todo de forma más simple con un único iterador en comparación con la estructura existente
si aplicas una transformación síncrona a una entrada síncrona, todo el procesamiento puede hacerse de forma síncrona, reduciendo la duplicación de código
se reduce la creación innecesaria de Promise, lo que mejora el rendimiento
permite control de concurrencia, superando las limitaciones de async iterator
Con tu enfoque no se puede construir fácilmente su estructura, mientras que al revés sí
Un iterador orientado a I/O debe devolver chunks en unidades de T para evitar el desperdicio de búfer
Se usa
Uint8Arraypara alinearse con los byte streams a nivel de SODe hecho, incluso en proyectos basados en C este tipo de estructura es la más eficiente, así que es natural que un protocolo con información de tipos se construya encima de eso
En versiones anteriores la diferencia llegaba hasta 105 veces
Recuerdo que hubo una optimización del procesamiento async en Node 16, y que en ese momento se rompieron algunas pruebas
Uint8Arrayno existeUint8Arrayes simplemente un tipo primitivo para representar arreglos de bytes, y la información de tipos debe manejarse a nivel de aplicación, no de protocoloReferencia: Documentación de Clojure Transducers
Async iterable tampoco es una solución perfecta
El overhead de Promise y del cambio de stack es grande, así que el rendimiento es malo cuando se manejan datos en unidades pequeñas
En Lit-SSR usaron un enfoque de incluir thunks dentro de un iterable síncrono para resolverlo
Solo cuando se necesita trabajo async se invoca el thunk y se hace await, lo que mejoró el rendimiento de SSR entre 12 y 18 veces
Aun así, como es difícil que la Streams API adopte ese tipo de contrato frágil, me parece ideal una estructura que permita asincronía opcional como
write()ywriteAsync()Comparto un ejemplo usando un generator síncrono en este código de GitHub
La clave está en
step.value.then(value => this.next(value))next(): {done, value: T} | Promise)Desde el debate de 2013 sobre “Do not unleash Zalgo”, ha habido una tendencia a evitar formas como
MaybeAsync, perocreo que ese miedo está demasiado exagerado y está bloqueando el diseño de APIs rápidas y flexibles
También se pueden crear utilidades para traer varios valores de una vez, y siento que el problema de velocidad de los generators en la práctica no es tan grande
En Node.js, manejar Web Streams es doloroso
Están diseñados pensando en el navegador, así que son incómodos en entornos de servidor
Incluso para una transformación simple hay que envolver un transform stream, y es difícil encadenar de forma intuitiva como con
.pipe()El enfoque de async iterable se siente mucho más natural y encaja bien con
for-await-ofLa especificación de Web Streams está demasiado centrada en la abstracción, así que resulta poco práctica
Yo pensaba que eso era solo para compatibilidad entre cliente y servidor
La ventaja real no es solo el rendimiento, sino también la consistencia entre entornos (convergence)
Si ReadableStream se comporta igual en navegadores, Worker y otros runtimes,
la portabilidad del código mejora y también se reducen los bugs de backpressure
Estandarizar la capa de streams es clave para construir sistemas de streaming confiables
Antes hice una abstracción llamada Repeater
Es una idea que lleva el constructor de Promise a async iterable, controlando los eventos con push/stop
La librería Repeater es lo bastante estable como para registrar 6.5 millones de descargas semanales
Últimamente prefiero más los streams, pero las críticas relacionadas con
tee()siguen siendo válidasCreo que tomar async iterable como abstracción base es la dirección correcta
stopen Repeater funcione tanto como función como PromiseDespués de ver el código fuente,
pensé que, aunque se aparta del patrón tradicional, podría ser una decisión intencional para una arquitectura ergonómica
Me da tanta nostalgia que hasta uso “Up, Up, Down, Down, Left, Right, Left, Right, B, A” en la firma del correo
Yo también hice alguna vez un wrapper para usar AsyncIterable de forma más concisa
Era fluent-async-iterator,
y me resultó útil para streaming de datos a pequeña escala en pipelines de Lambda o CLI
Para estas fechas esperaba que ya hubiera aparecido una API mejor
El comportamiento de backpressure de
ReadableStream.tee()es confuso porque es lo opuesto al depipe()en Node.jsLa especificación dice que “la salida más lenta debería determinar la velocidad”, pero en la implementación real incluso el lado rápido se bloquea si el otro no consume
Me parece mejor una estructura simple basada en push como la de la nueva Stream API
Node y Web Streams dejan colas infinitas para que uno pueda abusar de
res.write()de forma síncrona, peroesta API fuerza el flujo de
yieldbasado en generators, así que es más seguraQue en Node.js se agote el pool de conexiones al usar undici(fetch)
se debe a las limitaciones de los lenguajes con recolección de basura
Si no se cierran explícitamente los recursos, se generan fugas según el momento en que corra el GC
El enfoque de RAII (reference counting) de C++ es, de hecho, más seguro
En cuanto a la liberación de recursos, ojalá el patrón
using/await usingse siga expandiendoEstoy aplicando a drivers de base de datos una estructura similar al
usingde C# que soporta dispose/disposeAsyncLas cifras del benchmark (por ejemplo, 530GB/s) superan el ancho de banda de memoria del M1 Pro (200GB/s), así que cuesta creerlas
Es muy probable que se trate de un benchmark vibe-coded con poco control de calidad en la implementación