Why Your WebSocket Disconnects After 60 Seconds: Spring Boot Behind NGINX
▶ Watch on YouTube & subscribe to The Stack Underflow
Your Spring Boot chat works on localhost. Behind NGINX, the WebSocket won’t even connect. Fix that, and it drops after exactly 60 seconds of quiet. Put Azure Application Gateway in front, with its default timeout and a heart-beat configured slower than 20 seconds, and it drops after 20. Your code didn’t change: several layers between your browser and your app can impose their own timeout. The video follows one WebSocket through those layers as one state machine, and this page walks the same path with the setting and the source behind every step.
The one-line version: a WebSocket is one HTTP handshake, completed with 101, then frames. Every proxy on the way must pass Upgrade and Connection. Several layers can time out, and the shortest applicable timeout wins.
Last verified against RFC 6455, the WHATWG WebSockets standard, nginx.org and NGINX’s source, the Spring Framework reference and source, the Tomcat 11 documentation and Microsoft Learn: 6 October 2026. Scope: the classic HTTP/1.1 Upgrade path, open-source NGINX (our lab ran nginx 1.31.6), Spring Framework 7.0.9 on Spring Boot’s embedded Tomcat. New to 101, frames or 1006? Start with the primer: WebSocket 1006, 101 and Ping/Pong Explained.
One HTTP handshake, then a long-lived WebSocket
A WebSocket begins with an HTTP GET carrying Upgrade: websocket and Connection: Upgrade. The server completes the handshake with 101 Switching Protocols, and from then on the upgraded connection carries WebSocket frames, both ways. 101 isn’t an error: it’s the last HTTP response on this connection.
Behind proxies, that’s one logical session over several physical TCP legs:
| Layer | What it is | Leg |
|---|---|---|
| Browser | The WebSocket API: readyState, send, close | Leg 1 starts here: wss://, WebSocket over TLS |
| Cloud edge | Azure Application Gateway, a layer 7 proxy that can end TLS; or Azure Load Balancer, layer 4, which passes the TCP flow through | Leg 2 when the edge is a proxy: the gateway opens its own connection to NGINX |
| NGINX | Reverse proxy | Leg 3: its own connection to Tomcat |
| Tomcat | HTTP server and Jakarta WebSocket container | |
| Spring | A WebSocketHandler, or STOMP over WebSocket |
The browser’s side is the state machine in the video: CONNECTING (0) → OPEN (1) → CLOSING (2) → CLOSED (3). When the page closes first, CLOSING means the browser has sent its Close frame. When the other side’s Close frame comes back, the socket is CLOSED with 1000, a normal closure. If the connection drops before that Close frame arrives, the code is 1006.
Failure 1 · The upgrade never arrives
Behind NGINX, the request reaches Spring, but its upgrade headers don’t. Upgrade and Connection are hop-by-hop headers, so NGINX doesn’t pass them to the proxied server unless you set them. Without explicit headers, NGINX drops Upgrade and sends Connection: close instead, so Tomcat gets a plain GET.
Three views of one failure:
| Who | What they see |
|---|---|
| Spring | In our Spring path, the handshake handler rejects it with HTTP 400: Can "Upgrade" only to "WebSocket". (a missing Connection header gets a 400 too: "Connection" must be "upgrade".) |
| The Network tab and proxy logs | The failed handshake |
| The page | It can’t read that 400 through the WebSocket API: an error event, then close code 1006 |
The fix, verbatim from nginx.org’s WebSocket page:
location /chat/ {
proxy_pass http://backend;
# proxy_http_version 1.1; # before version 1.29.7
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Or, with a map so that requests without an Upgrade header get Connection: close:
http {
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
...
location /chat/ {
proxy_pass http://backend;
# proxy_http_version 1.1; # before version 1.29.7
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
proxy_http_version 1.1 is only needed before NGINX 1.29.7; since then, 1.1 is the default. On an older NGINX, keep the line: it’s harmless. The two proxy_set_header lines are still required.
Where the WebSocket lives in Spring Boot
Spring Boot auto-configures WebSocket support for embedded Tomcat (and Jetty), and Tomcat implements the Jakarta WebSocket API. You’ll usually use it in one of two ways: a raw WebSocketHandler, or STOMP over WebSocket, for publish-subscribe messaging through a broker. SockJS is still supported, as a fallback for restrictive proxies.
Before any of that, Spring makes two checks, in this order:
- Origin first. By default, Spring accepts only same-origin requests; you can allow a list of origins, or all of them. A page from another origin is rejected with 403 until you allow it. The Origin check protects browser scenarios. It isn’t authentication: non-browser clients can forge
Origin. - Then the headers. The handshake handler checks
UpgradeandConnection(400 if either is missing, as above). With both checks passed, the server completes the handshake with 101 and aSec-WebSocket-Acceptcomputed from the browser’s key.
Frames, and saying goodbye
Once it’s open (readyState 1), everything is a frame: text, binary, ping, pong and close. Frames from the browser to the server are masked; frames from the server aren’t. Each proxy relays the frames from one leg to the next.
The page sends with send and receives in onmessage. It can’t send protocol pings, which the API doesn’t expose, but its own messages are frames too. When the page calls close, the browser sends a Close frame and moves to CLOSING.
| Close code | Meaning |
|---|---|
| 1000 | Normal closure: both sides exchanged Close frames |
| 1001 | Going away, like a server going down or a browser leaving the page |
| 1011 | The server hit an unexpected condition |
| 1006 | Reserved: must never be sent in a Close frame. The browser reports it when the connection closed without one. |
1006 wasn’t sent by the server. It means no goodbye arrived.
Failure 2 · Exactly 60 seconds
A chat that’s quiet for a minute dies. NGINX’s timer here is proxy_read_timeout, 60 seconds by default. nginx.org says: “By default, the connection will be closed if the proxied server does not transmit any data within 60 seconds.” NGINX runs that clock on its own connection to Tomcat, for as long as the WebSocket is open.
What restarts the clock? The docs describe only the server-silence case. In NGINX’s source (ngx_http_upstream_process_upgraded), the read timer is re-armed on traffic in either direction, and our Docker lab confirmed it on nginx 1.31.6, with proxy_read_timeout 6s and a backend that never sends data:
| Lab run | Result |
|---|---|
| No traffic either way | Closed at 6.0 s; the client saw 1006 |
| Client sends a text message every 2 s; the server never replies | Still open after 20 s |
| Server pings every 2 s | Still open after 20 s |
No Upgrade/Connection headers passed | Handshake rejected (our Python test backend answers 426; Spring answers 400, as above) |
So 60 seconds with no frame in either direction, and NGINX closes the connection. No Close frame arrives, so the browser reports 1006.
The fixes: NGINX’s own suggestion is to have the proxied server send WebSocket ping frames periodically, “to reset the timeout and check if the connection is still alive”. Or STOMP heart-beats (next section). Either way, a heartbeat only helps if it reaches the timer you’re fighting.
The AI suggestion: an assistant may suggest raising 60 seconds to an hour, proxy_read_timeout 3600s. That can be a valid policy, but it gives you no liveness detection. A robust design picks an intentional timeout and sends heartbeats comfortably inside it.
Not the same timer: proxy_send_timeout is a different directive, also 60 s by default. On an upgraded connection, NGINX’s source arms it only while a write to the upstream is blocked. The 60-second quiet failure is proxy_read_timeout.
Tomcat’s end of the session, and heartbeats
Tomcat’s container holds the server end of the session. Its docs don’t state a default idle timeout for it; in the Jakarta API, zero or negative means never. So Tomcat won’t close a quiet session, but the proxies in front of it will. The fix is traffic from here, inside their timeouts.
With STOMP, use heart-beats. The heart-beat header is defined by the STOMP 1.2 specification, on its CONNECT and CONNECTED messages, which travel inside WebSocket frames. Spring’s defaults apply only when heartbeat scheduling is enabled:
| Spring piece | Heartbeat default |
|---|---|
| Simple broker, with a task scheduler | 10 s each way (10000, 10000 ms) |
| Simple broker, without one | 0, 0: no heart-beats, so a quiet chat sends nothing at all |
WebSocketStompClient (Spring’s separate Java client), with a task scheduler | 10 s write inactivity (sends a heartbeat), 10 s read inactivity (closes the connection) |
| SockJS server | A heartbeat after 25 s with no other messages |
Each heart-beat is a frame through every leg, so it restarts NGINX’s clock.
Failure 3 · The shortest applicable timeout wins
The same chat behind Azure Application Gateway, with its default timeout and a heart-beat configured slower than 20 seconds. The request timeout in the backend HTTP settings, 20 seconds by default, also applies to the WebSocket session. Microsoft says to set it longer than your ping/pong interval; otherwise, the client gets 1006.
On one timeline:
| Layer | Timer | Default | When it ends the session |
|---|---|---|---|
| Azure Application Gateway | Request timeout (backend HTTP settings) | 20 s | Microsoft: shorter than your ping/pong interval → 1006 on the client |
| NGINX | proxy_read_timeout | 60 s | No frame in either direction for that long (source + lab) |
| Azure Load Balancer | Idle timeout | 4 min | Idle flows are dropped silently, unless TCP reset is on |
| Tomcat | Session idle timeout | No documented default | Jakarta: zero or negative means never |
With a heart-beat configured slower than 20 seconds, the gateway’s limit is hit first, long before NGINX’s 60, and the client gets 1006. So every heartbeat interval must sit comfortably inside the shortest applicable timeout. (Without the gateway, there’s no 20-second clock; a Load Balancer has its own 4 minutes.)
Which layer timed out first? Five steps
- Did the handshake succeed? Look for 101.
- Which close code did the browser report? 1000 or 1006?
- How long was the connection quiet? 20 seconds, 60, 4 minutes: timing is a strong clue, but any layer can be set to the same value.
- Which layer owns a matching timeout?
- Confirm it in the gateway, proxy and server logs.
Frames don’t carry HTTP request IDs, so capture a correlation ID during the handshake and carry it into your server-side session logs.
On Kubernetes: ingress-nginx (legacy)
If you still run the community ingress-nginx controller, note that it is retired: best-effort maintenance ended in March 2026, with no further releases, bug fixes or security fixes. Existing deployments keep working. Its docs say WebSocket works out of the box, and that the only requirement to avoid closed connections is raising proxy-read-timeout and proxy-send-timeout (default 60 seconds; they suggest more than one hour, 3600), per Ingress with the annotations nginx.ingress.kubernetes.io/proxy-read-timeout and nginx.ingress.kubernetes.io/proxy-send-timeout (values in seconds). The same caveat applies: a long timeout isn’t liveness detection.
HTTP/2 and HTTP/3
Everything above is the classic HTTP/1.1 Upgrade path. WebSocket over HTTP/2 doesn’t use Upgrade, Connection or 101 at all: it uses extended CONNECT (RFC 8441). RFC 9220 applies the same mechanism to HTTP/3.
Pause & Prove
1. The pinned question
Your chat runs behind Azure Application Gateway and NGINX, both on their default timeouts. STOMP heart-beats are set to 25 seconds, and the chat goes quiet. Which layer closes the connection first, and what close code does the browser report?
Application Gateway, and the browser reports 1006. Its request timeout, 20 s by default, also applies to the WebSocket session, and Microsoft says to set it longer than your ping/pong interval; a 25-second heart-beat is slower than that. The gateway’s 20 s is the shortest applicable timeout, so NGINX’s 60 s never comes into play. No Close frame arrives, so the browser reports 1006. Fix: heart-beats comfortably inside 20 s (Spring’s simple broker defaults to 10 s once you give it a task scheduler), or a request timeout longer than your heartbeat interval.
2. The Community poll
Your WebSocket closed and the browser reports close code 1006. Who sent the 1006?
- The server. The tempting one. 1006 is reserved: no endpoint may send it in a Close frame. A server that closes cleanly sends a Close frame with a real code, such as 1000, 1001 or 1011.
- NGINX. NGINX may be what closed the connection (after 60 s of quiet, for example), but it closes it without a Close frame. It doesn’t send a 1006.
- Nobody: no Close frame arrived. ✓ The browser reports 1006 when the connection closed without a Close frame: a dropped connection, a proxy timeout, or a handshake that failed. Then the question is which layer timed out first.
- The browser’s JavaScript. The page only receives the code. When it calls
close, the browser sends a Close frame, which is a clean goodbye, not a 1006.
Before / after this video
- Words first: WebSocket 1006, 101 and Ping/Pong Explained: The Words Before the Deep Dive
- Before: Tomcat vs NGINX: What Each Does and Why Spring Boot May Need Both, where the same
proxy_read_timeoutcuts off a streamed HTTP answer - Deeper: What NGINX Does Before Your App Sees a Request (and Where 502 vs 504 Come From) and What Apache Tomcat actually does with your request
- Not covered here, each a video of its own: scaling out with several app instances and a broker relay, Server-Sent Events, and auth tokens on the handshake.
Sources
Read on 6 October 2026:
- RFC 6455, The WebSocket Protocol: the HTTP/1.1
GETwith Upgrade and Connection, 101, Sec-WebSocket-Accept, opcodes, masking, the Close handshake, close codes 1000, 1001, 1011 and 1006 - WHATWG WebSockets Standard:
readyState0–3, ping/pong not exposed to JavaScript, a failed handshake and an abrupt close both report 1006 - nginx.org, WebSocket proxying: hop-by-hop headers, both snippets verbatim, 60 s without data, server pings
- nginx.org, ngx_http_proxy_module:
proxy_http_version(1.1 default since 1.29.7),proxy_read_timeoutandproxy_send_timeout(60 s),proxy_set_headerdefaults (Connection close) - NGINX source,
src/http/ngx_http_upstream.candsrc/event/ngx_event_timer.h: the upgraded connection re-arms the read timer on traffic in either direction; the send timer only while a write is blocked - Our Docker lab: nginx 1.31.6,
proxy_read_timeout 6s, a Python WebSocket backend and client (results in the table above) - Spring Framework 7.0.9, WebSocket, allowed origins, SockJS fallback, simple broker and STOMP client
- Spring Framework source,
spring-websocket/.../server/support(AbstractHandshakeHandler: 400 and its messages;OriginHandshakeInterceptor: 403, applied before the handshake) andSimpleBrokerRegistration(0, 0unless a task scheduler is set, then10000, 10000) - STOMP 1.2 specification, Heart-beating
- Spring Boot, WebSockets: auto-configuration for embedded Tomcat and Jetty
- Apache Tomcat 11, WebSocket how-to: Jakarta WebSocket, no documented default idle timeout; Jakarta WebSocket API: zero or negative means never
- Microsoft Learn, Application Gateway WebSocket support and backend HTTP settings: the request timeout (20 s default) also applies to the WebSocket session; 1006 when it’s shorter than the ping/pong interval
- Microsoft Learn, Application Gateway overview, TLS termination, Load Balancer overview and Load Balancer TCP reset and idle timeout: layer 7 vs layer 4, 4-minute default, silent drop unless TCP reset
- ingress-nginx, project home (retirement), WebSockets and custom timeouts
- RFC 8441 (WebSocket over HTTP/2, extended CONNECT) and RFC 9220 (HTTP/3)
Change notes
- 7 Oct 2026: first published. Added from our accuracy notes, beyond the video: the official snippets verbatim, the Docker lab’s results,
proxy_send_timeoutas a separate timer, the ingress-nginx retirement, and HTTP/2 and HTTP/3.
Not affiliated with or endorsed by F5, the nginx project, the Apache Software Foundation or Microsoft. NGINX is a trademark of F5, Inc. Apache Tomcat is a trademark of the Apache Software Foundation. Azure is a trademark of Microsoft Corporation. Found a mistake? Tell us in the video’s comments and we’ll correct this page.
Found this useful? The deep version lives on YouTube — new breakdowns of how AI dev tools actually work, weekly.
Subscribe on YouTube →Prefer email? Get the free newsletter: one failure, traced step by step, about once a week.