stream: skip unobserved 'readable' emission at EOF - #65749
Open
mcollina wants to merge 1 commit into
Open
Conversation
When a stream reaches its end via push(null) while nobody is observing it (no 'readable' listener, not flowing, no pending readable need), do not schedule the deferred 'readable' emission: it fires into the void and costs a tick. Record that the end-of-stream notification is owed instead; attaching a 'readable' listener later emits it, and read() and resume() reach the end of the stream on their own. As a side effect, a 'readable' listener attached more than a tick after an unobserved end now receives the owed 'readable' before 'end', where it previously received only 'end'. For an HTTP server this removes one nextTick and one dead emit per request whose body the handler never reads. Signed-off-by: Matteo Collina <hello@matteocollina.com>
Collaborator
|
Review requested:
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #65749 +/- ##
==========================================
- Coverage 90.05% 89.99% -0.06%
==========================================
Files 754 757 +3
Lines 255722 257758 +2036
Branches 48314 48889 +575
==========================================
+ Hits 230281 231971 +1690
- Misses 16555 16856 +301
- Partials 8886 8931 +45
🚀 New features to boost your workflow:
|
ronag
approved these changes
Sep 2, 2026
Collaborator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When a stream reaches its end via
push(null)while nobody is observing it — no'readable'listener, not flowing, no pending readable need — the deferred'readable'emission fires into the void one tick later, costing anextTickand a deademitper stream. This happens for every HTTP request whose body the handler never reads (every body-less request).This change skips scheduling in that case and records that the end-of-stream notification is owed (a new readable-state bit). Attaching a
'readable'listener later redeems it — the listener receives'readable'and then'end'in the usual order — andread()/resume()/'data'reach the end of the stream on their own, as they already did. A consumingread()clears the owed notification.One deliberate behavior change, covered by the added test: a
'readable'listener attached more than a tick after an unobserved end now receives the owed'readable'before'end', where it previously received only'end'(the emission it would have needed had already fired with no listeners attached). This matches the documented behavior that'readable'is also emitted once the end of the stream is reached.For a hello-world HTTP server this removes one
nextTick(7 → 6 per request on main) and one dead emit (9 → 8) per request. Complements the teardown work in #65732; combined they bring the request lifecycle to 4 ticks/request.benchmark/compare.js --runs 12 --filter readable streamsshows no statistically significant change across all 14 benchmarks.test/parallel/test-stream*,test-http*,test-https*,test-net-*,test-tls-*,test-zlib*, fs stream, readline and child_process suites all pass (1722 tests).AI assisted, humanly reviewed