Skip to content

@angular/ssr: writeResponseToNodeResponse() does not settle when the client disconnects while the response is backpressured #33719

Closed

Description

@ondraulehla

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

  1. ng new demo --ssr, then set renderMode: RenderMode.Server in app.routes.server.ts
  2. render a body larger than the socket buffer, for example protected readonly big = 'x'.repeat(8 * 1024 * 1024); shown in the template
  3. add .then(() => console.log('settled')) to the generated middleware chain
  4. 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.

Activity

  1. added this to the needsTriage milestone on Jul 31, 2026
  2. added 3 commits that reference this issue on Jul 31, 2026
    60b6f6b
    2cd9689
    e4a510a
  3. self-assigned this
    on Jul 31, 2026
  4. added theissue type on Jul 31, 2026
  5. added 2 commits that reference this issue on Jul 31, 2026
    810cff1
    b74eef8
  6. added a commit that references this issue on Jul 31, 2026
    6af906c
  7. angular-automatic-lock-bot commented on Sep 20, 2026

    @angular-automatic-lock-bot

    This issue has been automatically locked due to inactivity.
    Please file a new issue if you are encountering a similar or related problem.

    Read more about our automatic conversation locking policy.

    This action has been performed automatically by a bot.

  8. locked and limited conversation to collaborators on Sep 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions