DEV Community

Cover image for How I Built a Socket.IO v4 Client for Unity from Scratch (with WebGL Support)
Magithar Sridhar
Magithar Sridhar

Posted on

How I Built a Socket.IO v4 Client for Unity from Scratch (with WebGL Support)

Every Unity multiplayer developer hits the same wall.

You've got a Node.js backend running Socket.IO. Your web client connects in three lines. Then you open the Unity docs, search the Asset Store, and spend two hours realising that the Unity side of this equation is... a mess.

That's where I was. And it's why I built socketio-unity.

The State of Socket.IO in Unity (Before This)

When I started, the options were grim:

  • Paid assets with no source code and no way to fix bugs yourself
  • Abandoned repos targeting Socket.IO v2 or v3, incompatible with the current protocol
  • WebGL support that was either broken or missing entirely — and WebGL multiplayer is huge if you want browser-based games

I needed something open-source, Socket.IO v4, and WebGL-verified. It didn't exist, so I built it.

The Architecture (High Level)

Before diving into the hard part, here's the shape of the library:

Socket.IO Server
      ↓
  ITransport  ←── WebSocketTransport (Standalone)
              ←── WebGLWebSocketTransport (Browser)
      ↓
 EngineIOClient  (handshake, heartbeat, RTT)
      ↓
 SocketIOClient  (namespaces, events, ACKs, binary)
      ↓
 SocketIOManager (Unity singleton)
Enter fullscreen mode Exit fullscreen mode

A single WebSocket connection. Namespaces multiplexed over it. Tick-driven — no background threads. Everything dispatched on Unity's main thread.

The public API ends up feeling natural for Unity developers:

var socket = SocketIOManager.Instance.Socket;

socket.OnConnected += () => Debug.Log("Connected!");
socket.On("chat", msg => Debug.Log(msg));

socket.Connect("ws://localhost:3002");
socket.Emit("chat", "Hello from Unity!");
Enter fullscreen mode Exit fullscreen mode

Getting there wasn't straightforward. The hardest part wasn't the protocol parsing, or the reconnect logic, or the binary packet assembly.

It was WebGL.

The WebGL Problem (And How I Solved It)

WebGL builds don't run on the .NET runtime. They compile to WebAssembly, which means your C# code runs in the browser — but it can't touch browser APIs directly. No raw sockets. No threads. No System.Net.

For WebSocket specifically, you have to go through a JavaScript bridge: a .jslib file that Unity compiles into the final build and exposes to your C# code via DllImport.

Here's what that looks like at the boundary:

// C# side — calling into JS
[DllImport("__Internal")]
private static extern void SocketIOConnect(string url);

[DllImport("__Internal")]
private static extern void SocketIOSend(string data);
Enter fullscreen mode Exit fullscreen mode
// SocketIOWebGL.jslib — the JS side
mergeInto(LibraryManager.library, {
  SocketIOConnect: function(urlPtr) {
    var url = UTF8ToString(urlPtr);
    // create WebSocket in browser JS, wire up callbacks
    socket = new WebSocket(url);
    socket.onmessage = function(e) {
      // marshal data back to C#
      SendMessage('SocketIOManager', 'OnWebGLMessage', e.data);
    };
  }
});
Enter fullscreen mode Exit fullscreen mode

Sounds manageable. In practice there were three brutal edge cases.

1. String marshalling across the boundary

Passing strings between C# and JS in Unity WebGL requires manual memory management. You allocate a buffer in the JS heap, write the UTF-8 bytes, pass the pointer to C#. If you get the buffer size wrong, or forget to free it, you get silent corruption or memory leaks that only appear after hundreds of messages.

2. Binary data

Socket.IO supports binary events — you can send byte[] payloads alongside JSON. In WebGL, ArrayBuffer from JS can't be passed directly to C# as a byte array. You have to Base64-encode it in JS, pass it as a string, decode it in C#.

For large payloads (say, 10MB in stress tests) this adds real overhead. But it was the only reliable cross-boundary option.

3. Domain reload safety

Unity's editor reloads the domain (re-initialises all C# state) when you enter Play mode. If your .jslib holds a reference to a WebSocket that no longer has a C# counterpart, you get ghost connections — sockets that are open in JS but invisible to C#, firing events into the void.

The fix was a cleanup hook in OnDisable that explicitly closes the JS-side socket on every domain reload:

void OnDisable()
{
#if UNITY_WEBGL && !UNITY_EDITOR
    SocketIOClose();
#endif
}
Enter fullscreen mode Exit fullscreen mode

Simple when you know. Absolutely maddening to diagnose before you do.

Other Things Worth Knowing

Reconnect with backoff and jitter

Reconnect logic seems easy until you have 500 clients all disconnecting at once and hammering your server simultaneously. Jitter spreads the reconnect attempts across a window:

socket.ReconnectConfig = new ReconnectConfig
{
    initialDelay  = 1f,
    multiplier    = 2f,
    maxDelay      = 30f,
    maxAttempts   = -1,
    jitterPercent = 0.1f,  // ±10% randomness on each delay
};
Enter fullscreen mode Exit fullscreen mode

Everything runs on the main thread

Unity's API is not thread-safe. Any callback that touches a GameObject, Transform, or Debug.Log needs to be on the main thread. The library uses UnityMainThreadDispatcher to queue all event callbacks — so you never have to think about this.

CI on every commit

The test suite covers 38+ protocol edge cases, binary assembler correctness, ACK overflow, reconnect config, and stress tests (1K events, 10MB binary, 100 concurrent ACKs). It runs on Unity 2022.3 LTS via GitHub Actions on every push.

Using It in Your Project

Install via Package Manager → +Add package from git URL:

https://github.com/Magithar/socketio-unity.git
Enter fullscreen mode Exit fullscreen mode

You'll also need NativeWebSocket:

https://github.com/endel/NativeWebSocket.git#upm
Enter fullscreen mode Exit fullscreen mode

Then create an empty GameObject, attach SocketIOManager, and you're connected.

There are four samples included (Basic Chat, PlayerSync, Lobby, LiveDemo) each with a Node.js test server you can run locally.

What I'd Do Differently

Honestly? Start with the WebGL bridge earlier. I designed the transport abstraction (ITransport) upfront specifically to keep WebGL isolated from the rest of the stack — but I still underestimated how much the JS boundary would dictate the architecture around binary data and lifecycle management.

If you're building anything that needs to run in a browser, design for WebGL constraints from day one, not as an afterthought.


The Repo

github.com/Magithar/socketio-unity

Want to see it running in a browser before installing anything?

There's a live WebGL demo — open it in two tabs to watch real-time player sync in action.

MIT licensed. Socket.IO v4 only. WebGL verified. Zero paid dependencies.

If you're building multiplayer in Unity and connecting to a Socket.IO backend — give it a try. Issues and PRs welcome.


If this saved you a few hours of pain, a ⭐ on the repo goes a long way.

Top comments (0)