When I built socketio-unity, the hardest part wasn't the Socket.IO protocol. It wasn't binary packet assembly or reconnect logic.
It was making WebGL work.
Not "works on my machine" works. Production-verified, binary-safe, multi-socket, reconnect-safe works.
This is the full technical writeup of how the WebGL bridge is built — every design decision, every edge case, and every gotcha that will silently destroy you in production if you miss it.
Why WebGL Needs a Bridge at All
Desktop Unity runs on the .NET runtime — you call System.Net.WebSockets directly and it just works. WebGL is different. Unity compiles your C# to WebAssembly via IL2CPP, which means your code runs inside a browser sandbox with no access to:
- No raw sockets from C#
- No background threads
- No
System.Netanything
Every WebSocket operation has to go through the browser's native WebSocket API. And the only way to reach browser APIs from C# in a WebGL build is through a .jslib — a JavaScript file Unity compiles into the final build and exposes to your C# code via DllImport("__Internal").
So instead of calling System.Net.WebSockets directly, you call JavaScript. JavaScript manages the actual socket and fires callbacks back into C# via Unity's SendMessage. That's the bridge.
The Dual-Transport Architecture
The library uses one interface for both platforms:
public interface ITransport
{
void Connect(string url);
void SendText(string data);
void SendBinary(byte[] data);
void Dispatch();
void Close();
}
At compile time, a preprocessor guard selects the right implementation:
#if UNITY_WEBGL && !UNITY_EDITOR
transport = new WebGLWebSocketTransport();
#else
transport = new NativeWebSocketTransport();
#endif
The !UNITY_EDITOR is not optional. Without it, the Editor tries to compile DllImport("__Internal") which has no backing implementation there — and fails silently in ways that take a long time to diagnose.
Everything above the transport layer — EngineIO, SocketIO, namespaces, ACKs, reconnect — is completely platform-agnostic. It never knows which transport it's talking to.
Files Involved
| File | Role |
|---|---|
WebGLWebSocketTransport.cs |
C# side — calls JS functions via DllImport
|
WebGLSocketBridge.cs |
MonoBehaviour singleton — routes JS callbacks to the right transport |
SocketIOWebGL.jslib |
JavaScript — manages browser WebSocket objects |
ITransport.cs |
Platform-agnostic interface |
link.xml |
IL2CPP stripping protection |
Socket ID Routing
This is the first non-obvious problem.
The bridge is a singleton — one WebGLSocketBridge MonoBehaviour for the entire application. But a single app can have multiple transports open simultaneously (the root namespace plus several custom namespaces, for example).
When JavaScript fires a callback, how do you know which C# transport it belongs to?
Every WebGLWebSocketTransport generates a GUID on construction. This ID is passed to JavaScript when creating the socket, and prefixed to every callback payload:
JS → C#: "a1b2c3d4-...:2[\"chat\",\"hello\"]"
^^^^^^^^^^^^^^ socketId ^^^^^^^^^ packet
WebGLSocketBridge maintains five Dictionary<string, Action> maps — open, close, text, binary, error — keyed by socket ID. When a transport connects, it calls Register(). When it closes, it calls Unregister(). The bridge parses the prefix and dispatches to the right handler. No GUID, no routing, no callback collision.
There's also a fallback: both text and binary parsers fall back to _lastActiveSocketId if the payload doesn't contain a socket ID prefix. This handles legacy jslib versions and transitional deployments without breaking anything.
Message Flow
Outbound (emit)
socket.Emit()
→ EngineIOClient.SendRaw()
→ WebGLWebSocketTransport.SendText() / SendBinary()
→ DllImport → SocketIO_WebSocket_SendText / SendBinary
→ browser WebSocket.send()
→ server
For binary sends, the managed byte array has to be pinned before its pointer is handed to JavaScript — otherwise the GC can move it mid-call:
var handle = GCHandle.Alloc(data, GCHandleType.Pinned);
try
{
SocketIO_WebSocket_SendBinary(socketId, handle.AddrOfPinnedObject(), data.Length);
}
finally
{
handle.Free();
}
The finally block is not optional. A leaked GCHandle is a memory leak that accumulates silently over a session.
Inbound (receive)
server → browser WebSocket.onmessage
→ JS allocates WASM heap (_malloc), copies bytes, calls SendMessage
→ WebGLSocketBridge.JSOnText / JSOnBinary
→ parses "socketId:payload" or "socketId,ptr,len"
→ dictionary lookup → fires handler on correct transport
→ EngineIOClient parses Socket.IO packet
→ game On() handler invoked
Binary inbound is the most delicate part of the entire bridge. The flow:
- JavaScript receives an
ArrayBufferfrom the server - JS calls
_malloc()to allocate memory on the WASM heap - JS copies the
ArrayBufferbytes into that allocation - JS calls
SendMessagewith the pointer and length - C# calls
Marshal.Copy((IntPtr)ptr, data, 0, len)to pull it into managed memory - JS immediately calls
_free()on the allocation
Step 6 has to happen after step 5. If you free before C# has copied, you're reading deallocated memory. If you forget to free at all, you leak WASM heap on every binary message received.
The jslib handles this:
socket.onmessage = function(e) {
if (e.data instanceof ArrayBuffer) {
var bytes = new Uint8Array(e.data);
var ptr = _malloc(bytes.length);
HEAPU8.set(bytes, ptr);
SendMessage('WebGLSocketBridge', 'JSOnBinary', socketId + ',' + ptr + ',' + bytes.length);
_free(ptr);
}
};
One more thing: binaryType = "arraybuffer" must be set on the socket at creation time. Without it, binary messages arrive as Blob — which cannot be synchronously read inside the callback. This is silent. The message arrives, your handler fires, and the data is just... not there.
var socket = new WebSocket(url);
socket.binaryType = "arraybuffer"; // without this, binary is broken
Dispatch() is a No-Op
On desktop, Dispatch() is called every frame to pump the WebSocket message queue. The native transport needs it — async operations need to be ticked.
On WebGL, Dispatch() does nothing. The browser's event loop drives message delivery. When a message arrives, the browser fires onmessage, which calls SendMessage, which routes directly to the C# handler. There's no queue to pump.
This is one of those things that looks wrong when you first read it but is correct.
Reconnection Safety
Each reconnect cycle:
- Closes the old transport — which fires
Unregister(), removing its GUID from all five dictionaries - Creates a new transport — which generates a new GUID on construction
- Connects, which calls
Register()with the new GUID
No stale callback references. No GUID collisions. No events firing on a transport that no longer exists.
IL2CPP Stripping
WebGL builds use IL2CPP with aggressive dead-code elimination. Any type that's only referenced at runtime — via JSON deserialization, reflection, or SendMessage — gets stripped and produces silent failures.
The link.xml in the package preserves the types the bridge depends on:
<linker>
<assembly fullname="socketio-unity">
<type fullname="UnityMainThreadDispatcher" preserve="all"/>
<type fullname="UnityTickDriver" preserve="all"/>
</assembly>
</linker>
If you're deserializing your own data models from Socket.IO events and they're disappearing in WebGL builds, they need entries here too.
WebGL vs Native at a Glance
| Aspect | WebGL | Native |
|---|---|---|
| WebSocket API | Browser WebSocket
|
System.Net.WebSockets |
| Thread model | Single (browser event loop) | async/await |
Dispatch() |
No-op | Must call each frame |
| Binary memory | Manual _malloc / _free
|
Automatic GC |
| Interop | DllImport("__Internal") |
Direct .NET |
| Socket ID | GUID per transport | N/A |
Production Gotchas
CORS. The Socket.IO server must explicitly allow the page origin. A CORS rejection in WebGL produces no useful error — the socket just fails to connect.
Browser cache. Stale .unityweb assets serve old jslib code after you've updated the bridge. Force-refresh (Cmd+Shift+R) or use Incognito during development. This has caused more confusion than any other single issue.
IL2CPP stripping. Any type only referenced via JSON deserialization needs link.xml preservation. The symptoms are random NullReferenceExceptions in WebGL builds that don't reproduce in the Editor.
binaryType = "arraybuffer". Set it on socket creation in the jslib, always. Without it, binary messages arrive as Blob and are silently unreadable.
The Full Picture
The WebGL bridge in socketio-unity isn't a hack or a workaround. It's a proper interop layer — dual transport with a clean interface, GUID-based routing, pinned memory for outbound binary, malloc/free for inbound binary, and IL2CPP stripping protection.
Most of this complexity is invisible when you're just calling socket.Emit(). That's the point.
If you're building Unity multiplayer with a Socket.IO backend — WebGL included:
Repo: github.com/Magithar/socketio-unity · Live demo: magithar.github.io/socketio-unity/ · MIT licensed. Zero paid dependencies.
If this saved you a few hours of pain, a ⭐ on the repo goes a long way.
Top comments (0)