Command
other (runtime behaviour of @angular/ssr)
Is this a regression?
No. The 'drain' wait has been in writeResponseToNodeResponse() since it was introduced, and the file has not changed since March 2025.
Description
writeResponseToNodeResponse() waits for 'drain' with no other way out:
const canContinue = (destination as ServerResponse).write(value);
if (canContinue === false) {
await new Promise<void>((resolve) => destination.once('drain', resolve));
}
A destroyed response never emits 'drain', so when the client goes away while the loop is parked there, the returned promise does not settle, and the documented "a promise that resolves once the streaming operation is complete" no longer holds. The destination.on('close') handler above cancels the reader, which covers the await reader.read() case but not this one.
In the server generated by ng add @angular/ssr the middleware is angularApp.handle(req).then((response) => response ? writeResponseToNodeResponse(response, res) : next()).catch(next), so for such a request neither branch ever runs, silently. The Hono example in the JSDoc of createNodeRequestHandler awaits this function too, so the same stall applies to the pattern documented for other frameworks. Only server rendered routes reach this code, since the default RenderMode.Prerender scaffold serves its routes statically.
I looked for retained memory and did not find any: 60 aborted requests did not grow RSS beyond what 60 completed ones do, so this is about the contract and about anything a caller does after the await, not about a leak.
Minimal Reproduction
ng new demo --ssr, then set renderMode: RenderMode.Server in app.routes.server.ts
- render a body larger than the socket buffer, for example
protected readonly big = 'x'.repeat(8 * 1024 * 1024); shown in the template
- add
.then(() => console.log('settled')) to the generated middleware chain
npm run build, start the server, then connect a client that reads about 32 KB, stops reading, and destroys the connection three seconds later
The loop enters the drain wait and nothing follows: no drain, no settle, no rejection, still nothing eight seconds after the disconnect. A client that reads the whole 8 MB body settles normally, so the happy path is unaffected.
Exception or Error
None. Nothing is thrown and nothing is logged, which is probably why this has gone unnoticed.
Your Environment
@angular/ssr 22.1.2, @angular/core 22.1.0, Node 24.18.1, Linux x64. Tested over HTTP/1.1 with the generated Express server, plus an Http2ServerResponse mirror of the loop for HTTP/2.
Anything else relevant?
Over HTTP/2 the wait is reached far sooner, because the initial flow control window is 64 KB: in the mirrored loop it parked after 128 KB of body, and the response emitted only close.
Two details for whoever fixes it, both of which caught me out first:
- Adding listeners alone is not enough. Once
close has fired, a freshly attached once('close') never runs, so the next write() parks again. The loop has to stop as well.
- The writable flags differ between the two response types. Right after
close on an Http2ServerResponse I measured destroyed=undefined, closed=undefined, writableEnded=false and writable=true, while response.stream.destroyed was true. A shared guard needs to cover both shapes.
Command
other (runtime behaviour of
@angular/ssr)Is this a regression?
No. The
'drain'wait has been inwriteResponseToNodeResponse()since it was introduced, and the file has not changed since March 2025.Description
writeResponseToNodeResponse()waits for'drain'with no other way out:A destroyed response never emits
'drain', so when the client goes away while the loop is parked there, the returned promise does not settle, and the documented "a promise that resolves once the streaming operation is complete" no longer holds. Thedestination.on('close')handler above cancels the reader, which covers theawait reader.read()case but not this one.In the server generated by
ng add @angular/ssrthe middleware isangularApp.handle(req).then((response) => response ? writeResponseToNodeResponse(response, res) : next()).catch(next), so for such a request neither branch ever runs, silently. The Hono example in the JSDoc ofcreateNodeRequestHandlerawaits this function too, so the same stall applies to the pattern documented for other frameworks. Only server rendered routes reach this code, since the defaultRenderMode.Prerenderscaffold serves its routes statically.I looked for retained memory and did not find any: 60 aborted requests did not grow RSS beyond what 60 completed ones do, so this is about the contract and about anything a caller does after the await, not about a leak.
Minimal Reproduction
ng new demo --ssr, then setrenderMode: RenderMode.Serverinapp.routes.server.tsprotected readonly big = 'x'.repeat(8 * 1024 * 1024);shown in the template.then(() => console.log('settled'))to the generated middleware chainnpm run build, start the server, then connect a client that reads about 32 KB, stops reading, and destroys the connection three seconds laterThe loop enters the drain wait and nothing follows: no drain, no settle, no rejection, still nothing eight seconds after the disconnect. A client that reads the whole 8 MB body settles normally, so the happy path is unaffected.
Exception or Error
None. Nothing is thrown and nothing is logged, which is probably why this has gone unnoticed.
Your Environment
@angular/ssr22.1.2,@angular/core22.1.0, Node 24.18.1, Linux x64. Tested over HTTP/1.1 with the generated Express server, plus anHttp2ServerResponsemirror of the loop for HTTP/2.Anything else relevant?
Over HTTP/2 the wait is reached far sooner, because the initial flow control window is 64 KB: in the mirrored loop it parked after 128 KB of body, and the response emitted only
close.Two details for whoever fixes it, both of which caught me out first:
closehas fired, a freshly attachedonce('close')never runs, so the nextwrite()parks again. The loop has to stop as well.closeon anHttp2ServerResponseI measureddestroyed=undefined,closed=undefined,writableEnded=falseandwritable=true, whileresponse.stream.destroyedwastrue. A shared guard needs to cover both shapes.