Skip to content

node:http ServerResponse doesn't emit close event #14697

Closed

Description

@cyco130

What version of Bun is running?

1.1.31+e448c4cc3

What platform is your computer?

Darwin 23.6.0 arm64 arm

What steps can reproduce the bug?

import { createServer } from "node:http";

createServer((req, res) => {
  req.once("close", () => {
    console.log("Request closed");
  });

  res.once("close", () => {
    console.log("Response closed");
  });
}).listen(3000);

Run the above script and in a separate terminal run curl:

curl --request GET --no-buffer http://localhost:3000

Then stop it with Ctrl+C.

What is the expected behavior?

Node (correctly) outputs both messages:

Response closed
Request closed

What do you see instead?

Bun only outputs:

Request closed

which means that ServerResponse doesn't emit the close event.

Additional information

This can cause resource leaks when using server-sent events and similar long polling responses.

Activity

  1. chrisbenincasa commented on Feb 15, 2025

    @chrisbenincasa

    This is a show stopper bug for any server that sends infinite streams (e.g. live stream video or audio) ... there is simply no way to know when the client stops listening

  2. heimskr commented on Mar 7, 2025

    @heimskr
    Contributor

    In PR #17093, which overhauls much of node:http, I'm seeing both "Response closed" and "Request closed" messages. Please try it yourself and let me know how it goes:

    $ bunx bun-pr 17093
    $ bun-17093 run ...
  3. connerlphillippi commented on Jul 22, 2025

    @connerlphillippi
    Member

    As of Bun v1.2.19, this issue continues to occur.

    bun a.js
    Request closed
    ^C
    node a.js
    Response closed
    Request closed
    curl --request GET --no-buffer http://localhost:3000
  4. itea-dev commented on Oct 28, 2025

    @itea-dev

    As of 1.3.1+89fa0f343 this bug still has not been fixed.

  5. added a commit that references this issue on Oct 28, 2025
    598012a
  6. added a commit that references this issue on Feb 20, 2026
    0cae148
  7. jan-wilhelm commented on Mar 18, 2026

    @jan-wilhelm

    this is so brutal, prevents us from using bun in production.

  8. EnzoAlbornoz commented on Mar 23, 2026

    @EnzoAlbornoz

    Bumping this because that's a BIG issue for any streaming api.

  9. bionboy commented on Mar 30, 2026

    @bionboy

    My current workaround (using SSE in Next.js) is patching the ServerResponse.write method to fail fast when a socket is closed by destroying the response.

    instrumentation.ts

    export async function register() {
      // ...
      const http = await import("node:http");
      type ServerResponse = InstanceType<typeof http.ServerResponse>;
      const _origWrite = http.ServerResponse.prototype.write;
      http.ServerResponse.prototype.write = function (
        this: ServerResponse,
        chunk: unknown,
        ...rest: unknown[]
      ): boolean {
        if (this.socket?.destroyed && !this.destroyed) {
          this.destroy();
          return false;
        }
        return (_origWrite as any).call(this, chunk, ...rest);
      };
      // ...
    }

    Warning

    Your mileage may very, and there should be more scoping logic here to minimize the area it touches.

  10. added a commit that references this issue on Mar 31, 2026
    bc8da62
  11. SirBernardPhilip commented on May 29, 2026

    @SirBernardPhilip

    Hey! Is there any way this could be fixed? It's also a production issue for us and it causes memory leaks when paired with OTEL instrumentation. If it speeds up solving this I created some simple repors (obv, with the help of Claude Code).

    What happens

    On Bun, node:http's ServerResponse does not emit the 'close' event when a client disconnects before the response finishes. Node emits it. This breaks any code that releases per-request resources from res.on('close') — most notably @opentelemetry/instrumentation-http, which ends the HTTP server span from that listener. The result is a real production memory leak: every aborted/disconnected request permanently leaks its span (and the OpenTelemetry Context + the IncomingMessage/Socket/ServerResponse graph those transitively retain).

    This is likely the same underlying defect as #14697 / #7716, but reported here with the concrete OpenTelemetry impact and a minimal, runtime-comparative reproduction, since this is how it bites real apps (Datadog/OTel tracing on Bun).

    Environment

    bun add @opentelemetry/[email protected] @opentelemetry/[email protected] @opentelemetry/[email protected]
    

    Repro 1 — pure node:http, no OpenTelemetry (root cause)

    close-events.mjs:

    import { createServer } from 'node:http';
    const runtime = globalThis.Bun ? `Bun ${Bun.version}` : `Node ${process.version}`;
    const fired = { 'req.aborted': 0, 'req.close': 0, 'res.close': 0, 'res.finish': 0 };
    let handled = 0;
    const server = createServer((req, res) => {
      handled++;
      req.on('aborted', () => fired['req.aborted']++);
      req.on('close',   () => fired['req.close']++);
      res.on('close',   () => fired['res.close']++);
      res.on('finish',  () => fired['res.finish']++);
      setTimeout(() => { try { res.end('ok'); } catch {} }, 60); // slow response
    });
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    server.listen(0, async () => {
      const url = `http://localhost:${server.address().port}/`;
      for (let i = 0; i < 500; i += 50) {
        await Promise.all(Array.from({ length: 50 }, async () => {
          const c = new AbortController();
          setTimeout(() => c.abort(), 10);           // client disconnects before the 60ms response
          try { await (await fetch(url, { signal: c.signal })).text(); } catch {}
        }));
      }
      await sleep(800);
      console.log(`\n=== ${runtime} ===`);
      for (const [k, v] of Object.entries(fired)) console.log(`  ${k.padEnd(12)} fired: ${v} / ${handled}`);
      server.close(); process.exit(0);
    });

    Output:

    === Bun 1.3.14 ===
      req.aborted  fired: 450 / 450
      req.close    fired: 450 / 450
      res.close    fired: 0 / 450      <-- never fires on Bun
      res.finish   fired: 0 / 450
    
    === Node v24.14.0 ===
      req.aborted  fired: 55 / 55
      req.close    fired: 55 / 55
      res.close    fired: 55 / 55      <-- fires on Node
      res.finish   fired: 0 / 55
    

    res.on('close') fires on Node but never on Bun when the client disconnects mid-response.

    Repro 2 — real-world impact: OpenTelemetry spans never end (leak)

    repro.mjs:

    import { NodeSDK } from '@opentelemetry/sdk-node';
    import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
    import { createRequire } from 'node:module';
    const require = createRequire(import.meta.url);
    
    const REQUESTS = Number(process.env.REQUESTS ?? 2000);
    const CONCURRENCY = Number(process.env.CONCURRENCY ?? 50);
    const ABORT_MS = Number(process.env.ABORT_MS ?? 10); // set high to disable aborts
    const runtime = globalThis.Bun ? `Bun ${Bun.version}` : `Node ${process.version}`;
    const gc = () => (globalThis.Bun ? Bun.gc(true) : global.gc?.());
    
    let started = 0, ended = 0;
    const sdk = new NodeSDK({
      spanProcessors: [{ onStart() { started++; }, onEnd() { ended++; },
        forceFlush: () => Promise.resolve(), shutdown: () => Promise.resolve() }],
      instrumentations: [new HttpInstrumentation()],
    });
    sdk.start();
    
    const { createServer } = require('node:http'); // require AFTER start so it gets patched
    const server = createServer((req, res) => { setTimeout(() => { try { res.end('ok'); } catch {} }, 60); });
    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
    server.listen(0, async () => {
      const url = `http://localhost:${server.address().port}/`;
      let sent = 0;
      const worker = async () => { while (sent < REQUESTS) { sent++;
        const c = new AbortController(); const t = setTimeout(() => c.abort(), ABORT_MS);
        try { await (await fetch(url, { signal: c.signal })).text(); } catch {} clearTimeout(t); } };
      await Promise.all(Array.from({ length: CONCURRENCY }, worker));
      for (let i = 0; i < 4; i++) { gc(); await sleep(300); }
      console.log(`\n=== ${runtime} === STARTED:${started} ENDED:${ended} NEVER-ENDED:${started - ended}`);
      server.close(); process.exit(0);
    });

    Output:

    # Bun, clients disconnect mid-response:
    === Bun 1.3.14 === STARTED:1950 ENDED:0 NEVER-ENDED:1950     <-- every span leaks
    
    # Bun, requests allowed to complete (ABORT_MS=5000):
    === Bun 1.3.14 === STARTED:2000 ENDED:2000 NEVER-ENDED:0     <-- fine when completed
    
    # Node, clients disconnect mid-response:
    === Node v24.14.0 === STARTED:325 ENDED:325 NEVER-ENDED:0    <-- fine on Node
    

    On Bun, none of the spans for disconnected requests are ever ended; on Node they all are. The never-ended spans retain the OTel Context and the request object graph indefinitely → steadily growing memory under normal production traffic (clients that cancel, time out, or navigate away), eventually OOM.

    Root cause

    @opentelemetry/instrumentation-http ends the server span in a response.on('close') handler. Because Bun's node:http ServerResponse doesn't emit 'close' on client disconnect (Repro 1), the span (and everything it retains) is never released (Repro 2). Completed requests are unaffected, which is why only a fraction of requests leak and why it's slow/silent in production.

    Expected

    ServerResponse should emit 'close' when the connection is terminated (including client disconnect), matching Node, so per-request cleanup listeners run.

  12. itea-dev commented on May 30, 2026

    @itea-dev

    @robobun open a PR for this issue

  13. robobun commented on Jul 22, 2026

    @robobun
    Collaborator

    This is fixed on main (verified on 1.4.0-canary.1+1498d7b77). With the repro above, aborting the client now prints both lines, matching Node.js:

    Response closed
    Request closed
    

    NodeHTTPServerSocket#onClose now schedules emitCloseNT for the still-attached _httpMessage when the connection dies, so a ServerResponse that never finished still sees 'close'. Duplicates #29219 / #30571 (same fix, all closed).

    Please reopen if you still see this on a current canary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmed bugWe can reproduce this issuenode:http

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions