DEV Community

Cover image for How Unity WebGL Talks to a WebSocket Server (The Hard Way)
Magithar Sridhar
Magithar Sridhar

Posted on

How Unity WebGL Talks to a WebSocket Server (The Hard Way)

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.Net anything

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

At compile time, a preprocessor guard selects the right implementation:

#if UNITY_WEBGL && !UNITY_EDITOR
    transport = new WebGLWebSocketTransport();
#else
    transport = new NativeWebSocketTransport();
#endif
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Binary inbound is the most delicate part of the entire bridge. The flow:

  1. JavaScript receives an ArrayBuffer from the server
  2. JS calls _malloc() to allocate memory on the WASM heap
  3. JS copies the ArrayBuffer bytes into that allocation
  4. JS calls SendMessage with the pointer and length
  5. C# calls Marshal.Copy((IntPtr)ptr, data, 0, len) to pull it into managed memory
  6. 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);
    }
};
Enter fullscreen mode Exit fullscreen mode

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

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:

  1. Closes the old transport — which fires Unregister(), removing its GUID from all five dictionaries
  2. Creates a new transport — which generates a new GUID on construction
  3. 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>
Enter fullscreen mode Exit fullscreen mode

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)