Debugging WebSocket, MQTT, STOMP, Socket.IO and SignalR connections from the browser
Real-time connections fail in a small number of ways, and the browser is unhelpfully quiet about nearly all of them. A WebSocket that cannot reach its server, one that a proxy refuses to upgrade, one killed by an idle timeout and one rejected for a bad token can all surface as the same thing: a closed socket, code 1006, no explanation. The fastest way through is to stop guessing from inside your application and connect to the endpoint with something that has no framework, no reconnect logic and no state — then add one variable at a time. This guide covers the failure modes in the order they actually occur, what each one looks like from the browser, and how to isolate them with the WebSocket, MQTT, STOMP, Socket.IO, SignalR and STUN/TURN testers here — all of which talk directly from your browser to your server, with nothing relaying in between.
What a WebSocket handshake actually is
A WebSocket starts life as an ordinary HTTP GET with Upgrade: websocket and Connection: Upgrade headers plus a Sec-WebSocket-Key. The server answers 101 Switching Protocols and from that point the TCP connection carries frames instead of HTTP. Three consequences follow, and each is behind a class of bug:
- There is no CORS preflight. The same-origin policy does not apply to WebSockets the way it does to
fetch. The browser does send anOriginheader, and the server is expected to check it — if your server rejects the connection with a 403, an origin allowlist is the first thing to look at. - You cannot set headers. The browser API takes a URL and an optional list of subprotocols, and that is all. No
Authorization, no custom tracing headers. Cookies for the target origin are sent, subject toSameSite. - Anything in the middle must understand the upgrade. A proxy, load balancer or CDN that treats the request as ordinary HTTP will strip the upgrade headers and you will get a 200 or a 400 instead of a socket.
Failure 1: mixed content
An https:// page may not open a ws:// socket. Browsers block it before a packet is sent, and the console message is easy to miss in a noisy app. The exemption worth remembering is loopback: ws://localhost, ws://127.0.0.1 and ws://[::1] count as potentially trustworthy, so a broker in Docker on your own machine is reachable from a live https page. Everything else needs TLS on the socket too.
All five protocol testers here enforce that rule explicitly rather than letting the browser fail silently — enter a non-loopback ws:// or http:// target and the page tells you why it will not try, which is more useful than a blank feed.
Failure 2: the 1006 wall
When a WebSocket dies without a proper close handshake, the browser reports code 1006and nothing else. This is intentional: exposing the difference between “connection refused” and “no route” would turn every web page into a network scanner. It is also the single most frustrating thing about debugging WebSockets, because 1006 covers DNS failure, an expired or mismatched TLS certificate, nothing listening, a proxy dropping the upgrade, and a firewall cutting the socket mid-life.
The close codes that do tell you something:
| Code | Meaning | Usually |
|---|---|---|
| 1000 | Normal closure | Someone called close() — often your own reconnect logic |
| 1001 | Going away | Server shutting down, or a deploy rolling pods |
| 1006 | Abnormal closure | See above — diagnose from outside the browser |
| 1008 | Policy violation | Auth rejected after the socket opened; check the close reason string |
| 1009 | Message too big | A frame exceeded the server or proxy limit |
| 1011 | Internal server error | An exception in the handler — the server log has it |
| 1015 | TLS handshake failure | Certificate chain, hostname or protocol version |
The WebSocket testerprints the code, the server’s reason string and a plain-English explanation on every close, plus how long the handshake took and which subprotocol was negotiated. A 1006 within milliseconds is usually “nothing is listening”; a 1006 after two seconds smells like TLS; a 1006 after exactly 30 or 60 seconds of silence is an idle timeout, which is the next section.
Failure 3: proxies, load balancers and idle timeouts
Two distinct problems hide here, and they present differently.
The upgrade never happens. nginx needs to be told explicitly — proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade"— and without them you get a plain HTTP response instead of a socket. AWS Application Load Balancers handle upgrades natively but Classic ones need TCP mode. Some corporate proxies and captive portals block upgrades entirely, which is why a socket that works everywhere fails on one customer’s office network.
The connection is cut while idle. Nearly every intermediary has an idle timeout: AWS ALB defaults to 60 seconds, Cloudflare closes an idle WebSocket after around 100, many corporate firewalls sooner. If your traffic is bursty — a dashboard that updates every few minutes — the socket dies between updates and reconnects, and users see a flicker they cannot explain. The fix is a heartbeat below the shortest timeout in the path. Servers can send protocol-level ping frames; browsers cannot initiate one from JavaScript, so a browser client that must keep a socket alive sends a small application-level message instead. Twenty to thirty seconds is a safe interval.
A quick way to tell the two apart: connect with the tester and leave it open, sending nothing. If it dies at a suspiciously round number of seconds, it is an idle timeout, not your code.
Failure 4: authentication
Because you cannot set headers, WebSocket auth is always a compromise, and the tester makes the constraint explicit — try to connect to wss://user:pass@host/ and it refuses with an explanation, because the browser constructor rejects embedded credentials outright. The three patterns that work:
- Token in the query string.
wss://host/socket?token=…. Simple, universally supported, and it lands in access logs and proxy logs — so use short-lived tokens and be aware of where those logs go. - Token as a subprotocol. The second argument to the WebSocket constructor is a list of subprotocol names, and servers can be written to read a token from it. Slightly obscure, keeps the token out of the URL. The tester takes a comma-separated list and shows you which one the server selected.
- Authenticate after connecting. Open the socket, send credentials as the first message, and let the server close with 1008 if they are wrong. This is what most protocol layers do: MQTT has CONNECT, STOMP has a CONNECT frame with login and passcode, SignalR takes a bearer token.
If a token is involved, decode it before blaming the transport — an expired exp explains a great many sockets that close immediately after opening. The JWT decoder shows the expiry in readable form.
Protocol-specific traps
MQTT.Browsers cannot open raw TCP, so ports 1883 and 8883 are unreachable no matter what you do; you need the broker’s WebSocket listener, which is usually a different port and often a path (wss://broker:8884/mqtt). After that, the usual culprits are a duplicate client ID — two clients with the same ID kick each other off in a loop that looks like a flapping network — and ACLs that allow connection but silently deny a subscription. The MQTT tester lets you subscribe with wildcards and publish with QoS and retain flags, so you can prove which half is broken: if the tester receives a retained message on the topic and your app does not, the problem is in your app.
STOMP. RabbitMQ’s Web-STOMP plugin listens on port 15674 with a /ws path, which is not the same as the AMQP port everyone remembers. The vhost is a frequent cause of a connection that authenticates and then sees nothing — credentials are right, but the default vhost is not where the queue is. The STOMP testerpasses login, passcode and vhost as CONNECT headers and surfaces the broker’s own error frame, which usually names the problem precisely.
Socket.IO. This is not raw WebSocket — it is a protocol on top, and a plain WebSocket client will connect and then fail to speak it. Two settings cause most failures: the path (default /socket.io/, and anything behind a reverse proxy tends to change it) and the transport. Socket.IO normally begins with HTTP long-polling and upgrades; the tester here connects with the WebSocket transport only, so if it cannot connect but your app can, your server is polling-only. The error message it prints comes straight from connect_error, which distinguishes a bad path from a rejected handshake.
SignalR. A SignalR client normally POSTs to a /negotiate endpoint first to pick a transport. The hub testerskips negotiation and goes straight to WebSockets, which is Microsoft’s documented mode for WebSocket-only servers — so it works against hubs that allow WebSockets and fails against ones relying on Server-Sent Events or long-polling fallbacks. With negotiation skipped there is no connection ID, which is expected rather than a fault. Bearer tokens go in the access-token field.
WebRTC: check ICE before you check media
WebRTC failures are usually not media failures — they are connectivity failures that only become visible when no video arrives. Before debugging codecs, confirm the ICE layer works. The STUN/TURN checker runs a real ICE gathering round against one server and tells you exactly what came back:
- A
srflxcandidate — your public address as the STUN server sees it — proves STUN is reachable and NAT traversal can start. No srflx means the STUN server is wrong, blocked, or on a port UDP cannot reach. - A
relaycandidate proves the TURN server allocated a relay for you, which is the only test that also proves your TURN credentials are valid. TURN without a relay candidate is a TURN server you do not have. - ICE error codes are shown as the server returns them: 701 for an unreachable server, 401 and 438 for credential problems (438 specifically means the nonce expired, which usually points at a clock or a TURN REST-credential expiry).
Remember that TURN credentials are usually time-limited: a setup that worked yesterday and fails today with 401 is more likely an expired credential than a configuration change.
A triage order that works
- Reproduce outside your app. Connect to the same URL with the matching tester. If that works, the problem is your client code, not the network.
- Read the close code and timing. Instant 1006 means nothing is listening; a couple of seconds means TLS; a round number of seconds means an idle timeout; 1008 means you were rejected after connecting.
- Remove the middle. Try the origin server directly, bypassing the CDN or load balancer. If direct works, the upgrade configuration in between is the answer.
- Try from another network.Mobile hotspot versus corporate Wi-Fi separates “your server” from “their firewall” in thirty seconds.
- Check the token, then the origin allowlist. These are the two application-level rejections, and both are invisible in the network tab.
- Only then read your reconnect logic.A surprising number of “the server keeps dropping us” reports are a client closing its own socket.
One last thing worth saying plainly: these testers connect from your browser straight to your endpoint. Credentials you type go to your broker or hub and nowhere else, and there is no relay in the middle logging your messages — which is the only way a debugging tool for production infrastructure should work.
Do this
- Reproduce the failure with a scratch client before changing any application code.
- Use the close code and the time-to-close as your first diagnostic: instant, a couple of seconds, or a round number of seconds each mean something different.
- Assume 1006 tells you nothing and go outside the browser:
curl, TLS checks, server and proxy logs. - Add a heartbeat under 30 seconds if anything in the path can time out an idle socket.
- Configure
UpgradeandConnectionheaders on every proxy in the chain, and test the origin directly to prove where it breaks. - For MQTT and STOMP, connect to the WebSocket listener and its path — not the native protocol port.
- For WebRTC, confirm srflx and relay candidates before debugging media at all.
Frequently asked questions
What does WebSocket close code 1006 mean?
It means the connection failed or dropped without a close frame, and the browser is deliberately not telling you why. 1006 covers DNS failure, TLS problems, nothing listening on the port, a proxy refusing the upgrade and a firewall cutting the socket — all reported identically, because exposing the difference would let a page probe the internal network. Distinguish them from outside the browser: curl the same host, check the TLS certificate, look at the server and proxy logs.
Why can’t I connect to ws:// from an https page?
Mixed content. A secure page may not open an insecure socket, and browsers block it silently. The one exception is loopback — ws://localhost and ws://127.0.0.1 are treated as potentially trustworthy, so a broker or dev server on your own machine is testable from a live https site. Everything else needs a wss:// listener.
How do I send an Authorization header on a WebSocket handshake?
You cannot from a browser. The WebSocket API has no header parameter and rejects credentials embedded in the URL. The three workable patterns are a token in the query string (it will appear in server logs), a token smuggled as a subprotocol value, or authenticating with the first message after the socket opens. Cookies are sent automatically for same-site connections, which is why cookie auth is common for first-party sockets.
Can I connect to an MQTT broker on port 1883 from a browser?
No — browsers cannot open raw TCP sockets at all. MQTT from a browser always means MQTT over WebSockets, on whatever port the broker exposes its WebSocket listener (commonly 8083 or 8084 for Mosquitto and EMQX, 8884 for HiveMQ Cloud, often with a /mqtt path). If that listener is not enabled, no browser client can reach the broker.
My WebRTC peers say “connected” but no media arrives — where do I start?
Check ICE candidates before you look at media. If neither side gathers a server-reflexive (srflx) candidate, STUN is not working. If both are behind restrictive NAT and there is no relay candidate, the connection cannot be established at all. Verify the STUN or TURN server on its own first: a valid TURN setup produces a relay candidate and an invalid credential produces ICE error 401 or 438.
Tools used in this guide
Every one of these runs in your browser — the files you work on never leave your device.