Nginx WebSocket connections drop with 504 after 60 seconds idle because proxy_read_timeout defaults to 60s
Nginx WebSocket connections drop with 504 after 60 seconds idle because proxy_read_timeout defaults to 60s
A dashboard team ships a live-updating panel, and users start reporting that it goes stale about a minute after they stop interacting with it. The browser tab is still open, the socket object still says OPEN for a moment, then a reconnect fires, then it dies again. Server logs show a 504 against the /ws location every 60 seconds, from clients that were doing nothing wrong. If the app pushes updates only on change, an idle screen is a normal screen — and nginx quietly kills it.
The failure is a default, not a bug in your app
proxy_read_timeout defines how long nginx waits between two successive read operations from the proxied upstream. Its default is 60 seconds [link needed]. That default was chosen for request/response traffic, where a stalled upstream should not hold a worker thread forever. A WebSocket tunnel is not request/response: if your server has nothing to say for 61 seconds, no bytes cross the upstream leg, and nginx treats that silence as a dead backend.
The result shows up as upstream timed out (110: Connection timed out) in error.log and a 504 in access.log [link needed]. Browsers usually surface it as an abnormal closure rather than a clean 1000, because the close frame never arrives.
Fixing nginx websocket proxy_read_timeout 504 in four steps
This assumes nginx terminates TLS in front of an upstream that supports the WebSocket handshake. If a CDN or cloud load balancer sits in front of nginx, raise its idle timeout first, or you will just move the 60-second wall.
1. Find the location block that proxies the socket. Grep your site config for the proxy_pass that serves /ws or equivalent. Keep the scope narrow — a global http-level bump affects every virtual host on the box.
2. Raise both timeouts inside that location. Set proxy_read_timeout 3600s; and proxy_send_timeout 3600s;. Both default to 60s [link needed], and the send side governs nginx writing to a quiet upstream. Confirm the location also sets proxy_http_version 1.1; plus proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade"; [link needed] — the timeouts are meaningless if the tunnel never upgrades.
3. Make the application prove liveness. A long timeout only defers the problem; without traffic, neither side knows about a dead peer. Send a ping frame every 20–30 seconds from the server or client and answer it. That keeps bytes flowing and lets keepalive detection do its job.
4. Validate and reload, then watch the logs. Run nginx -t, then nginx -s reload. Tail error.log for the timeout string for at least ten minutes with a genuinely idle connection open.
Evidence that it worked: an idle socket survives past 120 seconds with no 504 and no reconnect in the browser devtools.
When NOT to raise the timeout
Do not just push it to a huge value and walk away if the upstream cannot reap dead sockets. A tunnel that outlives its backend process stays counted until nginx's next read fails, so pair long timeouts with a sane worker_shutdown_timeout and confirm the app closes cleanly on shutdown.
Also skip the change if your traffic is mostly short polling on the same upstream — 3600s there mostly inflates the time a half-open connection occupies a worker slot before cleanup.
And if your app already pings every 20 seconds, the 60-second default is rarely the culprit; look at handshake headers and upstream keepalive settings before touching timeouts.
Checklist
- [ ] Idle timeout raised on the socket
locationblock only, both read and send. - [ ]
proxy_http_version 1.1and theUpgrade/Connectionheaders confirmed in place. - [ ] Application-level ping interval shorter than the configured timeout.
- [ ]
nginx -tpassed beforenginx -s reload. - [ ] Any upstream load balancer's idle timeout raised to match.
- [ ] 120-second idle test held without a 504 in
access.log.