We deserve a better streams API for JavaScript

(blog.cloudflare.com)

172 points | by nnx 3 hours ago

18 comments

  • conartist6 2 hours ago
    As it happens i have an even better API than this article proposes!

    They propose just using an async iterator of UInt8Array. I almost like this idea, but it's not quite all the way there.

    They propose this:

      type Stream<T> = {
        next(): Promise<{ done, value: UInt8Array<T> }>
      }
    
    I propose this, which I call a stream iterator!

      type Stream<T> = {
        next(): { done, value: T } | Promise<{ done, value: T }>
      }
    
    Obviously I'm gonna be biased, but I'm pretty sure my version is also objectively superior:

    - I can easily make mine from theirs

    - In theirs the conceptual "stream" is defined by an iterator of iterators, meaning you need a for loop of for loops to step through it. In mine it's just one iterator and it can be consumed with one for loop.

    - I'm not limited to having only streams of integers, they are

    - My way, if I define a sync transform over a sync input, the whole iteration can be sync making it possible to get and use the result in sync functions. This is huge as otherwise you have to write all the code twice: once with sync iterator and for loops and once with async iterators and for await loops.

    - The problem with thrashing Promises when splitting input up into words goes away. With async iterators, creating two words means creating two promises. With stream iterators if you have the data available there's no need for promises at all, you just yield it.

    - Stream iterators can help you manage concurrency, which is a huge thing that async iterators cannot do. Async iterators can't do this because if they see a promise they will always wait for it. That's the same as saying "if there is any concurrency, it will always be eliminated."

    • Joker_vD 1 hour ago
      > Obviously I'm gonna be biased, but I'm pretty sure my version is also objectively superior:

      > - I can easily make mine from theirs

      That... doesn't make it superior? On the contrary, theirs can't be easily made out of yours, except by either returning trivial 1-byte chunks, or by arbitrary buffering. So their proposal is a superior primitive.

      On the whole, I/O-oriented iterators probably should return chunks of T, otherwise you get buffer bloat for free. The readv/writev were introduced for a reason, you know.

      • conartist6 1 hour ago
        As an abstraction I would say it does make mine superior that it captures everything theirs can and more that theirs can't.

        Plus theirs involves the very concrete definition of an array, which might have 100 prototype methods in JS, each part of their API surface. I have one function in my API surface.

    • flowerbreeze 1 hour ago
      I think the more generic stream concept is interesting, but their proposal is based on different underlying assumptions.

      From what it looks like, they want their streams to be compatible with AsyncIterator so it'd fit into existing ecosystem of iterators.

      And I believe the Uint8Array is there for matching OS streams as they tend to move batches of bytes without having knowledge about the data inside. It's probably not intended as an entirely new concept of a stream, but something that C/C++ or other language that can provide functionality for JS, can do underneath.

      For example my personal pet project of a graph database written in C has observers/observables that are similar to the AsyncIterator streams (except one observable can be listened to by more than one observer) moving about batches of Uint8Array (or rather uint8_t* buffer with capacity/count), because it's one of the fastest and easiest thing to do in C.

      It'd be a lot more work to use anything other than uint8_t* batches for streaming data. What I mean by that, is that any other protocol that is aware of the type information would be built on top of the streams, rather than being part of the stream protocol itself for this reason.

      • conartist6 1 hour ago
        Yeah it makes sense to me that the actual network socket is going to move data around in buffers. I'm just offering an abstraction over that so that you can write code that is wholly agnostic to how data is stored.

        And yes, because it's a new abstraction the compat story is interesting. We can easily wrap any source so we'll have loads of working sources. The fight will be getting official data sinks that support a new kind of stream

    • paxys 1 hour ago
      There is no such thing as Uint8Array<T>. Uint8Array is a primitive for a bunch of bytes, because that is what data is in a stream.

      Adding types on top of that isn't a protocol concern but an application-level one.

      • softfalcon 50 minutes ago
        > Adding types on top of that isn't a protocol concern but an application-level one.

        I agree with this.

        I have had to handle raw byte streams at lower levels for a lot of use-cases (usually optimization, or when developing libs for special purposes).

        It is quite helpful to have the choice of how I handle the raw chunks of data that get queued up and out of the network layer to my application.

        Maybe this is because I do everything from C++ to Javascript, but I feel like the abstractions of cleanly getting a stream of byte arrays is already so many steps away from actual network packet retrieval, serializing, and parsing that I am a bit baffled folks want to abstract this concern away even more than we already do.

        I get it, we all have our focuses (and they're ever growing in Software these days), but maybe it's okay to still see some of the bits and bytes in our systems?

        • conartist6 15 minutes ago
          My concern isn't with how you write your network layer. Use buffers in there, of course.

          But what if you just want to do a simple decoding transform to get a stream of Unicode code points from a steam of bytes? If your definition of a stream is that it has UInt8 values, that simply isn't possible. And there's still gonna be waaay too many code points to fall back to an async iterator of code points.

    • pgt 1 hour ago
      This is similar to how Clojure transducers are implemented: "give me the next thing plz." – https://clojure.org/reference/transducers
    • paulddraper 1 hour ago
      Your idea is flatten the UInt8Array into the stream.

      While I understand the logic, that's a terrible idea.

      * The overhead is massive. Now every 1KiB turns into 1024 objects. And terrible locality.

      * Raw byte APIs...network, fs, etc fundamentally operate on byte arrays anyway.

      In the most respectful way possible...this idea would only be appealing to someone who's not used to optimizing systems for efficiency.

      • conartist6 1 hour ago
        JS engines actually are optimized to make that usage pattern fast.

        Small, short-lived objects with known key ordering (monomorphism) are not a major cost in JS because the GC design is generational. The smallest, youngest generation of objects can be quickly collected with an incremental GC because the perf assumption is that most of the items in the youngest generation will be garbage. This allows collection to be optimized by first finding the live objects in the gen0 pool, copying them out, then throwing away the old gen0 pool memory and replacing it with a new chunk.

        • softfalcon 39 minutes ago
          What happens when I send an extremely high throughput of data and the scheduler decides to pause garbage collection due to there being too many interrupts to my process sending network events? (a common way network data is handed off to an application in many linux distros)

          Are there any concerns that the extra array overhead will make the application even more vulnerable to out of memory errors while it holds off on GC to process the big stream (or multiple streams)?

          I am mostly curious, maybe this is not a problem for JS engines, but I have sometimes seen GC get paused on high throughput systems in GoLang, C#, and Java, which causes a lot of headaches.

          • conartist6 5 minutes ago
            Yeah I don't think that's generally a problem for JS engines because of the incremental garbage collector.

            If you make all your memory usage patterns possible for the incremental collector to collect, you won't experience noticeable hangups because the incremental collector doesn't stop the world. This was already pretty important for JS since full collections would (do) show up as hiccups in the responsiveness of the UI.

        • conartist6 40 minutes ago
          It's not blazingly fast, no, but it's not as much overhead as people think either when they're imagining what it would cost to do the same thing with malloc. TC39 knew all this when they picked { step, done } as the API for iteration and they still picked it, so I'm not really introducing new risk but rather trusting that they knew what they were doing when they designed string iterators.

          At the moment the consensus seems to be that these language features haven't been worth investing much in optimizing because they aren't widely used in perf-critical pathways. So there's a chicken and egg problem, but one that gives me some hope that these APIs will actually get faster as their usage becomes more common and important, which it should if we adopt one of these proposed solutions to the current DevX problems

      • fwip 44 minutes ago
        I agree with your post, but in practice, couldn't you get back that efficiency by setting T = UInt8Array? That is, write your stream to send / receive arrays.

        My reference point is from a noob experience with Golang - where I was losing a bunch of efficiency to channel overhead from sending millions of small items. Sending batches of ~1000 instead cut that down to a negligible amount. It is a little less ergonomic to work with (adding a nesting level to your loop).

    • soulofmischief 27 minutes ago
      In the language I've been working on for a couple months, Eidos, streams are achieved through iterators as well. It's dead simple. And lazy for loops are iterators, and there is piping syntax. This means you can do this (REPL code):

        >> fn double(iter: $iterator<i32>) {
          return *for x in iter { $yield( x * 2 )}
        }
      
        >> fn add_ten(iter: $iterator<i32>) {
          return *for x in iter { $yield( x + 10 )}
        }
      
        >> fn print_all(iter: $iterator<i32>) {
          for x in iter { $print( x )}
        }
      
        >> const source = *for x in [1, 2, 3] { $yield( x )}
      
        >> source |> double |> add_ten |> print_all
        12
        14
        16
      
      You get backpressure for free, and the compiler can make intelligent decisions, such as automatic inlining, unrolling, kernel fusing, etc. depending on the type of iterators you're working with.
    • conartist6 1 hour ago
      There's one more interesting consequence: you rid yourself of the feedback problem.

      To see the problem let's create a stream with feedback. Lets say we have an assembly line that produces muffins from ingredients, and the recipe says that every third muffin we produce must be mushed up and used as an ingredient for further muffins. This works OK until someone adds a final stage to the assembly line, which puts muffins in boxes of 12. Now the line gets completely stuck! It can't get a muffin to use on the start of the line because it hasn't made a full box of muffins yet, and it can't make a full box of muffins because it's starved for ingredients after 3.

      If we're mandated to clump the items together we're implicitly assuming that there's no feedback, yet there's also no reason that feedback shouldn't be a first-class ability of streams.

  • spankalee 44 minutes ago
    Async iterables aren't necessarily a great solution either because of the exact same promise and stack switching overhead - it can be huge compared to sync iterables.

    If you're dealing with small objects at the production side, like individual tag names, attributes, bindings, etc. during SSR., the natural thing to do is to just write() each string. But then you see that performance is terrible compared to sync iterables, and you face a choice:

      1. Buffer to produce larger chunks and less stack switching. This is the exact same thing you need to do with Streams. or
    
      2. Use sync iterables and forgo being able to support async components.
    
    The article proposes sync streams to get around this some, but the problem is that in any traversal of data where some of the data might trigger an async operation, you don't necessarily know ahead of time if you need a sync or async stream or not. It's when you hit an async component that you need it. What you really want is a way for only the data that needs it to be async.

    We faced this problem in Lit-SSR and our solution was to move to sync iterables that can contain thunks. If the producer needs to do something async it sends a thunk, and if the consumer receives a thunk it must call and await the thunk before getting the next value. If the consumer doesn't even support async values (like in a sync renderToString() context) then it can throw if it receives one.

    This produced a 12-18x speedup in SSR benchmarks over components extracted from a real-world website.

    I don't think a Streams API could adopt such a fragile contract (ie, you call next() too soon it will break), but having some kind of way where a consumer can pull as many values as possible in one microtask and then await only if an async value is encountered would be really valuable, IMO. Something like `write()` and `writeAsync()`.

    The sad thing here is that generators are really the right shape for a lot of these streaming APIs that work over tree-like data, but generators are far too slow.

  • matheus-rr 30 minutes ago
    The practical pain with Web Streams in Node.js is that they feel like they were designed for the browser use case first and backported to the server. Any time I need to process large files or pipe data between services, I end up fighting with the API instead of just getting work done.

    The async iterable approach makes so much more sense because it composes naturally with for-await-of and plays well with the rest of the async/await ecosystem. The current Web Streams API has this weird impedance mismatch where you end up wrapping everything in transform streams just to apply a simple operation.

    Node's original stream implementation had problems too, but at least `.pipe()` was intuitive. You could chain operations and reason about backpressure without reading a spec. The Web Streams spec feels like it was written by the kind of person who thinks the solution to a complex problem is always more abstraction.

    • zarzavat 15 minutes ago
      It's news to me that anyone actually uses the web streams in node. I thought they were just for interoperability, for code that needs to run on both client and server.
  • bikeshaving 1 hour ago
    A long time ago, I wrote an abstraction called a Repeater. Essentially, the idea behind it is, what would the Promise constructor look like if it was translated to async iterables.

      import { Repeater } from "@repeaterjs/repeater";
      
      const keys = new Repeater(async (push, stop) => {
        const listener = (ev) => {
          if (ev.key === "Escape") {
            stop();
          } else {
            push(ev.key);
          }
        };
        window.addEventListener("keyup", listener);
        await stop;
        window.removeEventListener("keyup", listener);
      });
      const konami = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"];
      (async function() {
        let i = 0;
        for await (const key of keys) {
          if (key === konami[i]) {
            i++;
          } else {
            i = 0;
          }
          if (i >= konami.length) {
            console.log("KONAMI!!!");
            break; // removes the keyup listener
          }
        }
      })();
    
    https://github.com/repeaterjs/repeater

    It’s one of those abstractions that’s feature complete and stable, and looking at NPM it’s apparently getting 6.5mil+ downloads a week for some reason.

    Lately I’ve just taken the opposite view of the author, which is that we should just use streams, especially with how embedded they are in the `fetch` proposals and whatever. But the tee critique is devastating, so maybe the author is right. It’s exciting to see people are still thinking about this. I do think async iterables as the default abstraction is the way to go.

    • boilerupnc 1 hour ago
      Off topic - But just wanna say - Love the cheat code! 30 Lives added :-) Nostalgia runs deep with that code. So deep - in fact, that I sign many of my emails off with "Sent by hitting Up, Up, Down, Down, Left, Right, Left, Right, B, A"
  • cogman10 26 minutes ago
    Seems pretty similar to the design of OKIO in java [1]. With pretty similar goals ultimately. Here's a presentation on the internal details and design decisions. [2]

    [1] https://github.com/square/okio

    [2] https://www.youtube.com/watch?v=Du7YXPAV1M8

  • ai-christianson 2 hours ago
    The point about BYOB reads is spot on. It's frustrating that such a critical feature for performance and reducing GC pressure ended up being so difficult to implement correctly in the WHATWG standard. A simpler, more ergonomic approach to buffer management would go a long way for those of us building high-performance data processing tools in JS.
    • slowcache 2 hours ago
      > high-performance data processing tools in JS

      I may be naive in asking this, but what leads someone to building high perf data tools in JS? JS doesn't seem to me like it would be the tool of choice for such things

      • afavour 19 minutes ago
        Browsers are now able to stream files from disk so you can create a high performance tool that'll read locally, do [x] with it and present the results, all without any network overhead.
      • n_e 1 hour ago
        I have a SaaS project where the backend is in JS. I also have some data processing to do with large file (several TB). Doing it is in JS is more convenient as I can reuse code from the backend, and it is also the language I know best.

        Performance-wise, I get about half the throughput I had with the same processsing done it rust, which doesn't change anything for my use-case.

        However that's not really relevant to the context of the post as I'm using node.js streams which are both saner and fast. I'm guessing that the post is relevant to people using server-side runtimes that only implement web streams.

      • moron4hire 1 hour ago
        You don't always have a choice on where you deliver your software. It'd be nice to have good tools wherever you are forced to work.
      • thadt 1 hour ago
        Browsers
    • jitl 1 hour ago
      but instead of trying to solve that, this api is just like “too hard no one uses it let’s forget about it”.

      right now when i need to wrangle bytes, i switch languages to Golang. it’s easy gc language, and all its IO is built around BYOB api:

      interface Reader { read(b: Uint8Array): [number, Error?] }

      you pass in your own Uint8Array allocation (in go terms, []byte), the reader fills at most the entire thing, and returns (bytes filled, error). it’s a fully pull stream API with one method at its core. now, the api gets to be that simple because it’s always sync, and blocks until the reader can fill data into the buffer or returns an error indicating no data available right now.

      go has a TeeReader with no buffering - it too just blocks until it can write to the forked stream.

      https://pkg.go.dev/io#TeeReader

      we can’t do the same api in JS, because go gets to insert `await` wherever it wants with its coroutine/goroutine runtime. but we can dream of such simplicity combined with zero allocation performance.

  • tracker1 1 hour ago
    One minor niggle on freeing resources... I'm hoping it becomes more popular with libraries, but there's using/await using with disppse/disposeAsync which works similarly to C#'s use of using.

    I'm working on a db driver that uses it by convention as part of connection/pool usage cleanup.

  • z3t4 1 hour ago
    I like Node.JS streams. It's very satisfying to rent a 250MB memory machine and let it process GB's of data using streams.
  • halfmatthalfcat 56 minutes ago
    The Observables spec should just get merged and implemented.

    https://github.com/tc39/proposal-observable

  • shevy-java 2 hours ago
    We deserve a better language than JavaScript.

    Sadly it will never happen. WebAssembly failed to keep some of its promises here.

    • gejose 2 hours ago
      There's always a comment like this in most discussions about javascript.
    • krashidov 57 minutes ago
      > WebAssembly failed to keep some of its promises here

      classic case of not using an await before your promise

    • postalrat 2 hours ago
      Where can I find these not kept promises?
  • paulddraper 1 hour ago
    Just use AsyncIterator<UIntArray>.

    The objection is

    > The Web streams spec requires promise creation at numerous points — often in hot paths and often invisible to users. Each read() call doesn't just return a promise; internally, the implementation creates additional promises for queue management, pull() coordination, and backpressure signaling.

    But that's 95% manageable by altering buffer sizes.

    And as for that last 5%....what are you doing with JS to begin with?

  • dilap 2 hours ago
    > The problems aren't bugs; they're consequences of design decisions that may have made sense a decade ago, but don't align with how JavaScript developers write code today.

    > I'm not here to disparage the work that came before — I'm here to start a conversation about what can potentially come next.

    Terrible LLM-slop style. Is Mr Snell letting an LLM write the article for him or has he just appropriated the style?

    • jasnell 1 hour ago
      Heh, I was using emdashes and tricolons long before LLMs appropriated the style but I did let the agent handle some of the details on this. Honestly, it really is just easier sometimes... Especially for blogs posts like this when I've also got a book I'm writing, code to maintain etc. Use tools available to make life easier.
      • silisili 1 hour ago
        I'm not sure any emdash use at all is what people are calling out typically(maybe it is?), more the sheer number of them typical in LLM written stuff.

        Just ctrl-f'ing through previous public posts, I think there were a total of 7 used across about that many posts. This one for example had 57. I'm not good enough in proper English to know what the normal number is supposed to be, just pointing that out.

      • dilap 1 hour ago
        I think you'd be much better served by writing something rough that maintains your own voice!
      • n_e 1 hour ago
        I found your article both interesting and readable.

        It doesn't really matter what tools are used if the result is good

      • eis 1 hour ago
        People are understandably a bit sensitized and sceptical after the last AI generated blog post (and code slop!) by Cloudflare blew up. Personally I'm fine with using AI to help write stuff as long as everything is proof-read and actually represents the authors thoughts. I would have opted to be a bit more careful and not use AI for a few blog posts after the last incident though if I was working at Cloudflare...
    • azangru 1 hour ago
      What was it specifically about the style that stood out as incongruous, or that hindered comprehension? What was it that made you stumble and start paying close attention to the style rather than to the message? I am looking at the two examples, and I can't see anything wrong with them, especially in the context of the article. They both employ the same rhetorical technique of antithesis, a juxtaposition of contrasting ideas. Surely people wrote like this before? Surely no-one complained?
      • jsheard 1 hour ago
        The problem is less with the style itself and more that it's strongly associated with low-effort content which is going to waste the readers time. It would be nice to be able to give everything the benefit of the doubt, but humans have finite time and LLMs have infinite capacity for producing trite or inaccurate drivel, so readers end up reflexively using LLM tells as a litmus test for (lack of) quality in order to cut through the noise.

        You might say well, it's on the Cloudflare blog so it must have some merit, but after the Matrix incident...

        • azangru 39 minutes ago
          > You might say well, it's on the Cloudflare blog so it must have some merit

          I would instead say that it is written by James Snell, who is one of the central figures in the Node community; and therefore it must have some merit.

    • nebezb 1 hour ago
      The idea is well articulated and comes across clear. What’s the issue? Taking a magnifying glass to the whole article to find sentence structure you think is “LLM-slop” is an odd way to dismiss the article entirely.

      I’ve read my fair share of LLM slop. This doesn’t qualify.

    • jitl 2 hours ago
      cloudflare does seem to love ai written everything
    • lapcat 2 hours ago
      You’ve got it backwards: LLMs were trained on human writing and appropriated our style.
      • have_faith 1 hour ago
        Partially true. They've been trained and then aligned towards a preferred style. They don't use em-dashes because they are over-represented in the training material (majority of people don't use them).
        • lapcat 1 hour ago
          It seems likely that with the written word, as with most things, a minority of people produce the majority of content. Most people publish relatively few words compared to professional writers.

          Possibly the LLM vendors could bias the models more toward nonprofessional content, but then the quality and utility of the output would suffer. Skip the scientific articles and books, focus on rando internet comments, and you’ll end up with a lot more crap than you already get.

  • kg 2 hours ago
    It's a real shame that BYOB (bring your own buffer) reads are so complex and such a pain in the neck because for large reads they make a huge difference in terms of GC traffic (for allocating temporary buffers) and CPU time (for the copies).

    In an ideal world you could just ask the host to stream 100MB of stuff into a byte array or slice of the wasm heap. Alas.

    • amluto 2 hours ago
      I wonder if you can get most of the benefit BYOB with a much simpler API:

          for await (const chunk of stream) {
              // process the chunk
              stream.returnChunk(chunk);
          }
      
      This would be entirely optional. If you don’t return the chunk and instead let GC free it, you get the normal behavior. If you do return it, then the stream is permitted to return it again later.

      (Lately I’ve been thinking that a really nice stream or receive API would return an object with a linear type so that you must consume it and possibly even return it. This would make it impossible to write code where task cancellation causes you to lose received data. Sadly, mainstream languages can’t do this directly.)

  • murmansk 2 hours ago
    For gods sake, finally, somebody have said this!
  • ralusek 1 hour ago
    I tinkered with an alternative to stream interfaces:

    https://github.com/ralusek/streamie

    allows you to do things like

        infiniteRecords
        .map(item => doSomeAsyncThing(item), { concurrency: 5 });
    
    And then because I found that I often want to switch between batching items vs dealing with single items:

        infiniteRecords
        .map(item => doSomeAsyncSingularThing(item), { concurrency: 5 })
        .map(groupOf10 => doSomeBatchThing(groupsOf10), { batchSize: 10 })
        // Can flatten back to single items
        .map(item => backToSingleItem(item), { flatten: true });
  • user3939382 2 hours ago
    “ The Streams Standard was developed between 2014 and 2016 with an ambitious goal to provide "APIs for creating, composing, and consuming streams of data that map efficiently to low-level I/O primitives." Before Web streams, the web platform had no standard way to work with streaming data.”

    This is what UDP is for. Everything actually has to be async all the way down and since it’s not, we’ll just completely reimplement the OS and network on top of itself and hey maybe when we’re done with that we can do it a third time to have the cloud of clouds.

    The entire stack we’re using right down to the hardware is not fit for purpose and we’re burning our talent and money building these ever more brittle towering abstractions.

    • afavour 2 hours ago
      UDP is a protocol, not an API
      • mlhpdx 1 hour ago
        True. But it’s also true that trying to shoehorn every use case into TCP streams is counter productive.

        A stream API can layer over UDP as well (reading in order of arrival with packet level framing), but such a stream would a bit weird and incompatible with many stream consumers (e.g. [de]compression). A UDP API is simpler and more naturally event (packet) oriented. The concepts don’t mix well.

        Still, it would be nice if they browser supported a UDP API instead of the weird and heavy DTLS and QUIC immitations.

    • delaminator 2 hours ago
      We're too busy building products while waiting for the perfect system to arrive.
      • user3939382 1 hour ago
        I’m building everything from first principles, I’m not climbing the exponential curve with some billionaire that has to finance it.
        • delaminator 1 hour ago
          I really doubt you are. you're not visiting the transistor shop every time you want to build a react component
          • user3939382 54 minutes ago
            Good thing your confidence is a soft requirement :)
  • animanoir 1 hour ago
    [dead]
  • Feathercrown 2 hours ago
    [flagged]