Envoy Gateway WebSocket Disconnects: Check the Idle Timeout

Tested on RHEL 10.2 (Coughlan)
Package kubectl v1.37.0
Envoy Gateway v1.9.2
ingress-nginx controller v1.15.1
Gateway API CRDs v1.6.1 (standard channel)
Applies to Any host with kubectl configured; any Kubernetes cluster that already runs Envoy Gateway
Assumes Envoy Gateway is already installed, an HTTPRoute sends the WebSocket path to the backend Service, and you can send traffic through the Gateway
Privilege Normal user
Scope Why a WebSocket drops before or after HTTP 101, how the documented 5 minute stream idle timeout differs from a route-level idle limit and from the HTTP request deadline, and how to tell a route miss, a policy rejection, an idle close, and an application close. Does not cover gRPC streaming.
Related guides Fix an Envoy Gateway 504
Migrate proxy-read-timeout to Envoy Gateway
Replace proxy-body-size on Envoy Gateway
Expose services with Ingress
Gateway API with HTTPRoute
Short on time?Beta Get the gist of this 12-minute read in seconds, then ask follow-up questions

A WebSocket starts as ordinary HTTP. The connection has two phases:

  1. HTTP handshake. The client sends GET /ws with Upgrade: websocket.
  2. Upgraded stream. The Gateway returns 101 Switching Protocols. Frames then flow in both directions.

Which check you run depends on whether the handshake finished:

  • No 101: look at routing, policy, and upgrade handling
  • 101, then a later drop: look at stream lifetime and who closed the socket

Envoy Gateway already has a stream idle timeout when you do not attach a route-level BackendTrafficPolicy:

  • The documented listener default is 5 minutes
  • A BackendTrafficPolicy targeting the HTTPRoute can set a route-specific streamIdleTimeout instead of relying on the listener default
  • A cloud load balancer, CDN, firewall, another proxy, or the backend can still close a WebSocket that Envoy would allow

Diagnose the disconnect first

Read the HTTP status before you change a timeout:

  • HTTP 404 before the expected WebSocket upgrade: first check whether the hostname and path match the HTTPRoute. If the route matches, check whether the backend itself returned the 404
  • HTTP 403: the request was rejected before a successful WebSocket upgrade. Check policies first, especially anything attached to this route
  • HTTP 101: the WebSocket handshake succeeded
  • HTTP 101, then a close frame (opcode 8): the application ended the socket
  • HTTP 101, then an empty read or a reset and no close frame: check idle timers and the network path
                    WebSocket does not stay connected
                                    |
                                    v
                      Did the handshake return 101?
                                    |
                ┌───────────────────┴───────────────────┐
                NO                                     YES
                |                                       |
                404: check route/backend    Close frame (opcode 8)?
                403: check policies                     |
                                                        |
                                            ┌───────────┴───────────┐
                                           YES                      NO
                                            |                       |
                                       application         stream idle timeout,
                                          close             load balancer, or
                                                             the network path

A handshake you can recognize looks like this:

text
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade

101 means routing and the WebSocket upgrade worked. A timeout change does not fix a 404 or a 403.


Does Envoy Gateway need special WebSocket headers?

No. A normal HTTPRoute accepts the standard HTTP/1.1 upgrade. You do not add Connection or Upgrade with a header modifier. The client still has to send a valid WebSocket handshake, including Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key, and Sec-WebSocket-Version.

This route is enough when the Gateway is already named eg and the backend Service is ws:

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: ws
spec:
  parentRefs:
  - name: eg
  hostnames:
  - app.example.com
  rules:
  - matches:
    - path:
        type: PathPrefix
        value: /ws
    backendRefs:
    - name: ws
      port: 80

On Envoy Gateway v1.9.2 that route did this:

  • It returned 101 and forwarded the backend's first frame
  • With no route-level stream idle policy, a silent connection stayed open past 25 seconds
  • That check is shorter than the documented 5 minute default, so it does not show the default firing

Configure the WebSocket idle timeout

streamIdleTimeout is how long a stream may sit with no upstream or downstream activity. The timeout does not measure how old the WebSocket connection is. A WebSocket can remain connected for hours as long as traffic keeps resetting the idle timer. Any frame in either direction resets the clock. This example uses 30 seconds so the reset is easy to picture. Use your own longest legitimate silence when you write the policy.

streamIdleTimeout is 30 seconds

     server sends a frame
               ↓
the timer returns to 30 seconds

the client sends a frame after 20 seconds
                    ↓
  the timer returns to 30 seconds again

no traffic for the full 30 seconds
                ↓
 Envoy treats the stream as idle

Two different idle fields show up in the API. Set the stream field for a WebSocket:

  • streamIdleTimeout is no activity on an open stream. The listener default is 5 minutes.
  • idleTimeout is a connection with no active request. The documented default is 1 hour. It is not this disconnect.

Attach the limit to the HTTPRoute with BackendTrafficPolicy:

  • Omit streamIdleTimeout and the route keeps the listener value
  • The 5 second setting below is only so a silent failure shows up quickly
  • A real application usually wants a longer quiet period
yaml
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: ws-idle
spec:
  targetRefs:
  - group: gateway.networking.k8s.io
    kind: HTTPRoute
    name: ws
  timeout:
    http:
      streamIdleTimeout: 5s

On v1.9.2 that 5 second policy closed the silent stream like this:

  • The upstream stream ended after 5.001 seconds
  • The client saw an empty read at 8.199 seconds, not a WebSocket close frame
  • Close handling continued after the timer fired, so the YAML duration is not the exact time a browser will notice

Why heartbeats keep the connection alive

Any traffic on the stream can reset the idle timer:

  • In this test the client sent a text message containing ping every 2 seconds
  • That message is application data, not a WebSocket Ping control frame (opcode 9)
  • Applications often send Ping and Pong control frames, or their own heartbeat messages
  • Either kind of traffic counts as activity

The timer only cares that bytes or frames are flowing. It does not care whether that activity is business data or a heartbeat.

With the 5 second policy still in place, those text messages kept the connection open past 20 seconds. The interval has to be shorter than every idle timer on the path:

  • the route streamIdleTimeout
  • the listener default, when you did not set a route value
  • a cloud load balancer, CDN, firewall, or other proxy in front of the Gateway

Raising only the Envoy value does not help when a shorter timer still sits in front of it.


How this differs from the HTTPRoute request timeout

HTTPRoute timeouts.request is the ordinary HTTP request deadline:

  • Envoy Gateway's FAQ says that deadline can cut a long HTTP stream such as kubectl logs -f
  • An upgraded WebSocket is a different case

The 15 second 504 is that ordinary deadline. For the WebSocket check, the same /ws rule was set to 5 seconds:

yaml
timeouts:
  request: 5s

On Envoy Gateway v1.9.2, once the HTTP/1.1 upgrade completed:

  • timeouts.request: 5s did not close the upgraded WebSocket during a 15 second silent check
  • The client still had 101 and the first frame, and the socket was still open at the end of that wait
  • For WebSocket inactivity, configure streamIdleTimeout

Migrate WebSocket timeouts from ingress-nginx

ingress-nginx does not need a special upgrade annotation for WebSocket. Its docs tell you to raise both of these, and both default to 60 seconds:

  • proxy-read-timeout: the allowed gap between reads from the upstream
  • proxy-send-timeout: the allowed gap between writes to the upstream

Envoy's streamIdleTimeout is one clock for no activity in either direction. The proxy-read-timeout migration is the same distinction for ordinary HTTP.

ingress-nginx Envoy Gateway
proxy-read-timeout Not a direct 1:1 field
proxy-send-timeout Not a direct 1:1 field
WebSocket quiet period streamIdleTimeout is the closest route-level inactivity control

Do not mechanically copy 60 to 60s. Pick streamIdleTimeout based on the longest period during which your WebSocket is legitimately expected to send no frames in either direction.

The same silent connection behaved differently under the two nginx settings:

  • Defaults: the WebSocket stayed open through a 25 second sample
  • Both annotations set to 5: the client got an empty read, and the backend saw the peer close at 5 seconds
  • The symptom looked like the Envoy idle close. The fields are still not interchangeable.

Do not use requestBuffer on WebSocket routes

requestBuffer is incompatible with WebSocket upgrades on Envoy Gateway, so do not attach request buffering to the WebSocket route:

  • Buffering waits for a complete request body
  • A protocol upgrade turns the connection into a long-lived stream
  • That field is the proxy-body-size replacement for a normal upload

This is the failing piece on the policy that targets the WebSocket HTTPRoute:

yaml
requestBuffer:
  limit: 1Ki

On Envoy Gateway v1.9.2, the same upgrade that had returned 101 then returned:

text
HTTP/1.1 403 Forbidden
connection: close
content-length: 0

The response had no application server header. Removing requestBuffer from that route brought the 101 back.


Backend close versus a proxy or network close

A WebSocket close frame uses opcode 8 and can carry a close code and a reason. When the test application closed the socket itself, the client received opcode 8 and the reason backend close.

An empty read or a TCP reset, with no opcode 8, means the connection ended without a WebSocket close frame. That does not prove Envoy did it. The cause can be:

  • Envoy
  • a load balancer
  • a network failure
  • a backend that died without writing a close frame

Compare the disconnect with streamIdleTimeout and with every other idle timer on the path before you change the application.


Troubleshooting

Symptom Likely cause Fix
HTTP 404 instead of 101 The route may not match, or the backend may be returning 404 Compare the hostname and path with the HTTPRoute. If they match, check the backend response
HTTP 403 instead of 101 The upgrade was rejected before completion. In this tested setup, requestBuffer caused it Check route policies first and remove requestBuffer from the WebSocket route
HTTP 101, then an empty read or reset Some idle timer or network path closed the socket without a close frame Compare the quiet period with streamIdleTimeout, then with load balancer and firewall idle limits
HTTP 101, then opcode 8 The application sent a WebSocket close frame Read the close code and reason before changing proxy timeouts
Disconnects after about 5 minutes with no route policy That matches the documented listener stream idle default Set streamIdleTimeout on the HTTPRoute if the application is supposed to stay silent longer
timeouts.request: 5s did not close this silent WebSocket The ordinary request deadline was not the upgraded stream's idle timer Use streamIdleTimeout for WebSocket inactivity
Heartbeats still do not help Their interval is longer than some other timer on the path Send them more often than the shortest idle timeout in front of the app
HTTPRoute status is healthy but there is no 101 Status does not run the upgrade Send a real handshake and read the status line

Summary

A WebSocket on Envoy Gateway is an HTTP handshake followed by a long-lived stream. HTTP 101 means the upgrade worked. A 404 means the expected upgrade did not happen, so check route matching and the backend. In the tested v1.9.2 setup, a 403 appeared when requestBuffer was attached to the WebSocket route. Neither result represents a WebSocket idle disconnect because the connection never reached the upgraded-stream stage.

With no route-specific policy, the documented stream idle default is 5 minutes, so a silent WebSocket may be closed after that idle period unless another device on the path closes it sooner. A BackendTrafficPolicy targeting the HTTPRoute can set a route-specific streamIdleTimeout instead of relying on that default. In the v1.9.2 check, 5 seconds closed the upstream stream at 5.001 seconds, and the client saw an empty read at 8.199 seconds. Text messages every 2 seconds reset the timer and kept the socket open past 20 seconds. Those messages were application data, not WebSocket Ping frames.

timeouts.request is a different clock. It can end an ordinary stream such as kubectl logs -f, but a 5 second request deadline did not close this upgraded WebSocket during a 15 second silent check. ingress-nginx proxy-read-timeout and proxy-send-timeout are directional quiet periods, not a field you copy onto streamIdleTimeout. When the application closes cleanly, look for opcode 8. An empty read only tells you the connection ended without that frame.


References


Frequently Asked Questions

Does Envoy Gateway need an Upgrade header filter for WebSockets?

No. A normal HTTPRoute forwarded the standard Connection: Upgrade and Upgrade: websocket handshake. The response was HTTP 101, and the first WebSocket frame crossed the proxy without a header modifier.

What is the default stream idle timeout?

The documented listener default is 5 minutes with no upstream or downstream activity. A BackendTrafficPolicy targeting the HTTPRoute can set a route-specific value instead of relying on the 5-minute default. A 25 second silent check stayed open when no route policy was set, which is shorter than that default.

Why did the HTTPRoute request timeout not close this WebSocket?

timeouts.request is the ordinary HTTP request deadline. It can cut a long HTTP stream such as kubectl logs -f. On Envoy Gateway v1.9.2, timeouts.request: 5s did not close this upgraded WebSocket during a 15 second silent check. WebSocket inactivity uses streamIdleTimeout.

Why did a five-second idle timeout show up later at the client?

The backend socket ended after 5.001 seconds. The client saw the empty read at 8.199 seconds. Connection close handling continued after the stream timer fired. Do not treat the YAML duration as the exact time a browser will notice.

Do heartbeats keep a WebSocket open?

They can, when something on the stream arrives before the timer expires. In this test a text message containing ping every 2 seconds kept the connection open past 20 seconds with a 5 second stream idle timeout. That message was application data, not a WebSocket Ping control frame. The interval also has to beat every other idle timer on the path, including a load balancer in front of Envoy.

How can I tell a backend close from a proxy or network close?

A WebSocket close frame is opcode 8 and can carry a close code and a reason. The test application sent opcode 8 with the reason backend close. The idle timeout produced an empty read and no close frame. An empty read means the connection ended without that frame. It does not by itself prove the proxy closed it.

Can I enable request buffering on a WebSocket route?

No. requestBuffer is incompatible with WebSocket upgrades on Envoy Gateway, so do not attach it to the WebSocket route. On the tested version, a 1 KiB request buffer changed the same valid upgrade from HTTP 101 to HTTP 403.

Should I copy proxy-read-timeout 60 to streamIdleTimeout?

No. ingress-nginx watches a gap between upstream reads and a separate gap between upstream writes, and both default to 60 seconds. streamIdleTimeout resets on activity in either direction. Choose the duration from the longest silence your application is supposed to allow.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)