Skip to content

Calling res.end() twice stalls follow-up HTTP request (drain event is missing) #36620

Closed
@kachkaev

Description

@kachkaev
  • Version: 14.0.0...14.15.3 (latest lts), 15.0.0...15.5.0 (latest stable)
  • Platform: macos / ubuntu / windows
  • Subsystem: N/A

What steps will reproduce the bug?

Originally, I noticed this behaviour in a Next.js app and it took me quite a while to drill down to the bottom of it. You can find a plain MWE here: https://github.com/kachkaev/node-http-response-double-end-call-breaking-drain-event (no NPM packages involved).

Since Node version 14.0.0, calling res.end() twice in a body-less response seems to be silencing the drain event in a follow-up HTTP request if it uses the same connection. This can happen in practice when redirecting a client to a heavy page and using compression package as middleware.

I understand that calling res.end() twice is a developer mistake, however it does not seem right to have to debug such a small oversight for more than two working days 😅 When using res.redirect(...) helper method in Next.js, it’s easy to forget that it’s not only doing res.writeHead(...) for you, but also calls res.end(). Seeing res.redirect(...); res.end() does not feel too wrong initially and there is no feedback from the server or the tooling to suggest that this involves res.end() being called twice.

Here are the reproduction steps from the server POV:

  1. A client establishes a connection and requests a page that results with a redirect:

    res.writeHead(302, { Location: "/another-page" });
    res.end();
    res.end(); // called twice intentionally
  2. The same client immediately comes back with another request, which is meant to return 200 and contain some payload.

    • If the size of the payload is small enough to fit a single res.write(...), all works fine.

    • If the payload involves res.write(...) === trueres.on("drain", () => {...}), the second request is never finished because the drain event is never invoked.

You can look into how compression is using res.write(...) + res.on("drain", ...) to find a practical example: https://github.com/expressjs/compression/blob/3fea81d0eaed1eb872bf3e0405f20d9e175ab2cf/index.js#L193-L218

How often does it reproduce? Is there a required condition?

I am able to silence the drain event quite reliably on any Node version ≥14.0.0 on any OS. See the GitHub Workflow for my MWE.

What is the expected behavior?

Ideally, I would expect the second res.end() to not produce any side effects or at least to give me a warning. All works fine in Node v13.14.0 and below.

What do you see instead?

I stumbled across some magic behaviour which took more than two days to investigate 😅

Additional information

The unwanted side effect from a double call to res.end() is negated:

  • if the follow-up request does not share the connection with the first (body-less) request (i.e. curl called twice);
  • or if res.write("") is added to the first (body-less) request

Both observations are included into GitHub Workflows within the MWE repo. I also tried playing with res.end("") as a replacement for res.write(""), but it did not help.

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-bugIssues with confirmed bugs.httpIssues or PRs related to the http subsystem.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions