Why Your WebSocket Disconnects After 60 Seconds: Spring Boot Behind NGINX

October 7, 2026 · How It Actually Works: Real Systems as State Machines (part 18)

▶ 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:

LayerWhat it isLeg
BrowserThe WebSocket API: readyState, send, closeLeg 1 starts here: wss://, WebSocket over TLS
Cloud edgeAzure Application Gateway, a layer 7 proxy that can end TLS; or Azure Load Balancer, layer 4, which passes the TCP flow throughLeg 2 when the edge is a proxy: the gateway opens its own connection to NGINX
NGINXReverse proxyLeg 3: its own connection to Tomcat
TomcatHTTP server and Jakarta WebSocket container
SpringA 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:

WhoWhat they see
SpringIn 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 logsThe failed handshake
The pageIt 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:

  1. 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.
  2. Then the headers. The handshake handler checks Upgrade and Connection (400 if either is missing, as above). With both checks passed, the server completes the handshake with 101 and a Sec-WebSocket-Accept computed 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 codeMeaning
1000Normal closure: both sides exchanged Close frames
1001Going away, like a server going down or a browser leaving the page
1011The server hit an unexpected condition
1006Reserved: 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 runResult
No traffic either wayClosed at 6.0 s; the client saw 1006
Client sends a text message every 2 s; the server never repliesStill open after 20 s
Server pings every 2 sStill open after 20 s
No Upgrade/Connection headers passedHandshake 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 pieceHeartbeat default
Simple broker, with a task scheduler10 s each way (10000, 10000 ms)
Simple broker, without one0, 0: no heart-beats, so a quiet chat sends nothing at all
WebSocketStompClient (Spring’s separate Java client), with a task scheduler10 s write inactivity (sends a heartbeat), 10 s read inactivity (closes the connection)
SockJS serverA 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:

LayerTimerDefaultWhen it ends the session
Azure Application GatewayRequest timeout (backend HTTP settings)20 sMicrosoft: shorter than your ping/pong interval → 1006 on the client
NGINXproxy_read_timeout60 sNo frame in either direction for that long (source + lab)
Azure Load BalancerIdle timeout4 minIdle flows are dropped silently, unless TCP reset is on
TomcatSession idle timeoutNo documented defaultJakarta: 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

  1. Did the handshake succeed? Look for 101.
  2. Which close code did the browser report? 1000 or 1006?
  3. 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.
  4. Which layer owns a matching timeout?
  5. 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

Sources

Read on 6 October 2026:

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_timeout as 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.