You test your Unity WebGL multiplayer game. It works perfectly. You share it with a friend — it works. You share it with someone on a corporate network, or a phone hotspot, or after pushing a new build — and it silently fails to connect.
No useful error. No crash. Just... nothing.
This happens because Unity WebGL multiplayer has three distinct failure modes that don't exist in standalone builds: CORS blocks the connection before it opens, WebSocket gets intercepted by a network proxy, and browser cache serves your old build to players days after you deployed a fix.
Here's how to diagnose and fix all three.
1. CORS — the most common first failure
What it is
CORS (Cross-Origin Resource Sharing) is a browser security rule. When your WebGL game at https://yoursite.com tries to connect to https://api.example.com, the browser first sends a preflight OPTIONS request asking "are you allowed to talk to me?" Your Socket.IO server must answer yes.
If it does not, the browser blocks the WebSocket upgrade before it even begins.
What you see
In the browser console (F12 → Console):
Access to fetch at 'https://api.example.com/socket.io/?EIO=4...'
from origin 'https://yoursite.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present
Or in the Network tab: the /socket.io/ request returns status 0 or the WebSocket connection is refused before opening.
Why WebGL is different from desktop
A Unity desktop or mobile build connects as a native TCP socket — no browser, no CORS check. WebGL runs inside the browser, so every connection goes through the browser's security model. The same server that works for your standalone build may refuse WebGL completely.
The fix
On your Node.js Socket.IO server, pass a cors option when creating the server:
import { Server } from "socket.io";
import { createServer } from "http";
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: "https://yoursite.com", // exact origin, or "*" for open access
methods: ["GET", "POST"]
}
});
For local development you often need to allow localhost and your production domain together:
cors: {
origin: ["http://localhost:8080", "https://yoursite.com"],
methods: ["GET", "POST"]
}
origin: "*"works but removes all cross-origin protection. Fine for a public demo, not for a game with auth tokens.
Verifying it worked
After fixing the server, check the Network tab in DevTools. The /socket.io/ request should return 200 with these response headers:
Access-Control-Allow-Origin: https://yoursite.com
Access-Control-Allow-Methods: GET,POST
2. WebSocket blocking
What it is
Some networks actively block WebSocket connections. This includes:
-
Corporate firewalls — WebSocket upgrade requests look unusual; many enterprise proxies strip the
Upgrade: websocketheader - Carrier-grade NAT and mobile networks — Some mobile carriers terminate WebSocket connections after 30–60 seconds of inactivity
- Transparent HTTP proxies — Common in schools, hotels, and public Wi-Fi. These intercept HTTP traffic and cannot forward a WebSocket upgrade
-
Strict CSP headers — If your hosting adds a
Content-Security-Policyheader that does not includeconnect-src wss://your-server.com, the browser blocks the connection
What you see
The connection opens briefly, then closes, often with:
[SocketIO:Transport] Transport error: WebSocket is closed before connection is established
Or the socket connects on the first try but drops every 30–60 seconds on mobile.
The browser console may show:
WebSocket connection to 'wss://...' failed: Error during WebSocket handshake:
Unexpected response code: 200
That 200 instead of 101 means a proxy intercepted and replied instead of letting the WebSocket upgrade through.
Mixed content: the silent variant
If your game page is served over https:// but your Socket.IO server URL starts with ws:// (not wss://), the browser blocks it as mixed content — no error dialog, just a silent drop.
In Unity, your server URL must match the page protocol:
// Page is https:// — use wss://
[SerializeField] private string serverUrl = "wss://your-server.com";
// Page is http:// (local dev) — ws:// is fine
[SerializeField] private string serverUrl = "ws://localhost:3000";
Most hosting platforms (Render, Railway, Fly.io) terminate TLS for you, so your Node.js server can listen on plain HTTP while clients connect over wss://. The TLS layer is handled at the edge.
Polling fallback
Socket.IO supports two transports: WebSocket and HTTP long-polling. Long-polling works through most proxies because it looks like normal HTTPS traffic. Socket.IO will automatically fall back to polling if WebSocket fails during the handshake.
To confirm which transport is active, check the browser Network tab. A persistent WS entry means WebSocket succeeded. Repeated XHR requests to /socket.io/?transport=polling mean it fell back.
CSP fix
If your host adds a Content-Security-Policy header, explicitly allow your Socket.IO server origin:
Content-Security-Policy: connect-src 'self' wss://your-server.com
For GitHub Pages there's no way to set response headers directly — if this is a blocker, move to a host that gives you header control (Netlify, Vercel, and Cloudflare Pages all support _headers files).
3. Browser cache
What it is
When you push a new WebGL build, players on repeat visits may run your old code — the browser served it from disk cache before even asking the server. This is particularly disruptive for multiplayer because the old client sends packets the new server no longer understands.
What you see
- A bug you fixed reappears after deployment
- Players report "connection looping" while you cannot reproduce it
- The Socket.IO version mismatch shows in the handshake: the
EIO=query parameter differs between clients
Why Unity WebGL is especially vulnerable
Unity WebGL produces large binary files (.wasm, .data, .framework.js). Browsers aggressively cache large static assets. Unless filenames include a content hash, users will not get the new version until their cache expires — which could be days.
Fixes
Development (testing your own build):
Hard-refresh: Cmd+Shift+R (Mac) or Ctrl+Shift+R (Windows/Linux). Use a Private/Incognito window for a fully clean test environment.
Production (your players' browsers):
The correct fix is to version all four asset URLs — the loader, data, wasm, and framework files. The loader is a separate <script> tag, but the data/wasm/framework URLs come from the config object passed to createUnityInstance in index.html. If those filenames are unchanged, the browser will cache them even if the loader itself is fresh.
Version all four in index.html:
<!-- Loader script tag -->
<script src="Build/LiveDemo.loader.js?v=1.3.2"></script>
// createUnityInstance config — version all asset URLs
const config = {
dataUrl: "Build/LiveDemo.data.unityweb?v=1.3.2",
frameworkUrl: "Build/LiveDemo.framework.js.unityweb?v=1.3.2",
codeUrl: "Build/LiveDemo.wasm.unityweb?v=1.3.2",
// ...
};
A CI/CD step can rewrite all four at once using the git commit SHA:
# Linux
GIT_SHA=$(git rev-parse --short HEAD)
sed -i "s|\.unityweb|\.unityweb?v=${GIT_SHA}|g" docs/index.html
sed -i "s|LiveDemo\.loader\.js|LiveDemo.loader.js?v=${GIT_SHA}|" docs/index.html
# macOS requires an empty backup suffix
sed -i "" "s|\.unityweb|\.unityweb?v=${GIT_SHA}|g" docs/index.html
sed -i "" "s|LiveDemo\.loader\.js|LiveDemo.loader.js?v=${GIT_SHA}|" docs/index.html
Also set appropriate cache headers on your static host:
# Aggressive caching for content-hashed assets
Cache-Control: public, max-age=31536000, immutable
# Short cache or no-cache for the loader and index
Cache-Control: no-cache
Diagnostic checklist
When WebGL multiplayer breaks on a specific network or after a deploy, work through this list:
CORS
[ ] Server has cors.origin set to match the page origin
[ ] OPTIONS preflight returns 200 with Access-Control-Allow-Origin
WEBSOCKET BLOCKING
[ ] Page is https:// — server URL is wss://, not ws://
[ ] Network tab shows 101 Switching Protocols, not 200
[ ] No CSP connect-src violation in browser console
[ ] Socket.IO falls back to polling on restrictive networks (check for XHR traffic)
CACHE
[ ] Hard-refresh confirms the bug persists (rules out cache)
[ ] Loader URL has version query string updated after deploy
[ ] No players stuck on old EIO version (check server handshake logs)
Quick reference
| Symptom | Most likely cause | First thing to check |
|---|---|---|
| Works on your network, fails elsewhere | WebSocket blocked by proxy | Network tab: 101 or 200? |
| Works standalone, fails in browser | CORS | Console: Access-Control-Allow-Origin error |
| Works on http://, fails on https:// | Mixed content | Server URL: ws:// vs wss://
|
| Bug reappears after you fixed it | Browser cache | Hard-refresh, confirm build version |
| Connects then drops every ~60s | Mobile NAT timeout | Enable ping keepalive; confirm polling fallback |
| "Connection refused" after new deploy | Old client, new server protocol | Check EIO version in query string |
The repo
All of this applies to socketio-unity — the open-source Socket.IO v4 client for Unity this series is built around. MIT licensed, WebGL verified, zero paid dependencies.
Live WebGL demo: magithar.github.io/socketio-unity/
Have you hit a WebGL multiplayer issue that isn't on this list? Drop it in the comments — especially anything weird you've seen on specific mobile carriers or corporate networks.
See also
- WEBGL_NOTES.md — Architecture, jslib setup, IL2CPP stripping
- DEBUGGING_GUIDE.md — Trace system, diagnostics overlay, common errors
- RECONNECT_BEHAVIOR.md — Reconnect strategy and configuration
Top comments (0)