4 points par GN⁺ 2026-02-28 | 1 commentaires | Partager sur WhatsApp
  • 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

 
GN⁺ 2026-02-28
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 laquelle next() peut renvoyer un résultat synchrone ou asynchrone
    Cela 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

    • Tu dis que ton approche est meilleure, mais je pense en réalité que celle d’en face est supérieure en tant que forme primitive plus fondamentale
      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
    • Le concept de stream que tu proposes est intéressant, mais leur conception part du principe de la compatibilité avec AsyncIterator
      L’usage de Uint8Array vise à s’aligner sur les flux d’octets au niveau du système d’exploitation
      En 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
    • J’ai mesuré par microbenchmark l’écart de vitesse entre les appels de fonctions synchrones et async dans Node 24, et c’est environ 90 fois plus lent
      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é
    • Le type Uint8Array n’existe pas
      Uint8Array n’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 protocole
    • Cette structure ressemble au concept de transducer de Clojure
      Ré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() et writeAsync(), permettant un traitement asynchrone optionnel, serait idéale

    • Le problème que tu décris peut être résolu par mon stream iterator
      Je partage un exemple utilisant un generator synchrone dans ce code GitHub
      Le point clé est step.value.then(value => this.next(value))
    • J’aime bien la proposition de conartist6 (next(): {done, value: T} | Promise)
      Depuis la controverse de 2013 autour de « Do not unleash Zalgo », on a eu tendance à éviter les formes MaybeAsync, mais
      je 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-of
    La spécification Web Streams est trop centrée sur l’abstraction, au détriment du côté pratique

    • Ça me surprend qu’il y ait vraiment des gens qui utilisent Web Streams dans Node
      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 ReadableStream se 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

    • Oui, l’intérêt n’est pas seulement la performance, c’est aussi la valeur de la standardisation
  • 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 valables
    Je pense que prendre async iterable comme abstraction de base est la bonne direction

    • J’ai trouvé intéressant que stop de Repeater fonctionne à la fois comme fonction et comme Promise
      Aprè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
    • Hors sujet, mais l’exemple du code Konami m’a fait très plaisir
      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 de pipe() dans Node.js
    La 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, mais
    cette API impose un flux de yield fondé sur les generators, ce qui est plus sûr

  • Avec 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 using
    J’applique actuellement, comme en C# avec using, une structure prenant en charge dispose/disposeAsync à des drivers de base de données

  • Des 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