DocsGuidesWebSockets & SSE

WebSockets & SSE

WebSocket upgrades and SSE streams are proxied end to end, over the same yamux stream that was opened for the original request, for as long as the connection stays open. Both the edge and the agent configure their httputil.ReverseProxy with FlushInterval: -1, so bytes reach the client as soon as they arrive instead of being batched.

WebSocket passthrough#

Go's stdlib httputil.ReverseProxy handles Connection: Upgrade on its own — hijacking the connection, completing the 101handshake, and copying bytes bidirectionally for the rest of the socket's life. That passthrough is now covered end to end by the e2e suite: a real 101 handshake with a multi-frame echo and a clean close.

WebSocket over HTTP/2#

Not enabled

WS-over-h2 (RFC 8441 extended CONNECT) is not enabled on the edge. Browsers already fall back to HTTP/1.1 for WebSocket upgrades automatically, so this is a non-issue in practice — just don't expect an h2 WebSocket frame in the wild.

Server-Sent Events#

SSE is plain HTTP streaming, not an upgrade — the response keeps status 200 and a Content-Type of text/event-stream, with bytes flushed to the client as they're written on the local app side. This is also covered by the e2e suite: a real SSE response with incremental delivery.

The upgrade budget#

A long-lived connection would otherwise hold a normal request slot — one of the global and per-session in-flight semaphores described in Limits & timeouts — for its entire life, which would starve ordinary HTTP requests behind it. The edge avoids that by watching for a long-lived response — 101 Switching Protocols, or a 200 whose Content-Type is text/event-stream— and moving it onto a separate upgrade budget the moment it's detected, releasing the request-scoped slot back to the pool.

-upgrade-inflightintdefault: 256
Global concurrent upgraded (WebSocket/SSE) connection cap, across every tunnel on the edge.
-per-session-upgradeintdefault: 32
Per-session concurrent upgraded connection cap, beneath the global one, so one tunnel can't monopolize the upgrade budget.

When the upgrade budget is full#

A connection that can't be reclassified because the upgrade budget is exhausted is rejected, not silently downgraded or left on the request slot: the edge returns 503 Service Unavailable and increments ngstoned_rejected_upgrade_cap_total. ngstoned_active_upgrades is a live gauge of currently open upgraded connections, visible on the expvar ops endpoint.

Timeout behavior#

Long-lived, not unlimited

Streams don't live forever — several independent timeouts still apply. None of them are tuned to cut a healthy, active stream short, but it's worth knowing exactly what each one does and doesn't cover.
WriteTimeoutdurationdefault: unset
The public server sets no WriteTimeout, so a live stream is never cut off mid-flight by the HTTP server itself.
IdleTimeoutdurationdefault: 120s
Applies to keep-alive idle time between requests on a reused connection — not to an active stream. An open WebSocket or SSE response is not idle.
ResponseHeaderTimeoutdurationdefault: 30s
Only bounds the time to the first response header. A 101or an SSE response's headers arrive immediately in practice, so this timeout is not in the way once the stream is established.
yamux StreamCloseTimeoutdurationdefault: 30m
Only affects a stream that has been half-closed in one direction — it forces the other side closed if it doesn't follow suit within the timeout. A live, bidirectional WebSocket never triggers it, since neither side has half-closed.

Access control applies too#

Source-IP allowlisting and HTTP basic auth are enforced beforethe upgrade happens, on the same request path as any other HTTP request. A gated tunnel's WebSocket and SSE traffic requires the same credentials or source IP as its plain HTTP traffic — there's no separate, unprotected path for upgrades. See Access control.

How they appear in the inspector#

The request inspector already understands WebSockets: an upgrade is finalized in the inspector at 101 time, not at close, so a long-lived connection shows up in the request list the instant it connects instead of staying invisible until the socket eventually closes. Rows for an open socket show WS in the method column and live byte counts in both directions in place of a duration.