- Le standard Web Streams a été conçu pour assurer un streaming de données cohérent entre navigateur et serveur, mais il dégrade aujourd’hui l’expérience développeur en raison de sa complexité et de ses limites de performance
- L’API existante impose des contraintes de conception comme la gestion des verrous (lock), le BYOB et la backpressure, qui créent une charge inutile tant pour l’usage que pour l’implémentation
- Cloudflare propose un nouveau modèle de flux fondé sur l’itération asynchrone (async iteration), qui affiche des performances 2 fois à 120 fois supérieures
- La nouvelle API améliore l’efficacité et la cohérence grâce à une structure simple d’async iterable, des politiques explicites de backpressure et la prise en charge parallèle du synchrone et de l’asynchrone
- Cette approche pourrait permettre un modèle de streaming unifié sur tous les runtimes, notamment Node.js, Deno, Bun et les navigateurs, et servir de point de départ à de futures discussions de standardisation
Limites structurelles de Web Streams
- Le standard WHATWG Streams a été développé entre 2014 et 2016 avec une conception centrée sur le navigateur ; comme l’async iteration n’existait pas encore à l’époque, il a introduit un modèle distinct de reader/writer
- Cela a entraîné des procédures inutiles comme la gestion des verrous, des boucles de lecture complexes et le traitement des buffers BYOB
- Le modèle de verrouillage (locking) monopolise un flux et empêche la consommation parallèle ; si
releaseLock() est omis, le flux peut rester verrouillé de façon permanente
- La fonctionnalité BYOB (Bring Your Own Buffer) visait la réutilisation mémoire, mais son modèle complexe de séparation et de transfert des buffers la rend peu utilisée en pratique et difficile à implémenter
- La backpressure est théoriquement prise en charge, mais sa structure ne permet pas de contrôle réel, par exemple
enqueue() réussit même quand la valeur desiredSize est négative
- Chaque appel à
read() force la création d’une Promise, ce qui provoque une baisse de performances et une charge GC dans les scénarios de streaming à haute fréquence
Problèmes constatés en pratique
- Si le corps de réponse de
fetch() n’est pas consommé, cela peut épuiser le pool de connexions ; et l’usage de tee() entraîne un buffering mémoire illimité
TransformStream traite immédiatement sans tenir compte de l’état de préparation à la lecture, ce qui provoque une explosion du buffer quand le consommateur est lent
- En rendu côté serveur (SSR), le traitement de milliers de petits chunks déclenche un GC thrashing qui fait chuter fortement les performances
- Chaque runtime (Node.js, Deno, Bun, Workers) a introduit des chemins d’optimisation non standard pour atténuer ces problèmes, au prix d’une baisse de compatibilité et de cohérence
- Les Web Platform Tests exigent plus de 70 fichiers de test complexes, reflet d’une gestion interne d’état excessive et de comportements peu intuitifs
Principes de conception de la nouvelle API de flux
- Le flux est défini comme un simple async iterable, consommable directement avec
for await...of
- Elle adopte des transformations pull-through, de sorte que le traitement ne s’exécute que lorsque le consommateur demande des données
- Elle fournit des politiques explicites de backpressure (
strict, block, drop-oldest, drop-newest) pour éviter l’emballement mémoire
- Les données sont transmises sous forme de chunks batchés (
Uint8Array[]) pour réduire le coût de création des Promise
- L’API est simplifiée avec un traitement dédié uniquement aux octets, en supprimant BYOB et les concepts complexes de contrôleur
- Une voie synchrone (synchronous) est prise en charge afin d’éliminer le surcoût des Promise dans les traitements centrés CPU
Exemples et caractéristiques de la nouvelle API
Stream.push() permet de créer simplement une paire writer/readable, et Stream.text() de récupérer tout le texte
Stream.pull() construit une pipeline paresseuse (lazy) qui ne s’exécute qu’au moment de la consommation
Stream.share() et Stream.broadcast() prennent en charge une gestion explicite de plusieurs consommateurs
- Les API sync/async combinées (
Stream.pullSync(), Stream.textSync()) maximisent les performances pour les opérations sans I/O
- Pour l’interopérabilité avec Web Streams, la conversion est possible via de simples fonctions d’adaptation
Comparaison de performances et perspectives
- Des benchmarks sur Node.js montrent un traitement jusqu’à 80 à 90 fois plus rapide, et dans les navigateurs plus de 100 fois plus rapide
- Exemple : 275GB/s contre 3GB/s sur une chaîne de transformation en 3 étapes
- Ces gains de performances viennent de la suppression du surcoût asynchrone, du traitement par lots et d’une conception fondée sur le pull
- Cette implémentation est écrite en pur TypeScript/JavaScript, avec un potentiel d’amélioration supplémentaire via une implémentation native
- Cloudflare présente cette approche comme un point de départ pour la discussion autour d’un standard et sollicite les retours de la communauté des développeurs
Conclusion
- Web Streams était une solution raisonnable compte tenu des contraintes de l’époque, mais ne correspond plus aux fonctionnalités du JavaScript moderne ni aux pratiques actuelles de développement
- Le nouveau modèle fondé sur les async iterables réunit simplicité, performance et contrôle explicite, et ouvre la voie à un écosystème de streaming cohérent entre runtimes
- Cloudflare publie une implémentation de référence, de la documentation et des exemples de code sur GitHub dans jasnell/new-streams
- L’objectif n’est pas de définir immédiatement un nouveau standard, mais de poser un point de départ concret pour discuter d’une “meilleure API de flux”
1 commentaires
Réactions sur Hacker News
J’ai moi-même conçu une interface de Stream meilleure que l’API proposée dans cet article
La proposition actuelle prend la forme d’un
async iterator of UInt8Array, alors que je propose une structure dans laquellenext()peut renvoyer un résultat synchrone ou asynchroneCela permet
de parcourir plus simplement avec un itérateur unique que dans la structure existante
si l’on applique une transformation synchrone à une entrée synchrone, tout le traitement peut rester synchrone, ce qui réduit la duplication de code
de réduire les créations inutiles de Promise, avec un gain de performances
et de permettre le contrôle de la concurrence, en dépassant ainsi les limites de l’async iterator
Avec ton approche, on ne peut pas facilement reconstruire leur structure, alors que l’inverse est possible
Un itérateur centré sur les E/S doit renvoyer des chunks de type T pour éviter le gaspillage de buffer
L’usage de
Uint8Arrayvise à s’aligner sur les flux d’octets au niveau du système d’exploitationEn pratique, même dans les projets basés sur C, ce type de structure est le plus efficace, donc il est naturel qu’un protocole portant des informations de type se construise par-dessus
Sur les anciennes versions, l’écart montait jusqu’à 105 fois
Il y a eu une optimisation du traitement async dans Node 16, et je me souviens qu’à l’époque certains tests avaient cassé
Uint8Arrayn’existe pasUint8Arrayn’est qu’un type primitif représentant un tableau d’octets, et les informations de type doivent être gérées au niveau de l’application, pas du protocoleRéférence : documentation Clojure Transducers
Async iterable n’est pas non plus une solution parfaite
Le coût des Promise et des changements de pile est élevé, donc les performances sont mauvaises quand on traite de petites unités de données
Dans Lit-SSR, on a résolu cela en incluant des thunk dans un iterable synchrone
Le thunk n’est invoqué et awaited que lorsqu’un traitement async est nécessaire, ce qui a amélioré les performances SSR d’un facteur de 12 à 18
En revanche, comme la Streams API adopte difficilement ce type de contrat fragile, je pense qu’une structure comme
write()etwriteAsync(), permettant un traitement asynchrone optionnel, serait idéaleJe partage un exemple utilisant un generator synchrone dans ce code GitHub
Le point clé est
step.value.then(value => this.next(value))next(): {done, value: T} | Promise)Depuis la controverse de 2013 autour de « Do not unleash Zalgo », on a eu tendance à éviter les formes
MaybeAsync, maisje pense que cette peur est largement exagérée et qu’elle empêche de concevoir des API rapides et flexibles
On peut aussi créer des utilitaires qui récupèrent plusieurs valeurs à la fois, et les problèmes de vitesse des generators ne me semblent pas si importants en pratique
Travailler avec les Web Streams dans Node.js est pénible
Elles ont été conçues avant tout pour le navigateur, donc elles sont peu confortables côté serveur
Même pour une transformation simple, il faut envelopper le tout dans un transform stream, et on ne retrouve pas l’enchaînement intuitif d’un
.pipe()L’approche async iterable est bien plus naturelle et s’accorde mieux avec
for-await-ofLa spécification Web Streams est trop centrée sur l’abstraction, au détriment du côté pratique
Je pensais que ça ne servait qu’à la compatibilité client-serveur
Le véritable avantage, ce n’est pas seulement la performance, mais aussi la convergence entre environnements
Si
ReadableStreamse comporte de la même façon dans le navigateur, les Worker et d’autres runtimes,cela améliore la portabilité du code et réduit les bugs de backpressure
La standardisation de la couche stream est essentielle pour construire des systèmes de streaming fiables
J’avais créé autrefois une abstraction appelée Repeater
C’est une sorte de transposition du constructeur de Promise vers un async iterable, avec contrôle des événements via push/stop
La bibliothèque Repeater est suffisamment stable pour atteindre 6,5 millions de téléchargements hebdomadaires
Aujourd’hui, je préfère davantage les streams, mais les critiques autour de
tee()restent valablesJe pense que prendre async iterable comme abstraction de base est la bonne direction
stopde Repeater fonctionne à la fois comme fonction et comme PromiseAprès avoir regardé le code source,
je me suis dit que, même si cela diffère des schémas traditionnels, c’était peut-être un choix intentionnel pour une conception ergonomique
J’ai une telle nostalgie que j’utilise même « Up, Up, Down, Down, Left, Right, Left, Right, B, A » dans ma signature d’e-mail
J’ai moi aussi déjà créé un wrapper pour utiliser AsyncIterable plus succinctement
Il s’agit de fluent-async-iterator,
et c’était utile pour de petits flux de données dans des pipelines Lambda ou CLI
J’espérais qu’une meilleure API serait apparue depuis
Le comportement de backpressure de
ReadableStream.tee()est déroutant, car il est inverse de celui depipe()dans Node.jsLa spécification dit que « la sortie la plus lente doit déterminer le rythme », mais dans l’implémentation réelle, le flux se bloque même si la branche rapide n’est pas consommée
Je pense qu’une structure plus simple, basée sur le push, comme cette nouvelle Stream API, serait préférable
Node et Web Streams utilisent des files d’attente infinies, ce qui permet de multiplier les
res.write()de façon synchrone, maiscette API impose un flux de
yieldfondé sur les generators, ce qui est plus sûrAvec undici(fetch) dans Node.js, le problème d’épuisement du pool de connexions vient
des limites des langages à ramasse-miettes
Si l’on ne ferme pas explicitement les ressources, des fuites apparaissent selon le moment où passe le GC
L’approche RAII (reference counting) de C++ est au contraire plus sûre
Pour tout ce qui touche à la libération des ressources, j’aimerais voir se généraliser les modèles
using/await usingJ’applique actuellement, comme en C# avec
using, une structure prenant en charge dispose/disposeAsync à des drivers de base de donnéesDes chiffres de benchmark comme 530 GB/s dépassent la bande passante mémoire du M1 Pro (200 GB/s), donc ils sont difficiles à croire
Il s’agit probablement d’un benchmark vibe-codé avec un contrôle qualité insuffisant