Streaming, WebSocket & Realtime Gateways
Every assumption an API gateway makes about a request — that it is short, that it has a body of bounded size, that policy can be evaluated once before forwarding, that a worker is free again in milliseconds — stops being true when the connection is long-lived. A WebSocket that stays open for four hours, a server-sent event stream that trickles a message a minute, and a bidirectional gRPC channel are all requests as far as the proxy’s accounting is concerned, and they consume a slot for their whole lifetime. This topic covers what actually changes: the connection budget, where policy can still be enforced, how idle timeouts interact with heartbeats, and what a deploy does to clients that were mid-stream. It sits under API gateway fundamentals and architecture, and it revisits several assumptions made in scaling limits and capacity planning that only hold for short requests.
Architectural baseline
A long-lived connection is a held resource, not a completed request. Throughput planning for request-response traffic works in requests per second against a service time of milliseconds. Streaming planning works in concurrent connections against a memory cost per connection and a hard ceiling on file descriptors. The two models share almost no arithmetic, and a gateway sized for one will fail in a way that looks inexplicable under the other.
Policy is enforced at the start, or not at all. Middleware runs when the request is admitted. After an upgrade or after the response headers of a stream are sent, there is no further per-request hook, so quotas, payload validation and authorisation decisions have to be made up front and re-checked, if at all, by the application that owns the socket. This is the single largest architectural consequence and it is not a limitation of any particular product.
Three transports, three failure modes. WebSocket is a protocol upgrade that leaves HTTP behind. Server-sent events remain an ordinary HTTP response that never ends. gRPC streaming is HTTP/2 frames multiplexed over one connection. They are often discussed together because they solve the same problem, but they break differently, and buffering behaviour is where they diverge most.
What holding a connection actually costs
The number that matters is not requests per second, it is concurrent connections multiplied by the per-connection memory, checked against the process file-descriptor limit.
Per connection, expect on the order of 16–64 KiB of proxy buffers, plus whatever the upstream holds. A node with 4 GiB available to the proxy and 32 KiB per connection tops out near 130,000 connections before memory, and rather earlier than that if the file-descriptor limit was left at its default. Both limits are worth setting explicitly rather than discovering.
# Envoy 1.32+ — bound the connection count deliberately
static_resources:
listeners:
- name: realtime
address: { socket_address: { address: 0.0.0.0, port_value: 8443 } }
per_connection_buffer_limit_bytes: 32768 # cap per-connection memory
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: realtime
upgrade_configs:
- upgrade_type: websocket # allow the upgrade at all
stream_idle_timeout: 0s # disable the per-stream idle timer
common_http_protocol_options:
idle_timeout: 3600s # connection-level ceiling
max_connection_duration: 14400s # force reconnection every 4 h
route_config:
virtual_hosts:
- name: realtime
domains: ["*"]
routes:
- match: { prefix: "/ws" }
route:
cluster: realtime_upstream
timeout: 0s # no overall request timeout
idle_timeout: 300s # but do reap dead sockets
overload_manager:
resource_monitors:
- name: "envoy.resource_monitors.global_downstream_max_connections"
typed_config:
"@type": type.googleapis.com/envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig
max_active_downstream_connections: 120000
Setting timeout: 0s is mandatory for a stream and dangerous on its own: it removes the only bound on how long a request may run. Pair it with a route-level idle_timeout so a connection that has stopped exchanging bytes is still reaped, and with max_connection_duration so no connection outlives a certificate rotation or a config generation indefinitely.
Where policy can still be enforced
The middleware chain runs once. Everything you want to enforce has to happen in that window, which changes how quotas and authorisation are designed for realtime endpoints.
Rate limiting therefore targets the handshake. A limit of a few connections per minute per consumer is both effective and cheap, and it is the control that stops a reconnect storm after a deploy from being indistinguishable from an attack. The mechanics are the same as in rate limiting and throttling strategies; only the key changes, from request to connection attempt.
# Kong 3.x — limit connection attempts, since messages cannot be counted
services:
- name: realtime
url: http://realtime.internal:8080
read_timeout: 3600000 # ms; the socket must be allowed to idle
write_timeout: 3600000
routes:
- name: ws
paths: ["/ws"]
protocols: ["http", "https"]
plugins:
- name: rate-limiting
config:
minute: 12 # connection attempts per consumer per minute
policy: redis
fault_tolerant: true
- name: jwt
config:
claims_to_verify: ["exp"]
Idle timeouts and heartbeats
The most common realtime incident is a connection that both ends believe is alive being reaped by something in the middle. Every hop has its own idle timer — client, gateway, cloud load balancer, upstream — and the effective timeout is the smallest of them.
WebSocket has protocol-level ping and pong frames for exactly this. Server-sent events have no framing, so the convention is a comment line — a line beginning with a colon — sent on a timer, which the client ignores but which keeps every intermediary’s idle timer fed. gRPC uses HTTP/2 PING frames, configured by keepalive parameters on both ends, and a mismatch there produces ENHANCE_YOUR_CALM errors rather than silent drops, which is at least easier to diagnose.
Comparative implementation
| Concern | WebSocket | Server-sent events | gRPC streaming |
|---|---|---|---|
| Transport | HTTP upgrade, then framed | ordinary HTTP response | HTTP/2 frames |
| Direction | bidirectional | server to client only | either or both |
| Proxy buffering risk | low, framing is explicit | high — response buffering breaks it | low, framing is explicit |
| Keepalive mechanism | ping and pong frames | comment lines on a timer | HTTP/2 PING |
| Reconnection | client-implemented | built into the browser API | client-implemented |
| Works through strict proxies | sometimes | almost always | needs end-to-end HTTP/2 |
| Per-message policy | none after upgrade | none after headers | none after headers |
Server-sent events are consistently underrated for one-directional feeds. They are ordinary HTTP, they survive intermediaries that mangle upgrades, and browsers reconnect automatically with a Last-Event-ID header. The single configuration requirement is disabling response buffering on every hop, which is covered in server-sent events vs WebSockets at the edge.
Scaling past one node
Once the connection count exceeds what a single node can hold, two things have to be decided that never come up with short requests.
The first is how connections are distributed. A plain round-robin load balancer distributes new connections evenly, which is not the same as distributing held connections evenly — a node that restarts comes back empty and stays comparatively empty until enough churn has occurred to refill it. Least-connection balancing fixes the steady state and makes a restarted node absorb the next burst, which is usually what you want. A maximum connection duration then provides the churn that keeps the distribution honest over hours.
The second is whether the upstream needs affinity. A chat room, a collaborative document or any feature where connected clients must see each other’s messages requires either that related clients land on the same upstream instance, or a shared bus behind the instances. Affinity at the gateway is the tempting answer and the fragile one: it makes the routing decision dependent on state the gateway cannot rebuild after a restart, and it turns a single hot room into a hot node. A message bus behind stateless stream servers costs more to run and does not develop these failure modes, which is why almost every realtime platform ends up there eventually.
Both decisions interact with the capacity arithmetic. Affinity means a node cannot shed load by rejecting new connections, because the connections it must accept are determined by which room the client is joining rather than by how busy it is. Without affinity, a node at its connection ceiling can simply refuse and let the load balancer place the client elsewhere — which is the behaviour you want during a reconnect storm, and the reason a connection ceiling should reject cleanly rather than accept and degrade.
Observability for connections rather than requests
The dashboards that serve request-response traffic answer questions streams do not ask. Requests per second is near-constant and uninteresting; what matters is how many connections are open, how long they have been open, how many closed in the last minute and why, and how many bytes are moving in each direction. Age distribution in particular is the one to add first: a healthy fleet shows a broad spread of connection ages, while a spike of very young connections means something just closed a lot of them and every client came back at once.
Operational gotchas
A deploy is a reconnect storm. Draining a node closes every connection it holds, and every client reconnects within its backoff window — which for many client libraries is “immediately”. A fleet holding 100,000 connections across ten nodes sends 10,000 reconnections in a burst when one node restarts. Stagger drains, give clients jittered backoff, and cap connection attempts per consumer, using the same reasoning as tuning retry budgets to prevent thundering herd.
Load balancing happens once per connection, not per message. A client that connects during a low-traffic window keeps its node for hours regardless of how loaded that node becomes. Rebalancing requires closing connections, so a maximum connection duration is a load-distribution mechanism as much as a security one.
Access logs are written at connection close. An hours-long connection produces no log line until it ends, so a dashboard built on access logs shows nothing while thousands of streams are active. Emit a connection-open event, or a periodic gauge of active connections, or accept a blind spot exactly where you need visibility.
Health checks that open a connection and immediately close it will, at sufficient frequency, dominate your connection metrics and can trip connection-rate limits. Give probes their own route with its own limits.
Production configuration checklist
- Connection ceiling set explicitly per node, below both the file-descriptor and memory limits
- Per-connection buffer limit configured, not left at the default
- Overall request timeout disabled on streaming routes, with an idle timeout still in place
-
max_connection_durationset so no connection outlives a certificate or config generation - Heartbeat interval below the smallest idle timer in the whole path, verified by measurement
- Connection-attempt rate limit per consumer, sized for a reconnect storm
- Active-connection gauge exported, since access logs only appear at close
- Drain procedure staggers node restarts rather than restarting in parallel
FAQ
Why does a streaming route need timeout set to zero?
Because the overall request timeout counts from when the request was accepted, and a stream is one request that lasts for hours. Leaving the default in place closes every stream at that boundary regardless of how healthy it is. Disabling it removes the only bound on request duration, so always pair it with an idle timeout that reaps sockets exchanging no bytes, and a maximum connection duration that forces periodic reconnection.
Can I rate limit individual WebSocket messages at the gateway?
Not with standard middleware. After the upgrade completes the proxy is relaying frames and no per-request hook runs, so there is nothing to count. Rate limit connection attempts per consumer instead, which is both cheap and effective, and enforce per-message limits in the application that owns the socket if you need them.
Why do my streams die after exactly 60 seconds?
A timer somewhere in the path is shorter than your heartbeat interval, and it is usually not the gateway. Client libraries, cloud load balancers and upstream servers each have their own idle timeout, and the effective value is the smallest of them. Find every hop, take the minimum, and set the heartbeat comfortably below it — a ping every 25 seconds survives a 60-second timer.
Should I use WebSockets or server-sent events?
If the data only flows from server to client, server-sent events are usually the better choice: they are ordinary HTTP, they pass through intermediaries that mangle upgrades, and browsers reconnect automatically with a Last-Event-ID header. Choose WebSockets when the client genuinely needs to push messages back on the same connection, and be prepared to implement reconnection yourself.
Parent: API Gateway Fundamentals & Architecture
Related
- Proxying WebSockets Through Kong and Envoy — the upgrade path end to end, with the timeout settings that actually matter.
- Server-Sent Events vs WebSockets at the Edge — buffering, reconnection and which one survives a hostile intermediary.
- gRPC Streaming Keepalives and Timeouts — HTTP/2 keepalive negotiation and the errors a mismatch produces.
- Scaling Limits & Capacity Planning — the request-rate model this topic replaces for long-lived connections.