Repository navigation
A paced streaming response keeps one Promise for each chunk while write() returns true #407
Description
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 possibleThe 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