Socket.IO Protocol and Clients

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Socket.IO is not a WebSocket library. It is a protocol of its own that uses WebSocket (or HTTP long-polling, or WebTransport) as a carrier, and that adds reconnection, acknowledgements, namespaces and rooms on top. This page opens that protocol up: you capture the bytes of a real session, see why a plain WebSocket client cannot talk to a Socket.IO server by accident, and then use the clients that exist for the browser, Node.js, Python and Java. The server side, rooms, adapters and scaling are covered on Socket.IO (Node.js) and are not repeated here.

Versions were checked on 2026-10-24. The baseline is Socket.IO protocol revision 5 on Engine.IO protocol revision 4 (EIO=4), implemented by socket.io and socket.io-client 4.8.4, engine.io 6.6.11, engine.io-client 6.6.7, socket.io-parser 4.2.7, python-socketio 5.17.0 with python-engineio 4.14.0, and the Java client io.socket:socket.io-client 2.1.2. Every packet trace below was captured from a running socket.io 4.8.4 server.

Chatterbox, the chat scenario of this section, defines its own JSON envelope on Message Design and Serialization. A Socket.IO version of Chatterbox does not need that envelope: the event name replaces type, the acknowledgement replaces ack, and the library supplies reconnection and heartbeats. This page shows the transport mechanics, using a small Chatterbox-flavoured server so that the examples are comparable.

Two protocols, one connection

Socket.IO is two protocols stacked on each other.

  • Engine.IO is the transport layer. It opens a session, gives it an id (sid), chooses and upgrades the transport, and keeps the connection alive with a heartbeat. It knows only a handful of packet types and carries text or binary payloads. It is what survives the proxy that blocks WebSocket.

  • Socket.IO is the application layer. Each Socket.IO packet is carried inside one Engine.IO message packet. It adds namespaces (several logical connections multiplexed over one Engine.IO session), events with JSON arguments, acknowledgements and binary attachments.

Layer Revision in this baseline Introduced

Engine.IO

4 (EIO=4)

Socket.IO 3.0.0 (November 2020); revision 4.1 describes the WebTransport framing

Socket.IO

5

Socket.IO 3.0.0 (November 2020)

The history matters when you meet an old client. Engine.IO revision 3 (Socket.IO 1.x and 2.x) had the client send the pings and used a length prefix instead of a separator in polling payloads; revision 4 reverses the heartbeat and uses the record separator. Socket.IO protocol revision 4 (1.0.3 to 2.x) had no payload on CONNECT and no default-namespace opt-out; revision 5 removed the implicit connection to /, added the authentication payload on CONNECT and renamed ERROR to CONNECT_ERROR with an object payload. A 2.x client talks to a 3.x or 4.x server only when the server runs with allowEIO3: true; 3.x and 4.x clients work with 3.x and 4.x servers.

Why a plain WebSocket client cannot talk to Socket.IO

A server that uses ws expects your frames to be your messages. A Socket.IO server expects frames to be Engine.IO packets on a URL that carries protocol parameters, and it answers with its own packets. Three things stand between a plain client and a Socket.IO server.

  1. The URL must include EIO=4 and transport=websocket on the Socket.IO path (/socket.io/ by default). Without them the server answers HTTP 400 and the WebSocket constructor reports only an error event.

  2. The first frame the server sends is 0{...}, an Engine.IO open packet with the session parameters, and the client must then send a Socket.IO CONNECT (40) before the server delivers anything.

  3. Every application message is wrapped (42["event",arg]), every heartbeat ping must be answered with 3, and acknowledgements are matched by numeric id.

The reverse is also true: a Socket.IO client cannot connect to a plain WebSocket server, because it begins with an HTTP long-polling request, not an upgrade. The following script, which ran against the test server of this page, shows that a plain client is unusable without help, then that implementing the framing by hand does work. It also shows that the protocol is small, which is why clients exist in so many languages.

// minimal-client.mjs -- Node.js 22+ (global WebSocket), no dependencies
const url = "ws://localhost:8120/socket.io/?EIO=4&transport=websocket";
const ws = new WebSocket(url);
let nextAckId = 0;
const pending = new Map();

function emit(event, data) {
  const id = nextAckId++;
  return new Promise((resolve) => {
    pending.set(id, resolve);
    ws.send(`42${id}${JSON.stringify([event, data])}`);
  });
}

ws.onmessage = async ({ data }) => {
  const type = data[0];
  const body = data.slice(1);
  if (type === "0") {                                  // Engine.IO open
    const open = JSON.parse(body);
    console.log("engine open:", open.pingInterval, open.pingTimeout, open.maxPayload);
    ws.send('40{"token":"change-me"}');                // Socket.IO CONNECT with auth
  } else if (type === "2") {                           // Engine.IO ping
    ws.send("3");                                      // pong
  } else if (type === "4") {                           // Engine.IO message
    const sio = body[0];                               // Socket.IO packet type
    if (sio === "0") {
      console.log("namespace connected:", body.slice(1));
      console.log("ack:", await emit("message", { text: "Hello, Chatterbox" }));
      ws.close(1000);
    } else if (sio === "2") {
      console.log("event:", body.slice(1));
    } else if (sio === "3") {                          // ACK: "<id>[args]"
      const m = /^(\d+)(.*)$/.exec(body.slice(1));
      pending.get(Number(m[1]))(JSON.parse(m[2])[0]);
    } else if (sio === "4") {
      console.log("refused:", body.slice(1));
    }
  }
};

Running it prints the handshake values, the namespace id, the broadcast and the acknowledgement:

engine open: 25000 20000 1000000
namespace connected: {"sid":"gbnoTuSqGoqBNiP-AAAc","pid":"x9WFwYsRnrKm85oWAAAd"}
event: ["message",{"from":"u_ada","text":"Hello, Chatterbox","seq":1},"Q5lSiO5"]
ack: { ok: true, seq: 1 }

The event carries a third array element (Q5lSiO5) that the application never sent. It is the recovery offset, explained in the section on connection state recovery below. Real clients strip it before calling your handler; this hand-written client does not.

The test server

All the traces and clients on this page talk to the following server. It is the Chatterbox idea reduced to what the transport needs: a token checked in a middleware, one room, an event with an acknowledgement, a binary event and an extra namespace. The options are explained on the Socket.IO (Node.js) page.

{
  "name": "chatterbox-sio-protocol",
  "private": true,
  "type": "module",
  "dependencies": {
    "socket.io": "4.8.4",
    "socket.io-client": "4.8.4"
  }
}
// server.mjs
import { createServer } from "node:http";
import { Server } from "socket.io";

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: { origin: ["https://chat.example.test"] },
  pingInterval: 25000,
  pingTimeout: 20000,
  maxHttpBufferSize: 1e6,
  connectionStateRecovery: { maxDisconnectionDuration: 120000 },
});

io.use((socket, next) => {
  if (socket.handshake.auth.token !== "change-me") {   // placeholder secret
    const err = new Error("unauthorized");
    err.data = { code: "unauthorized" };
    return next(err);
  }
  socket.data.userId = "u_ada";
  next();
});

io.on("connection", (socket) => {
  socket.join("general");
  socket.on("message", (msg, ack) => {
    const reply = typeof ack === "function" ? ack : () => {};
    if (typeof msg?.text !== "string") {
      return reply({ ok: false, code: "bad_request" });
    }
    io.to("general").emit("message", { from: socket.data.userId, text: msg.text, seq: 1 });
    reply({ ok: true, seq: 1 });
  });
  socket.on("upload", (buf, ack) => ack({ ok: true, bytes: buf.length }));
});

io.of("/admin").on("connection", (s) => s.emit("hello", "admin"));

httpServer.listen(8120, () => console.log("listening 8120"));

The typeof ack === "function" guard is deliberate. A client may emit without a callback, and the acknowledgement argument is then undefined. A handler that calls ack(...) unconditionally throws a TypeError inside an event listener; because Node.js treats an uncaught exception in an event emitter as fatal, the whole server process exits. This was reproduced while capturing the traces: one frame without an acknowledgement id from any client crashed an unguarded server. Treat the acknowledgement as optional input from an untrusted peer.

Engine.IO: the handshake

The client opens a session by sending GET /socket.io/?EIO=4&transport=polling (the browser client adds a timestamp parameter t against caching). The reply body is a single Engine.IO packet: the type digit 0 (open) followed by a JSON object.

GET /socket.io/?EIO=4&transport=polling&t=abc HTTP/1.1

HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
cache-control: no-store

0{"sid":"c1X3JkA-YuNqQaP4AAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}

The fields are:

Field Meaning

sid

Engine.IO session id. Every later request, on any transport, carries it as the sid query parameter. It is different from the Socket.IO sid of a namespace connection.

upgrades

Transports the server accepts as an upgrade. Empty when the client already uses WebSocket, or when allowUpgrades is false.

pingInterval

Milliseconds between server pings (default 25000).

pingTimeout

Milliseconds the peer has to answer a ping (default 20000 since 4.0.0).

maxPayload

Largest number of bytes the client should group into one polling POST; it follows maxHttpBufferSize (default 1000000).

Engine.IO has seven packet types, each a single digit at the start of the packet.

Digit Name Use

0

open

Server to client, first packet, carries the handshake JSON.

1

close

Asks the other side to close the transport.

2

ping

Server to client every pingInterval; also 2probe during an upgrade.

3

pong

The answer; also 3probe.

4

message

Carries one Socket.IO packet (the text after the 4).

5

upgrade

Client to server, completes the switch to a new transport.

6

noop

Server to client, releases a pending long-poll request during an upgrade.

An HTTP error before the handshake is a JSON object with a numeric code, not a packet:

GET /socket.io/?EIO=4&transport=polling&sid=nope   -> 400 {"code":1,"message":"Session ID unknown"}
GET /socket.io/?EIO=3&transport=polling            -> 400 {"code":5,"message":"Unsupported protocol version"}
GET /socket.io/?EIO=4&transport=foo                -> 400 {"code":0,"message":"Transport unknown"}
GET /socket.io/   (a plain browser visit)          -> 400 {"code":0,"message":"Transport unknown"}
GET /socket.io/?EIO=4&transport=polling  with Origin outside cors.origin: no Access-Control-Allow-Origin header

A server that answers 400 Transport unknown to a manual visit is healthy; it means the path is reachable. A 404 or an HTML error page means a proxy or the application is swallowing the path, a very common deployment fault.

HTTP long-polling

During polling a session is a series of short HTTP requests. The client keeps one GET open; the server answers it as soon as it has packets and the client immediately issues the next. The client sends with POST, whose body is the packet or packets, and the server answers ok. Several packets in one body are joined by the record separator \x1e; the old length prefix of revision 3 is gone. Binary data over polling is base64 encoded with a b prefix, because the body is text.

The following session was captured with a Python script that used raw HTTP only. The \x1e is shown as it appears in the body:

POST 40{"token":"change-me"}        -> 200 ok
GET                                 -> 40{"sid":"vopA59D2xH0g1FXcAAAA","pid":"LqrigZRMg-pxkebxAAAB"}
POST 420["message",{"text":"hi"}]   -> 200 ok
GET                                 -> 42["message",{"from":"u_ada","text":"hi","seq":1},"Q5lR9nB"]\x1e430[{"ok":true,"seq":1}]
POST 451-5["upload",{"_placeholder":true,"num":0}]\x1ebAQIDBA==
GET                                 -> 435[{"ok":true,"bytes":4}]

Read the lines with the two layers in mind. 4 is the Engine.IO message; 0 after it is the Socket.IO CONNECT packet; 2 is an EVENT; 3 an ACK; 5 a BINARY_EVENT with 1- meaning one attachment. After the packet type come the attachment count, the namespace (omitted for /), the acknowledgement id and the JSON, in that order. The event 420[...] is therefore: Engine.IO message, Socket.IO event, acknowledgement id 0. The GET reply joined an event and an acknowledgement with \x1e. The binary attachment bAQIDBA== is the bytes 01 02 03 04.

Polling has costs. Each message needs an HTTP request with headers, a response and, behind some proxies, a new connection. Two simultaneous GET requests for the same session, or two simultaneous POST requests, are an error: the server answers 400 and closes the session. Because the requests of one session may reach different server instances, polling requires sticky sessions; that is the main reason Socket.IO deployments need affinity at the load balancer, as discussed in Scaling WebSockets and Proxies, Load Balancers and Deployment.

The upgrade to WebSocket

Clients start with polling because a blocked WebSocket attempt can delay the first message noticeably, and a polling session works behind almost any proxy. When the handshake lists websocket in upgrades, the client tries the upgrade in the background. The captured exchange:

client: opens ws://host/socket.io/?EIO=4&transport=websocket&sid=<sid>
client: 2probe            (Engine.IO ping with the text probe)
server: 3probe            (pong with the text probe)
server: 6                 (noop, delivered to the still pending polling GET)
client: 5                 (upgrade: from now on only WebSocket carries packets)
sequenceDiagram participant C as Client (io) participant S as Server (Engine.IO) C->>S: GET /socket.io/?EIO=4&transport=polling S-->>C: 0 open {sid, upgrades, pingInterval, pingTimeout, maxPayload} C->>S: POST 40 {token} (Socket.IO CONNECT to /) S-->>C: 40 {sid, pid} (CONNECT accepted) Note over C,S: events and acks flow over polling C->>S: GET ... transport=websocket&sid, 101 Switching Protocols C->>S: 2probe S-->>C: 3probe S-->>C: 6 noop (ends the pending poll GET) C->>S: 5 upgrade Note over C,S: WebSocket carries every packet, polling is closed S->>C: 2 ping every pingInterval C-->>S: 3 pong within pingTimeout

The client pauses polling first so that no packet is lost or reordered, then probes; the server must flush the polling buffer before it answers. The server waits upgradeTimeout (10000 ms by default) for the final 5 and closes the new transport otherwise. If the probe fails the session simply stays on polling, which is why a connect event can fire on the polling transport and the transport changes a few milliseconds later. In the traces taken against a server on the same machine the upgrade finished so quickly that the Node.js client reported websocket inside the first connect handler; on a real network, test the current transport through the engine’s upgrade event, not by assuming it at connect:

socket.io.engine.on("upgrade", (transport) => console.log("now using", transport.name));

A client can skip polling with transports: ["websocket"], which starts straight on WebSocket and removes the sticky session requirement, at the price of no fallback. The tryAllTransports option (4.8.0) goes the other way: after a transport fails, the client tries the next one in the list instead of staying on the failing one.

Heartbeats and close

Since Engine.IO revision 4 the server pings. It sends 2 every pingInterval; the client must send 3 within pingTimeout. The client in turn assumes the connection is lost when no ping arrives within pingInterval plus pingTimeout. The captured WebSocket session shows the first ping 25.0 seconds after the open packet. A client that does not answer is disconnected on the server with the reason ping timeout: the first manual trace did exactly that, because the script read the 2 and never sent the 3.

A longer pingTimeout tolerates slow or stalled peers at the price of detecting dead ones later. See Connection Lifecycle, Heartbeats and Reconnection for the general design.

To end a session cleanly the client sends Socket.IO DISCONNECT (41) for a namespace, or an Engine.IO close (1) over polling, and closes the WebSocket with code 1000. The reason strings that reach your disconnect handler are:

Reason Meaning Reconnects

io server disconnect

The server called socket.disconnect().

no

io client disconnect

The client called socket.disconnect().

no

ping timeout

No pong, or no ping, within the heartbeat window.

yes

transport close

The connection closed: network lost, tab closed, proxy idle timeout.

yes

transport error

The transport failed, for example an oversized message or a server stop during a poll.

yes

An oversized message is a useful failure to know. A text frame larger than maxHttpBufferSize (1 MB by default) makes the server drop the connection; in the capture a 1.1 MB event ended with the server-side disconnect reason transport error and no application-level message. Raise the limit deliberately and validate payloads instead.

Socket.IO packets

Every Socket.IO packet has the form <type>[<attachments>-][<namespace>,][<ack id>][JSON], wrapped in an Engine.IO message (so the leading digit on the wire is 4).

Type Name Wire example (as it appears on the wire)

0

CONNECT

client 40{"token":"change-me"}, server 40{"sid":"...","pid":"..."}, with a namespace 40/admin,

1

DISCONNECT

41, or 41/admin,

2

EVENT

42["message",{"text":"hi"}]; with ack id 17 4217["message",{...}]

3

ACK

4317[{"ok":false,"code":"bad_request"}]

4

CONNECT_ERROR

44{"message":"unauthorized","data":{"code":"unauthorized"}}

5

BINARY_EVENT

451-22["upload",{"_placeholder":true,"num":0}] followed by one binary frame

6

BINARY_ACK

like ACK, with attachments

Points that surprise newcomers:

  • Namespaces. A client must send a CONNECT for each namespace it uses; connecting to / is no longer implicit in revision 5. The namespace precedes a comma, so a namespace name must not contain a comma. All sockets of one manager share the single Engine.IO session; the capture showed 40/admin,{"sid":"fHE7...","pid":"2Zo1..."} on the same connection, with a Socket.IO sid different from the Engine.IO one.

  • Authentication. The payload of the client CONNECT is the auth option. A middleware that calls next(err) makes the server answer CONNECT_ERROR with an object, and err.data becomes the data field. The Engine.IO connection stays open after a refusal, and the server went on pinging it in the capture, so a client that is refused and never closes keeps its Engine.IO session until it disconnects or stops answering pings.

  • Acknowledgements. The acknowledgement id is a counter chosen by the sender; a message without an id has no callback. Emitting with an id and getting no ACK is how the timeout of socket.timeout(ms) triggers.

  • Binary. Binary arguments are removed from the JSON, replaced by {"_placeholder":true,"num":n} objects, and sent as separate WebSocket binary frames after the text frame (or base64 strings after \x1e in polling). The ACK of a binary event used the plain 3 packet (4322[...]) in the capture because the response itself had no binary data. A parser that sees a placeholder waits for exactly the declared number of frames, so a binary event can never be interleaved with another event from the same socket.

  • Reserved event names. connect, connect_error, disconnect, disconnecting, newListener and removeListener cannot be emitted by the application.

  • Custom parsers. The default socket.io-parser can be replaced, for example with the msgpack parser. Both sides must agree, so a non-JS client then needs a matching parser.

Connection state recovery on the wire

With connectionStateRecovery enabled (4.6.0 and later) the server remembers the packets sent to each socket for up to maxDisconnectionDuration and appends an offset as an extra last argument to every event. The client keeps the latest offset. The server’s CONNECT reply contains a private id pid. On reconnection the client sends them back:

connect:   40{"sid":"YiWXewsrGls1Dp5DAAAY","pid":"0aSPG-nSIx9gykFgAAAZ"}
event:     42["message",{"from":"u_ada","text":"one","seq":1},"Q5lSVJz"]
reconnect: 40{"token":"change-me","pid":"0aSPG-nSIx9gykFgAAAZ","offset":"Q5lSVJz"}
recovered: 40{"sid":"YiWXewsrGls1Dp5DAAAY","pid":"0aSPG-nSIx9gykFgAAAZ"}

On success the server answers with the old sid, restores rooms and socket.data, replays the missed events, and socket.recovered is true on both sides; otherwise it behaves like a new connection. The restored session keeps the id, which the capture confirms. The recovery also supplies the missing piece for non-JavaScript clients, and a trap: any client that does not know about offsets sees one more argument in every event. The Python client in the next section failed with TypeError: on_message() takes 1 positional argument but 2 were given until the handler accepted extra arguments. The adapter support matrix (in-memory, Redis Streams and MongoDB, not Redis pub/sub) is on Socket.IO (Node.js).

The JavaScript client

The same socket.io-client package serves browsers (bundled, or from a CDN or from the path the server publishes at /socket.io/socket.io.js) and Node.js. io() returns a Socket for the default namespace, owned by a Manager that holds the Engine.IO session. io("/admin") on the same origin shares that manager and multiplexes a second namespace; forceNew: true or multiplex: false creates a separate session.

Option Default Notes

path

/socket.io/

Must equal the server path; with addTrailingSlash: false (4.6.0) the final slash is dropped.

transports

["polling", "websocket", "webtransport"]

["websocket"] skips polling. Order matters for the first transport.

upgrade / rememberUpgrade

true / false

Whether to upgrade, and whether to start directly on WebSocket after an earlier successful upgrade.

tryAllTransports

false

4.8.0: fall through the list when a transport fails.

auth

none

Object or function; sent in the CONNECT packet. A function is called on each reconnection, the place to fetch a fresh one-time ticket.

query

none

Added to every HTTP request as query parameters; visible in logs, so never put secrets there.

extraHeaders

none

Node.js and polling only; browsers cannot set arbitrary headers on WebSocket.

withCredentials

false

Send cookies on cross-origin polling; then the server cors needs credentials: true.

timeout

20000

Connection attempt timeout, before connect_error.

reconnection

true

Plus reconnectionAttempts (Infinity), reconnectionDelay (1000), reconnectionDelayMax (5000), randomizationFactor (0.5).

retries

none

4.6.0: resend unacknowledged events after reconnection, with a per-packet id; implies at-least-once.

ackTimeout

none

4.6.0: default timeout for acknowledgements when retries is used.

autoConnect

true

With false, call socket.connect().

closeOnBeforeunload

false

4.1.0: close the socket on beforeunload so a reload does not leave a transport close waiting for a timeout.

The next script is the browser equivalent of a Chatterbox client; it ran under Node.js against the test server. It shows the options in use, an acknowledgement with a deadline, recovery after a forced transport close, and a refused authentication.

import { io } from "socket.io-client";

const socket = io("http://localhost:8120", {
  auth: { token: "change-me" },
  retries: 3,
  ackTimeout: 5000,
  reconnectionDelay: 500,
  reconnectionDelayMax: 3000,
});

socket.on("connect", async () => {
  console.log("connect", socket.id, "recovered:", socket.recovered);
  try {
    const res = await socket.timeout(2000).emitWithAck("message", { text: "hi" });
    console.log("ack", res);
  } catch (e) {
    console.log("ack error", e.message);       // "operation has timed out"
  }
});
socket.on("message", (m) => console.log("broadcast", m));
socket.on("disconnect", (reason) => console.log("disconnect", reason));
socket.on("connect_error", (e) => console.log("connect_error", e.message, e.data));
socket.io.on("reconnect", (attempt) => console.log("reconnect after", attempt));

setTimeout(() => socket.io.engine.close(), 1500);   // simulate a dropped connection
setTimeout(() => { socket.disconnect(); process.exit(0); }, 5000);

The output, in order, was: connect ... recovered: false, the broadcast, the acknowledgement, disconnect forced close, reconnect after 1 and then connect ... recovered: true with the same socket id, followed by a second broadcast and acknowledgement from the repeated handler. A client with a wrong token printed connect_error unauthorized {"code":"unauthorized"} active: false.

The difference between those two failure paths is how reconnection decides. socket.active is true when the library will retry after a randomised delay, which is the case for transport errors, and false when the server refused the CONNECT in a middleware. In the second case the library never retries by itself: show the user the error, or call socket.connect() after fixing the credentials. A dashboard that stays on "Reconnecting…​" forever after an expired token is nearly always a missing connect_error handler. For the design of backoff, jitter and offline queues in general, see Connection Lifecycle, Heartbeats and Reconnection; Socket.IO’s default randomizationFactor of 0.5 varies each delay by plus or minus 50 percent and the 5 second cap suits a handful of clients, not a fleet.

Pitfalls specific to the JavaScript client:

  • Putting io() inside a React effect or a component body creates a new manager on every render. Create the socket once, outside the component or in a context, and remove listeners in the cleanup.

  • socket.on("connect", ...) fires again on every reconnection; join rooms and register server-side state there, not once at startup. When socket.recovered is false, resynchronise your own state.

  • retries resends events, so handlers must be idempotent. Chatterbox’s client-generated message ids make that safe.

  • An emit while disconnected is buffered by default and sent after reconnection. Use socket.volatile.emit for data that must be dropped when the connection is down, such as cursor positions.

  • Browsers cannot send custom headers on WebSocket. auth, a cookie, or a one-time ticket in the auth payload are the usual routes; see Authentication and Authorization.

Clients in other languages

Because the protocol is documented and small, several implementations exist. The Socket.IO documentation lists them and distinguishes the official libraries (JavaScript, Java, C++ and Swift) from community ones.

Language Library Notes

Python

python-socketio 5.17.0 (socketio.Client, socketio.AsyncClient)

Interoperates with Socket.IO 3.x and 4.x servers (protocol revision 5). Also a server.

Java

io.socket:socket.io-client 2.1.2 (repository socketio/socket.io-client-java)

Official. Built on engine.io-client 2.1.0 and OkHttp. Android and the JVM.

C++

socketio/socket.io-client-cpp

Official, C++11.

Swift

socketio/socket.io-client-swift

Official; the Apple platforms.

Dart

socket.io-client-dart

Community.

.NET

socket.io-client-csharp

Community.

Rust

rust-socketio

Community.

Go

go.socket.io

Community; go-socket.io is a server.

PHP

elephant.io

Community client.

Compatibility rule of thumb: pick the library version whose documentation says it speaks protocol revision 5 and Engine.IO EIO=4. If it speaks only EIO=3, the server must run with allowEIO3: true, which is a transitional setting and not a long-term choice. For the language-specific ecosystem, the Mobile and Native Clients page covers the Android, iOS and desktop clients of the whole section.

Python client

The Python client was run against the test server in a python:3.13-slim container. The authentication, the acknowledgement through call, and the extra recovery argument are all visible in it.

python-socketio==5.17.0
python-engineio==4.14.0
aiohttp==3.13.5
import asyncio
import socketio

sio = socketio.AsyncClient(reconnection=True)


@sio.event
async def connect():
    print("connected", sio.sid, "transport", sio.transport())
    reply = await sio.call("message", {"text": "Hello from Python"}, timeout=5)
    print("ack", reply)


@sio.on("message")
async def on_message(data, *offset):          # the server appends a recovery offset
    print("broadcast", data)


@sio.event
async def connect_error(data):
    print("connect_error", data)


async def main():
    await sio.connect(
        "http://localhost:8120",
        auth={"token": "change-me"},
        transports=["polling", "websocket"],
    )
    await asyncio.sleep(1.5)
    await sio.disconnect()


asyncio.run(main())

Run output:

connected QSXA7aWzdfNBQuKyAAAU transport websocket
broadcast {'from': 'u_ada', 'text': 'Hello from Python', 'seq': 1}
ack {'ok': True, 'seq': 1}

A wrong token makes connect() raise socketio.exceptions.ConnectionError with the message One or more namespaces failed to connect: /, and the server-provided data reaches the connect_error handler. Two practical notes from the run: AsyncClient requires aiohttp for its transport, and a handler registered with a fixed number of parameters breaks as soon as the server enables connection state recovery. The server side of python-socketio is covered on python-socketio.

Java client

The Java client was built and run in a maven:3.9-eclipse-temurin-17 container against the same server. Note the Maven coordinates: the client artifact is io.socket:socket.io-client; its last release at the time of writing (2.1.2, February 2025) targets the current protocol.

<dependency>
  <groupId>io.socket</groupId>
  <artifactId>socket.io-client</artifactId>
  <version>2.1.2</version>
</dependency>
package example;

import io.socket.client.IO;
import io.socket.client.Socket;
import org.json.JSONObject;

import java.net.URI;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

public class ChatterboxClient {
    public static void main(String[] args) throws Exception {
        String url = System.getenv().getOrDefault("SIO_URL", "http://localhost:8120");
        IO.Options options = IO.Options.builder()
                .setAuth(Map.of("token", "change-me"))
                .setTransports(new String[] {"websocket"})
                .build();
        Socket socket = IO.socket(URI.create(url), options);
        CountDownLatch done = new CountDownLatch(2);

        socket.on(Socket.EVENT_CONNECT, a -> {
            System.out.println("connected, id=" + socket.id());
            try {
                JSONObject msg = new JSONObject().put("text", "Hello from Java");
                socket.emit("message", new Object[] {msg}, ack -> {
                    System.out.println("ack: " + ack[0]);
                    done.countDown();
                });
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });
        socket.on("message", a -> {
            System.out.println("broadcast: " + a[0]);
            done.countDown();
        });
        socket.on(Socket.EVENT_CONNECT_ERROR, a -> System.out.println("connect_error: " + a[0]));
        socket.on(Socket.EVENT_DISCONNECT, a -> System.out.println("disconnect: " + a[0]));

        socket.connect();
        boolean ok = done.await(10, TimeUnit.SECONDS);
        socket.disconnect();
        System.out.println(ok ? "OK" : "TIMEOUT");
        System.exit(ok ? 0 : 1);
    }
}

Run output (the container reached the host server through host.docker.internal):

connected, id=mfDfsXZftQNTrfrqAAAG
broadcast: {"from":"u_ada","text":"Hello from Java","seq":1}
ack: {"ok":true,"seq":1}
OK

The Java client delivers event arguments as an Object[], so the extra recovery offset is simply a[1] and a handler that reads a[0] is unaffected. Using the WebSocket-only transport list skips polling and its sticky-session needs; on Android, remember that callbacks run on a library thread and UI work must be posted to the main thread.

WebTransport

Socket.IO 4.7.0 added WebTransport as a third transport, and the Engine.IO protocol revision 4.1 describes it: each payload is preceded by a header of 1, 3 or 9 bytes whose first bit marks it as text or binary. It runs on a bidirectional QUIC stream, avoids head-of-line blocking at the TCP level and is intended for lossy mobile networks.

The server must be an HTTP/3 server in addition to the normal HTTPS one. Node.js has no native one, so the Socket.IO guide uses the @fails-components/webtransport packages, hands every session to io.engine.onWebTransportSession, and lists "webtransport" in the server transports. A browser client uses the default transports, starts with polling, and upgrades to WebTransport when it can; if WebTransport is blocked (a firewall that drops UDP, for example) the connection simply stays on WebSocket. The requirements are strict: a secure context, a certificate valid for the test, and a Node.js version of 18 or later. The guide notes that on localhost the client should set transportOptions.webtransport.hostname to 127.0.0.1.

Be careful with the support claims. The guide still describes Safari support as missing, which is out of date: WebTransport shipped in Safari 26.4, as covered on WebTransport and Media over QUIC. WebTransport is also a W3C Candidate Recommendation Snapshot, not a Recommendation, so treat it as an emerging optimisation behind a WebSocket fallback, not a replacement. For the chat and videoconference scenarios it is unnecessary; for a game with frequent small updates (Pixel Arena) its unordered datagrams are of more interest than Socket.IO’s reliable stream anyway.

Security notes for the protocol

  • The cors option does not protect WebSocket. It only emits CORS headers on the polling responses. A browser sends the page’s Origin on every WebSocket handshake and no CORS check happens, so a hostile page could open a socket with the user’s cookies. In the capture a WebSocket handshake with Origin: https://evil.example was accepted by a server that configured only cors. Use allowRequest, which runs for polling and for the upgrade:

    const allowed = new Set(["https://chat.example.test"]);
    const io = new Server(httpServer, {
      allowRequest: (req, callback) => {
        const origin = req.headers.origin;
        callback(null, origin === undefined || allowed.has(origin));
      },
    });

    With this in place the hostile origin got HTTP 403 on the polling request and a failed handshake on the WebSocket, while the allow-listed origin worked. Clients that send no Origin (native apps, scripts) are accepted by this predicate and must authenticate with the auth payload; drop the origin === undefined clause on browser-only servers. Source: Socket.IO docs: Handling CORS

  • Authenticate in the CONNECT payload or a middleware, not the query string. Query parameters are logged by proxies. The polling transport can also carry cookies, and WebSocket cookies are sent automatically, so a cookie session needs the Origin check above.

  • Limit sizes. maxHttpBufferSize protects memory; validate every event payload with a schema, since any peer can send any array.

  • Namespace names and event names are caller-controlled. Never use them to build file paths, database collections or channel names without validation.

Related reading: Security for the whole section’s checklist.

Common errors

Symptom Likely cause

websocket error / xhr poll error in the browser console

Wrong URL or path, CORS missing for polling, a proxy that does not forward the Upgrade header, or a server that is not reachable.

400 Session ID unknown after some seconds

Polling requests reach different instances without sticky sessions, or the session timed out.

400 Transport unknown for a real client

Path mismatch between client and server, or EIO mismatch (an old client).

Connection works locally, not behind the load balancer

The proxy lacks the Upgrade/Connection headers or has a short idle timeout below pingInterval.

connect_error then nothing

Middleware refused; socket.active is false and nothing retries.

disconnect: ping timeout every few minutes

A proxy idle timeout shorter than pingInterval + pingTimeout, or an event loop blocked on the server.

transport error after a large emit

The message exceeded maxHttpBufferSize.

A non-JS client breaks when recovery is enabled

The extra trailing offset argument in every event.

Server process exits on one bad frame

An acknowledgement callback called without checking that it exists.

The Socket.IO troubleshooting guide gives the full checklist for connection problems.

Version history

  • Socket.IO 1.0 (2014) introduced Engine.IO and binary support; 2.0 (2017) rewrote the internals but kept protocol revision 4.

  • 3.0.0 (November 2020) moved to protocol revision 5 and Engine.IO revision 4: the server pings, the authentication payload and the explicit connection to / appeared, and the CONNECT_ERROR payload became an object.

  • 3.0.0 also stopped sending permissive CORS headers by default, which is why the cors option exists; 4.0.0 (March 2021) set pingTimeout to 20000 ms.

  • 4.1.0 added closeOnBeforeunload; 4.4.0 added socket.timeout(); 4.6.0 added emitWithAck, retries, ackTimeout, socket.recovered and connection state recovery; 4.7.0 added WebTransport; 4.8.0 added tryAllTransports. The current release is 4.8.4.

  • python-socketio 5.x and python-engineio 4.x speak protocol revision 5 and Engine.IO revision 4, so they interoperate with 3.x and 4.x JavaScript clients and servers.