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
messagepacket. 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 ( |
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.
-
The URL must include
EIO=4andtransport=websocketon the Socket.IO path (/socket.io/by default). Without them the server answers HTTP 400 and theWebSocketconstructor reports only an error event. -
The first frame the server sends is
0{...}, an Engine.IOopenpacket with the session parameters, and the client must then send a Socket.IOCONNECT(40) before the server delivers anything. -
Every application message is wrapped (
42["event",arg]), every heartbeat ping must be answered with3, 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"));
Source: Socket.IO docs: Server options
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 |
|---|---|
|
Engine.IO session id. Every later request, on any transport, carries it as the |
|
Transports the server accepts as an upgrade. Empty when the client already uses WebSocket, or when |
|
Milliseconds between server pings (default 25000). |
|
Milliseconds the peer has to answer a ping (default 20000 since 4.0.0). |
|
Largest number of bytes the client should group into one polling POST; it follows |
Engine.IO has seven packet types, each a single digit at the start of the packet.
| Digit | Name | Use |
|---|---|---|
|
open |
Server to client, first packet, carries the handshake JSON. |
|
close |
Asks the other side to close the transport. |
|
ping |
Server to client every |
|
pong |
The answer; also |
|
message |
Carries one Socket.IO packet (the text after the |
|
upgrade |
Client to server, completes the switch to a new transport. |
|
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)
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 |
|---|---|---|
|
The server called |
no |
|
The client called |
no |
|
No pong, or no ping, within the heartbeat window. |
yes |
|
The connection closed: network lost, tab closed, proxy idle timeout. |
yes |
|
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) |
|---|---|---|
|
CONNECT |
client |
|
DISCONNECT |
|
|
EVENT |
|
|
ACK |
|
|
CONNECT_ERROR |
|
|
BINARY_EVENT |
|
|
BINARY_ACK |
like ACK, with attachments |
Points that surprise newcomers:
-
Namespaces. A client must send a
CONNECTfor 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 showed40/admin,{"sid":"fHE7...","pid":"2Zo1..."}on the same connection, with a Socket.IOsiddifferent from the Engine.IO one. -
Authentication. The payload of the client
CONNECTis theauthoption. A middleware that callsnext(err)makes the server answerCONNECT_ERRORwith an object, anderr.databecomes thedatafield. 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
ACKis how thetimeoutofsocket.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\x1ein polling). TheACKof a binary event used the plain3packet (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,newListenerandremoveListenercannot be emitted by the application. -
Custom parsers. The default
socket.io-parsercan 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 |
|---|---|---|
|
|
Must equal the server |
|
|
|
|
|
Whether to upgrade, and whether to start directly on WebSocket after an earlier successful upgrade. |
|
|
4.8.0: fall through the list when a transport fails. |
|
none |
Object or function; sent in the |
|
none |
Added to every HTTP request as query parameters; visible in logs, so never put secrets there. |
|
none |
Node.js and polling only; browsers cannot set arbitrary headers on WebSocket. |
|
|
Send cookies on cross-origin polling; then the server |
|
|
Connection attempt timeout, before |
|
|
Plus |
|
none |
4.6.0: resend unacknowledged events after reconnection, with a per-packet id; implies at-least-once. |
|
none |
4.6.0: default timeout for acknowledgements when |
|
|
With false, call |
|
|
4.1.0: close the socket on |
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);
Source: Socket.IO docs: Client options
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. Whensocket.recoveredis false, resynchronise your own state. -
retriesresends events, so handlers must be idempotent. Chatterbox’s client-generated message ids make that safe. -
An
emitwhile disconnected is buffered by default and sent after reconnection. Usesocket.volatile.emitfor 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 theauthpayload 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 ( |
Interoperates with Socket.IO 3.x and 4.x servers (protocol revision 5). Also a server. |
Java |
|
Official. Built on |
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; |
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
corsoption does not protect WebSocket. It only emits CORS headers on the polling responses. A browser sends the page’sOriginon 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 withOrigin: https://evil.examplewas accepted by a server that configured onlycors. UseallowRequest, 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 theauthpayload; drop theorigin === undefinedclause on browser-only servers. Source: Socket.IO docs: Handling CORS -
Authenticate in the
CONNECTpayload 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.
maxHttpBufferSizeprotects 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 |
|---|---|
|
Wrong URL or |
400 |
Polling requests reach different instances without sticky sessions, or the session timed out. |
400 |
Path mismatch between client and server, or |
Connection works locally, not behind the load balancer |
The proxy lacks the |
|
Middleware refused; |
|
A proxy idle timeout shorter than |
|
The message exceeded |
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 theCONNECT_ERRORpayload became an object. -
3.0.0 also stopped sending permissive CORS headers by default, which is why the
corsoption exists; 4.0.0 (March 2021) setpingTimeoutto 20000 ms. -
4.1.0 added
closeOnBeforeunload; 4.4.0 addedsocket.timeout(); 4.6.0 addedemitWithAck,retries,ackTimeout,socket.recoveredand connection state recovery; 4.7.0 added WebTransport; 4.8.0 addedtryAllTransports. 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.
Related pages
-
Socket.IO (Node.js) for the server API, rooms, adapters and scaling.
-
python-socketio for the Python server.
-
The WebSocket Protocol and The Browser WebSocket API for the layer underneath.
-
Server-Sent Events and Long Polling for the fallback mechanics.
-
Choosing a WebSocket Stack to decide whether the Socket.IO protocol is worth its framing overhead.