Fix Django WebSocket Connection Failed Behind Nginx

Debug Django Channels WebSocket failures behind Nginx: handshake status codes, Upgrade headers, ASGI routing, origin checks, wss mixed content, and idle timeouts.

Problem statement

The browser console shows:

WebSocket connection to 'wss://www.example.com/ws/notifications/' failed

sometimes with an extra detail such as Error during WebSocket handshake: Unexpected response code: 404, or the connection opens and then closes with code 1006 after a while. Locally with runserver (and daphne in INSTALLED_APPS) everything works.

A WebSocket starts as an HTTP request with Upgrade: websocket headers. The server must reply 101 Switching Protocols, after which the connection stays open. In production, that handshake passes through several layers (CDN or load balancer, Nginx, an ASGI server, Django Channels routing, authentication, origin validation), and any of them can reject or break it. The status code of the failed handshake is the most useful clue.

The WebSocket handshakeBrowserwss:// upgradeNginxforwards UpgradeASGI serverDaphne or UvicornConsumeraccept or closeThe handshake status code names the layer that refused the connection.

Quick answer

  1. Open the browser's Network tab, filter by WS, and read the handshake response status.
  2. 404: the request reached a server that does not route WebSockets: Gunicorn (WSGI) instead of Daphne or Uvicorn, a wrong Nginx location, or a path mismatch with URLRouter.
  3. 400 or 403: Channels rejected it: AllowedHostsOriginValidator, ALLOWED_HOSTS, or your consumer's close() during connect().
  4. 502: Nginx could not reach the ASGI server (service down, wrong socket, permission).
  5. 200 with HTML: Nginx did not forward Upgrade and Connection headers, so the backend treated it as a normal request.
  6. Connects, then closes after about 60 seconds: proxy_read_timeout or a load balancer idle timeout.
  7. Blocked immediately on an HTTPS page: the client used ws://; use wss://.

Step-by-step solution

1. Read the handshake in the browser

In Chrome or Firefox developer tools, open Network, filter WS, reload, and click the request. Look at:

  • Request URL and scheme (wss:// on HTTPS pages)
  • Status Code (101 means success)
  • Request headers: Connection: Upgrade, Upgrade: websocket, Origin
  • Response headers: server often reveals whether Nginx, a CDN, or the app answered
the handshake status tells you whereHandshake resultCause404routed to Gunicorn, or no ws route400 or 403AllowedHosts or origin validator200 with HTMLUpgrade headers not forwarded502Daphne or Uvicorn not reachableCloses after ~60 sproxy_read_timeout defaultA successful handshake is 101 Switching Protocolsdjango-deployment.com
The WS filter in the Network tab shows the handshake status.

2. Reproduce with curl

curl -i -N --http1.1 \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  -H "Origin: https://www.example.com" \
  https://www.example.com/ws/notifications/

--http1.1 matters: the classic WebSocket upgrade uses HTTP/1.1. Expected success:

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

Then bypass layers one at a time. Directly against Daphne or Uvicorn on the server:

curl -i -N --http1.1 --unix-socket /run/myapp-daphne/daphne.sock \
  -H "Host: www.example.com" \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  -H "Origin: https://www.example.com" \
  http://localhost/ws/notifications/

If this returns 101 but the public URL does not, the problem is in Nginx or an outer proxy. If this also fails, the problem is in the ASGI app.

3. 404: request reached the wrong server or route

Served by Gunicorn with WSGI. A WSGI app cannot accept WebSockets; Django returns 404 because no URL pattern matches /ws/.... Route WebSocket paths to the ASGI server:

location /ws/ {
    proxy_pass http://unix:/run/myapp-daphne/daphne.sock;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Nginx chooses the longest matching prefix location, so location /ws/ wins over location / regardless of order. A regex location (location ~ ...) matching the same URL can still take precedence, so check for those.

Path mismatch. URLRouter patterns must match the full path:

websocket_urlpatterns = [
    path("ws/notifications/", NotificationConsumer.as_asgi()),
]

A client connecting to /ws/notifications without the trailing slash does not match this pattern. APPEND_SLASH does not apply to WebSocket routing.

ASGI app not wired. If asgi.py only returns get_asgi_application() without a ProtocolTypeRouter for websocket, Channels routing is not active.

4. 400 or 403: rejected by Channels or Django

AllowedHostsOriginValidator compares the Origin header with ALLOWED_HOSTS. An origin such as https://app.example.com is rejected if only www.example.com is allowed. The server log for Daphne shows a line like WSREJECT.

Also check:

  • the Host header passed by Nginx is in ALLOWED_HOSTS
  • your consumer does not call self.close() in connect() for anonymous users when the session cookie is not sent (cross-site contexts, or a cookie with SameSite=Strict in unexpected flows)

Log in the consumer while debugging:

import logging

from channels.generic.websocket import AsyncWebsocketConsumer

logger = logging.getLogger(__name__)


class NotificationConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        headers = dict(self.scope["headers"])
        logger.info(
            "ws connect user=%s origin=%s",
            self.scope["user"],
            headers.get(b"origin"),
        )
        if not self.scope["user"].is_authenticated:
            await self.close()
            return
        await self.accept()

5. 200 with HTML: upgrade headers not forwarded

Upgrade and Connection are hop-by-hop headers. Nginx does not pass them upstream unless configured, so the ASGI server sees a normal GET. Define the map in the http context:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
forwarding the upgrademap$http_upgrade $connection_upgrade {default upgrade; '' close; }location /ws/ {proxy_http_version1.1;proxy_set_headerUpgrade$http_upgrade;proxy_set_header Connection $connection_upgrade;1231In the http block: Connection depends on the request.2Required for Upgrade to work upstream.3Hop-by-hop: dropped unless set explicitly.Without these, Nginx sends a normal request and gets HTML backdjango-deployment.com
The map keeps ordinary requests from being sent with Connection: upgrade.

And in the WebSocket location, set proxy_http_version 1.1, Upgrade, and Connection as shown in step 3. Remember that any proxy_set_header in a location replaces all headers inherited from the server block, so repeat Host and X-Forwarded-* there.

6. 502: ASGI server unreachable

systemctl status myapp-daphne --no-pager
ls -l /run/myapp-daphne/daphne.sock
sudo tail -n 20 /var/log/nginx/error.log

connect() to unix:... failed (2: No such file or directory) means the socket does not exist (service down or wrong path). (13: Permission denied) means Nginx's user cannot access the socket.

7. Disconnects after about a minute

Nginx closes proxied connections when no data flows for proxy_read_timeout (60 seconds by default). Idle WebSockets hit that limit:

location /ws/ {
    # ...
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}
quiet sockets get closedSocket opens101No messagesuser reads the page60 s passproxy_read_timeoutNginx closesclient sees 1006Fixping every 30 s, longer timeoutLoad balancers have their own idle timeout to matchdjango-deployment.com
Application-level pings also detect dead connections early.

Outer layers have their own idle timeouts: AWS Application Load Balancers default to 60 seconds, and CDNs have limits too. Increasing every timeout is fragile; sending an application-level ping every 20 to 30 seconds keeps connections active through all layers:

const socket = new WebSocket(`wss://${location.host}/ws/notifications/`);
const heartbeat = setInterval(() => {
  if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping" }));
}, 25000);
socket.addEventListener("close", () => clearInterval(heartbeat));

Handle ping messages in the consumer by ignoring them or replying with pong.

8. Mixed content and scheme

Pages served over HTTPS cannot open ws:// connections; the browser blocks them before any network request. Build the URL from the page's protocol:

const scheme = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${scheme}://${location.host}/ws/notifications/`);

9. CDNs and load balancers

  • Cloudflare: WebSockets are supported on proxied records; make sure no rule blocks or caches /ws/.
  • AWS ALB: supports WebSockets on HTTP and HTTPS listeners; raise the idle timeout if you do not send pings. Sticky sessions are not required when using a channel layer.
  • HTTP/2 between client and CDN: browsers may use HTTP/2 or HTTP/1.1 to the edge; the edge still uses HTTP/1.1 upgrade semantics toward Nginx in typical setups, so keep proxy_http_version 1.1 at the origin.
every hop has an idle timeoutDefault idle timeoutSettingNginx60 sproxy_read_timeoutAWS ALB60 sidle_timeout.timeout_secondsCloudflare!about 100 snot configurable on most plansPings shorter than the smallest timeout keep every hop opendjango-deployment.com
Design for reconnects anyway; deploys close every socket.

10. Verify

curl -s -o /dev/null -w "%{http_code}\n" -N --http1.1 --max-time 3 \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  -H "Origin: https://www.example.com" \
  https://www.example.com/ws/notifications/

Verification check:

  • the handshake returns 101 through the public URL
  • an Origin not in ALLOWED_HOSTS is rejected
  • a connection idle for five minutes stays open (or reconnects automatically)
  • messages sent from the server (for example via the channel layer) reach the browser

Explanation

WebSocket failures are layered failures. Each hop must recognize the upgrade request, forward the hop-by-hop headers, route it to an ASGI-capable server, and then keep a long-lived connection open. Reading the handshake status code tells you which hop gave up: 404 and 200 usually mean routing or header forwarding, 400 and 403 mean Django or Channels rejected it, 502 means the ASGI server was unreachable, and late disconnects mean an idle timeout.

Edge cases / notes

Works for some users only

Corporate proxies and some antivirus products interfere with WebSockets, especially over plain ws://. Using wss:// avoids most of them. Implement reconnect with backoff in the client.

Authentication with tokens

Browsers cannot set custom headers on WebSocket connections. Token-based auth often uses a short-lived token in the query string or subprotocol; avoid long-lived secrets in URLs because they appear in logs.

Channel layer failures

If the handshake succeeds but group messages never arrive, the channel layer (usually Redis) is misconfigured or unreachable.

Client reconnection that survives deploys

Every Daphne or Uvicorn restart closes all open WebSockets. Clients that reconnect immediately and simultaneously create a spike exactly when the new process is starting. A reconnecting client with exponential backoff and jitter handles deploys and short outages gracefully:

function connectWithBackoff(url, onMessage) {
  let attempt = 0;
  let socket;

  function open() {
    socket = new WebSocket(url);

    socket.addEventListener("open", () => {
      attempt = 0;
    });

    socket.addEventListener("message", (event) => onMessage(JSON.parse(event.data)));

    socket.addEventListener("close", (event) => {
      if (event.code === 1000) return; // closed intentionally
      const base = Math.min(30000, 1000 * 2 ** attempt);
      const delay = base / 2 + Math.random() * (base / 2);
      attempt += 1;
      setTimeout(open, delay);
    });
  }

  open();
  return () => socket && socket.close(1000);
}

const scheme = location.protocol === "https:" ? "wss" : "ws";
const stop = connectWithBackoff(`${scheme}://${location.host}/ws/notifications/`, console.log);

Delays grow from about one second up to a 30-second cap, and the random component spreads reconnects across clients.

Close codes worth knowing

CodeMeaning in practice
1000normal closure, initiated by client or server code
1001server going away, for example during a restart
1006abnormal closure without a close frame: proxy timeout, network drop, or rejected handshake
1011server error while handling the connection, such as an exception in the consumer
4000–4999application-defined codes, for example 4401 for unauthenticated in your own consumer

1006 is the most common and least specific; it means the connection ended without a proper WebSocket close handshake, so check proxy timeouts and server logs around that time.

Logging handshakes in Nginx

A dedicated access log for WebSocket paths shows upgrade outcomes and connection durations:

log_format websocket '$remote_addr [$time_local] "$request" $status '
                     'upgrade=$http_upgrade duration=$request_time bytes_sent=$bytes_sent';

location /ws/ {
    access_log /var/log/nginx/websocket.log websocket;
    # proxy settings as above
}

WebSocket requests are logged when the connection closes, with $status 101 and $request_time showing how long it stayed open. Many connections closing after exactly 60 seconds point straight at a read timeout.

Common mistakes

  • Proxying /ws/ to Gunicorn with WSGI workers.
  • Setting proxy_set_header Connection "upgrade" for all requests instead of using the map, which breaks keepalive for normal traffic.
  • Forgetting proxy_http_version 1.1 in the WebSocket location.
  • Relying on longer timeouts at every layer instead of heartbeats and reconnect logic.

Next steps

FAQ

Why does my Django WebSocket return 404 in production?

The request is reaching a WSGI server such as Gunicorn, or the path does not match your Channels URLRouter. Route /ws/ in Nginx to Daphne or Uvicorn and check the exact path.

What Nginx settings do WebSockets need?

proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, and proxy_set_header Connection $connection_upgrade with a map, plus a longer proxy_read_timeout or client heartbeats.

Why do Django WebSockets disconnect after 60 seconds?

Nginx's default proxy_read_timeout is 60 seconds, and many load balancers use a similar idle timeout. Send periodic pings or raise timeouts for the WebSocket location.

Why does the browser block my WebSocket on an HTTPS site?

The page uses ws://, which is mixed content on HTTPS. Use wss://, derived from location.protocol.

Keep going