node:http2 client: 'end' is lost on empty-body (204/HEAD/304) responses when listeners attach after 'response'
Summary (corrected 2026-09-16)
The original report above is partly inaccurate. After testing with the official Deno 2.9.6 binary (same version the reporter's affected app is built with) the behaviour is:
responseis emitted for a 204 / empty-body response.endis emitted, but is only observable if thedata/endlisteners are attached before theresponseevent is delivered. A consumer that attaches them afterresponse— e.g.await-ing a promise that resolves onresponseand then piping the stream — never getsend, even thoughstream.readableEnded === trueandstream.destroyed === true.
This is method-independent and affects any response whose body is empty and whose END_STREAM is carried on the HEADERS frame: 204, 200 without a body, HEAD, 304. Responses with a body are fine (the buffered body defers end until it is read).
Node emits end in this situation.
Minimal repro
Upstream (Node here, but any H2 server) replies 204:
// upstream.mjs
import { createSecureServer } from "node:http2";
import { readFileSync } from "node:fs";
createSecureServer(
{ key: readFileSync("./key.pem"), cert: readFileSync("./cert.pem") },
(req, res) => {
req.resume();
req.on("end", () => { res.stream.respond({ ":status": 204 }); res.stream.end(); });
},
).listen(18111);
Client:
// client.mjs
import http2 from "node:http2";
const session = http2.connect("https://127.0.0.1:18111", { rejectUnauthorized: false });
const stream = session.request({ ":method": "POST", ":path": "/events" });
stream.end('{"a":1}');
await new Promise((r) => stream.on("response", r)); // 'response' fires fine
// Attach the body listeners only now (a common streaming-consumer pattern).
stream.on("data", () => {});
stream.on("end", () => { console.log("end"); process.exit(0); });
setTimeout(() => {
console.log("NO end", { readableEnded: stream.readableEnded, destroyed: stream.destroyed });
process.exit(1);
}, 3000);
| listener attach point | Node | Deno 2.9.6 |
|---|---|---|
before end() (early) |
end |
end |
synchronously inside the response handler (inside) |
end |
end |
after awaiting response (await) |
end |
no end (readableEnded=true, destroyed=true) |
deno run -A client.mjs prints NO end { readableEnded: true, destroyed: true }; node client.mjs prints end.
Probable cause
For a client stream onSessionHeaders defers the response event with process.nextTick, and the polyfill calls stream.push(null) for endOfStream. In addition, the native side treats END_STREAM on a HEADERS/PUSH_PROMISE frame as a read EOF: on_frame_recv_callback calls handle_data_end_stream() right after handle_headers_frame() (ext/node/ops/http2/session.rs), which invokes onread(undefined, UV_EOF). onStreamRead then runs stream.push(null); stream.read(0);. On a zero-length, already-ended readable, read(0) schedules endReadable and end is emitted on the next tick — before the consumer's promise continuation runs — so the event is lost.
Node does not invoke OnStreamRead for END_STREAM carried on HEADERS, so its end is deferred until the stream is actually read.
Real-world impact
meddle (an HTTP proxy) resolves its H2 proxy request on response and then pipes the stream (it buffers until end to run response plugins). Every 204/empty-body upstream response hangs it. The Deno binary build currently forces HTTPS upstreams through HTTP/1.1 as a workaround.
Related
- #35947 / PR #35959 fix the separate "
databeforeresponse" ordering. That PR explicitly assumes the lone-null-EOF / no-body case is already ordered correctly, so this case is still broken after it.
Source: denoland/deno