You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
node:http ServerResponse doesn't emit close event #14697
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.
Reacted by Christian Benincasa, Front-end developer, Konrad, nitedani, Jan Wilhelm, Tommy D. Rossi, Trey Dev and bngrid
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
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:
curl --request GET --no-buffer http://localhost:3000
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.
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 1.3.14 (Linux x64), compared against Node v24.14.0
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';constrequire=createRequire(import.meta.url);constREQUESTS=Number(process.env.REQUESTS??2000);constCONCURRENCY=Number(process.env.CONCURRENCY??50);constABORT_MS=Number(process.env.ABORT_MS??10);// set high to disable abortsconstruntime=globalThis.Bun ? `Bun ${Bun.version}` : `Node ${process.version}`;constgc=()=>(globalThis.Bun ? Bun.gc(true) : global.gc?.());letstarted=0,ended=0;constsdk=newNodeSDK({spanProcessors: [{onStart(){started++;},onEnd(){ended++;},forceFlush: ()=>Promise.resolve(),shutdown: ()=>Promise.resolve()}],instrumentations: [newHttpInstrumentation()],});sdk.start();const{ createServer }=require('node:http');// require AFTER start so it gets patchedconstserver=createServer((req,res)=>{setTimeout(()=>{try{res.end('ok');}catch{}},60);});constsleep=(ms)=>newPromise((r)=>setTimeout(r,ms));server.listen(0,async()=>{consturl=`http://localhost:${server.address().port}/`;letsent=0;constworker=async()=>{while(sent<REQUESTS){sent++;constc=newAbortController();constt=setTimeout(()=>c.abort(),ABORT_MS);try{await(awaitfetch(url,{signal: c.signal})).text();}catch{}clearTimeout(t);}};awaitPromise.all(Array.from({length: CONCURRENCY},worker));for(leti=0;i<4;i++){gc();awaitsleep(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:httpServerResponse 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.
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.
Activity
chrisbenincasa commentedon Feb 15, 2025
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
heimskr commentedon Mar 7, 2025
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:
connerlphillippi commentedon Jul 22, 2025
As of Bun v1.2.19, this issue continues to occur.
itea-dev commentedon Oct 28, 2025
As of 1.3.1+89fa0f343 this bug still has not been fixed.
fix(node:http): emit close event on ServerResponse when client discon…
fix(node:http): emit close event on ServerResponse when socket closes
jan-wilhelm commentedon Mar 18, 2026
this is so brutal, prevents us from using bun in production.
EnzoAlbornoz commentedon Mar 23, 2026
Bumping this because that's a BIG issue for any streaming api.
bionboy commentedon Mar 30, 2026
My current workaround (using SSE in Next.js) is patching the
ServerResponse.writemethod to fail fast when a socket is closed by destroying the response.instrumentation.ts
Warning
Your mileage may very, and there should be more scoping logic here to minimize the area it touches.
fix(node:http): emit close event on ServerResponse when client discon…
http: emit ServerResponse 'close' on client disconnect
fix: cancel upstream fetch on client disconnect in Bun
feat: handle client stream disconnections with upstream cancellation …
SirBernardPhilip commentedon May 29, 2026
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'sServerResponsedoes 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 fromres.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 OpenTelemetryContext+ theIncomingMessage/Socket/ServerResponsegraph 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
@opentelemetry/[email protected],@opentelemetry/[email protected],@opentelemetry/[email protected]Repro 1 — pure
node:http, no OpenTelemetry (root cause)close-events.mjs:Output:
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:Output:
On Bun, none of the spans for disconnected requests are ever ended; on Node they all are. The never-ended spans retain the OTel
Contextand 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-httpends the server span in aresponse.on('close')handler. Because Bun'snode:httpServerResponsedoesn'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
ServerResponseshould emit'close'when the connection is terminated (including client disconnect), matching Node, so per-request cleanup listeners run.itea-dev commentedon May 30, 2026
@robobun open a PR for this issue
robobun commentedon Jul 22, 2026
This is fixed on
main(verified on1.4.0-canary.1+1498d7b77). With the repro above, aborting the client now prints both lines, matching Node.js:NodeHTTPServerSocket#onClosenow schedulesemitCloseNTfor the still-attached_httpMessagewhen the connection dies, so aServerResponsethat never finished still sees'close'. Duplicates #29219 / #30571 (same fix, all closed).Please reopen if you still see this on a current canary.
fix: cancel upstream fetch on client disconnect in Bun
feat: handle client stream disconnections with upstream cancellation …