denoland/deno · error
h2 stream closed
Error message
h2 stream closed
What it means
Write path of Deno's WebSocket-over-HTTP/2 transport: before sending DATA frames it polls the h2 send stream for capacity. poll_capacity() returning None means the stream is closed — typically a peer-sent RST_STREAM or stream teardown — and the write completes with ErrorKind::ConnectionReset and this message instead of panicking (added for deno #33953).
Source
Thrown at ext/websocket/stream.rs:118
// Zero-length write succeeds
if buf.is_empty() {
return Poll::Ready(Ok(0));
}
send.reserve_capacity(buf.len());
match ready!(send.poll_capacity(cx)) {
Some(Ok(_)) => {} // capacity reserved
Some(Err(e)) => {
return Poll::Ready(Err(std::io::Error::new(
ErrorKind::ConnectionReset,
e,
)));
}
None => {
// The h2 stream is closed (typically a peer-sent
// RST_STREAM). Surface as a write error instead of
// panicking on `capacity() == 0` below. See #33953.
return Poll::Ready(Err(std::io::Error::new(
ErrorKind::ConnectionReset,
"h2 stream closed",
)));
}
}
// We'll try to send whatever we have capacity for.
let size = std::cmp::min(buf.len(), send.capacity());
if size == 0 {
return Poll::Ready(Err(std::io::Error::new(
ErrorKind::WriteZero,
"no h2 capacity",
)));
}
let buf: Bytes = Bytes::copy_from_slice(&buf[0..size]);
let len = buf.len();
// TODO(mmastrac): surface the h2 error?View on GitHub (pinned to 9ad36f7a2c)
Solutions
- Treat any write error here as fatal for the socket: close the WebSocket and reconnect with backoff.
- Stop sending once close starts: guard ws.send() with readyState === OPEN and queue/drop during CLOSING.
- Send periodic app-level heartbeats so dead streams are detected via error or timeout instead of writes failing much later.
- If you control the LB/proxy, raise stream idle timeouts or switch the endpoint to WebSocket-over-HTTP/1.1.
Example fix
// before
ws.on("message", m => ws.send(ack(m))); // send may hit RST_STREAM during teardown
// after
function safeSend(ws, data) {
if (ws.readyState !== WebSocket.OPEN) return false;
try { ws.send(data); return true; }
catch (e) { // ConnectionReset: h2 stream closed
ws.close();
scheduleReconnect();
return false;
}
} Defensive patterns
Strategy: try-catch
Validate before calling
const isOpen = (ws) => ws.readyState === WebSocket.OPEN; // guard before every send
Type guard
const isFatalWsWriteError = (e) => e instanceof Error && (/h2 stream closed/.test(e.message) || /ConnectionReset|ECONNRESET/.test(String(e.code ?? e.cause ?? e.message)));
Try / catch
try { ws.send(frame); } catch (e) { if (/h2 stream closed/.test(String(e))) { ws.close(); scheduleReconnect(); } else throw e; } Prevention
- Maintain an `open` flag cleared in onclose/onerror and check it before every send.
- Queue outbound frames during reconnect instead of writing to dying sockets.
- Use heartbeats to detect dead h2 streams early.
- Raise idle/stream timeouts on LBs fronting WS-over-h2 endpoints.
When it happens
Trigger: Calling ws.send() on a WebSocket riding HTTP/2 after the peer reset the stream or the connection entered GOAWAY teardown: server-side rejection/cleanup, proxy killing the stream, or sending after the remote already answered with a close/RST.
Common situations: Long-lived WS-over-h2 connections behind load balancers that reap idle streams; sending during teardown races where onclose has not fired yet; mobile clients dropping and proxies resetting streams; ALPN-negotiated h2 endpoints (CDNs) that time streams out aggressively.
Related errors
- no h2 capacity
- ERR_HTTP2_STATUS_101
- Already upgraded
- Already closed
- Unsupported 'alpnProtocols' option provided. 'h2' and 'http/
AI-assisted analysis of denoland/deno@9ad36f7a2c (2026-08-20).
Data as JSON: /api/errors/3c47a07520ce305e.
Report an issue: GitHub.