DEV Community

Cover image for Why Your Unity WebGL Multiplayer Breaks on Some Networks
Magithar Sridhar
Magithar Sridhar

Posted on

Why Your Unity WebGL Multiplayer Breaks on Some Networks

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
Enter fullscreen mode Exit fullscreen mode

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"]
    }
});
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: websocket header
  • 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-Policy header that does not include connect-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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
// 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",
  // ...
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)