Skip to content

A paced streaming response keeps one Promise for each chunk while write() returns true #407

Open

Description

@Mheaus

Hello, and thank you for @hono/node-server. We use it for the two Node.js services that stream our web radio.

I would like to report a memory growth that we found on those services, with a reproduction and a possible fix.

What happens

A streaming response whose source is paced keeps one Promise for each chunk it writes, for as long as write() returns true. Our audio streams do not end and never fill the socket, so the memory of each service grew until a listener disconnected (about 16 MB an hour in production, at about 41 chunks of 400 bytes a second).

I see the same growth with streamSSE (one event each millisecond). A source that produces as fast as it can does not show it: write() then returns false from time to time, and the chain settles.

Reproduction

https://github.com/Mheaus/hono-node-server-stream-promise-chain

npm install
npm start            # paced source
PACED=0 npm start    # source as fast as possible

The script serves one streaming route with Hono, reads it with fetch, and prints the number of live Promise objects every 20 000 chunks. It counts them with v8.queryObjects, which collects garbage first.

@hono/node-server 2.1.4, hono 4.12.31, Node.js 24.21.0, macOS arm64:

Chunks sent Live Promise objects Heap used
20 000 20 053 9.8 MiB
60 000 60 053 13.8 MiB
100 000 100 053 17.8 MiB

Node.js 20.20.2 and 22.23.3 show the same growth, and so does version 1.19.14. With PACED=0 the count stays between 105 and 207. The README has every number.

What I think the cause is

In writeFromReadableStreamDefaultReader (src/utils.ts), flow runs as a then callback and returns the promise of the next read:

} else {
  return reader.read().then(flow, handleStreamError)
}

A promise returned from a then callback makes the promise of that then wait for it (NewPromiseResolveThenableJob). While write() returns true, each chunk adds one promise to a chain. The chain settles only when the stream ends, when write() returns false, or on an error.

A possible fix

Start the next read without returning its promise. As far as I can see, nothing reads the value that flow returns, and the errors still reach handleStreamError:

-        return reader.read().then(flow, handleStreamError)
+        reader.read().then(flow, handleStreamError)

With this change built into the reproduction, the count stays at 63 from 20 000 to 100 000 chunks.

I read in the contribution guide of Hono that pull requests are for maintainers, so I have not opened one. The change is on a branch of my fork, with a test, in case it saves you time:

main...Mheaus:node-server:fix/stream-write-promise-chain

The test counts the live promises while a stream stays open. It fails on main with expected 2000 to be less than 200. format, lint, typecheck and the tests pass on Node.js 20, 22 and 24. One caveat: the test uses v8.queryObjects, which still prints an ExperimentalWarning on these versions. I will gladly change the approach or test something else if you prefer.

Thank you for your time.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions