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

  1. Treat any write error here as fatal for the socket: close the WebSocket and reconnect with backoff.
  2. Stop sending once close starts: guard ws.send() with readyState === OPEN and queue/drop during CLOSING.
  3. Send periodic app-level heartbeats so dead streams are detected via error or timeout instead of writes failing much later.
  4. 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

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


AI-assisted analysis of denoland/deno@9ad36f7a2c (2026-08-20). Data as JSON: /api/errors/3c47a07520ce305e. Report an issue: GitHub.