Socket io connection test
Test the handshake before the socket: curl 'http://host/socket.io/?EIO=4&transport=polling' answers 200 with 0{"sid":"...","upgrades":["websocket"],"pingInterval":25000}. That body proves a Socket.IO server is there. Then connect a real client and print socket.io.engine.transport.name, which reads polling on the first request and websocket after the upgrade. Neither step needs a browser.
Why check this
Socket.IO is not WebSocket. It runs its own Engine.IO protocol, which by default opens with an HTTP long-polling request and upgrades afterwards. The first network log entry is a GET returning JSON, not a 101, so a tester filtering for 101 reports a failure that is not there.
Run this after any deploy that moves the socket path, changes the proxy, or bumps the client library. It prevents a version split: socket.io-client 2 against a server on 4 reports xhr poll error and nothing else, so a team spends a day on firewall rules for a mismatch the server named in a 400.
Prerequisites
- Node 22.23.2.
npm i socket.io@4 socket.io-client@4in an empty folder. The captures came from socket.io 4.8.3, socket.io-client 4.8.3 and engine.io 6.6.10. Record those versions, because the wire protocol changed at Socket.IO 3.- curl. Any build reads the handshake, including one with no
ws://support, because it is an ordinary GET. This page used 8.21.0. - The server below, saved as
sio-server.mjs. Port 9411 was free here. - Every capture is local. On 127.0.0.1 the upgrade finishes in milliseconds, so the timings say nothing about a real network, where proxies and latency break upgrades.
// sio-server.mjs node 22, npm i socket.io@4
import { Server } from 'socket.io';
const io = new Server(9411);
io.engine.on('connection', (raw) => {
console.log('engine open sid', raw.id, 'transport', raw.transport.name);
raw.on('upgrade', (t) => console.log('engine upgrade sid', raw.id, 'to', t.name));
});
io.on('connection', (socket) => {
console.log('io connect ', socket.id, 'transport', socket.conn.transport.name);
socket.on('echo', (msg, ack) => ack({ echo: msg }));
socket.on('disconnect', (reason) => console.log('io disconnect', socket.id, reason));
});
console.log('socket.io listening on 9411');
- The client below, saved as
sio-client.mjs. Steps 2 and 4 both run it.
// sio-client.mjs node 22, npm i socket.io-client@4
// node sio-client.mjs default transports
// node sio-client.mjs ws websocket only
import { io } from 'socket.io-client';
const wsOnly = process.argv[2] === 'ws';
const socket = io('http://127.0.0.1:9411', wsOnly ? { transports: ['websocket'] } : {});
const engine = socket.io.engine;
engine.on('open', () => console.log('engine open transport', engine.transport.name));
engine.on('upgrade', (t) => console.log('engine upgrade transport', t.name));
socket.on('connect', () => {
console.log('connect id', socket.id, 'transport', engine.transport.name);
socket.emit('echo', 'hello', (ack) => {
console.log('ack ', JSON.stringify(ack), 'transport', engine.transport.name);
socket.disconnect();
process.exit(0);
});
});
socket.on('connect_error', (e) => console.log('connect_error ', e.message));
setTimeout(() => { console.log('no connect after 5000 ms'); process.exit(1); }, 5000);
Steps
- Step 1.
Ask for a handshake over polling, with no client library.
curl -s -i "http://127.0.0.1:9411/socket.io/?EIO=4&transport=polling"HTTP/1.1 200 OK Content-Type: text/plain; charset=UTF-8 Content-Length: 118 cache-control: no-store Date: Sat, 12 Sep 2026 07:27:06 GMT Connection: keep-alive Keep-Alive: timeout=5 0{"sid":"0HHk84v-yVQLHZl1AAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}The leading
0is the Engine.IO packet type for open.sidis the session every following request must carry. An emptyupgradesarray means the server stays on polling.pingIntervalandpingTimeoutare the heartbeat contract: a ping every 25 s, session dropped if no reply within 20 s. - Step 2.
Connect the real client with default options.
node sio-client.mjsengine open transport polling engine upgrade transport websocket connect id 5s2JcjhiEj1d0XabAAAA transport websocket ack {"echo":"hello"} transport websocketThe session opened on
polling, moved towebsocketon its own, and a round trip with an acknowledgement worked. The id on theconnectline is the socket id, not thesidfrom step 1. - Step 3.
Read the same connection from the server side.
tail -4 server.logengine open sid _rAXVJh2P-ZjiY-1AAAB transport polling io connect 5s2JcjhiEj1d0XabAAAA transport polling engine upgrade sid _rAXVJh2P-ZjiY-1AAAB to websocket io disconnect 5s2JcjhiEj1d0XabAAAA client namespace disconnectThe connection event fires while the transport is still
polling. Server code readingsocket.conn.transport.nameinside that handler recordspollingfor every client, including the ones that upgrade milliseconds later. Sample it on the engineupgradeevent instead. - Step 4.
Skip the polling step and open on a socket directly.
node sio-client.mjs wsengine open transport websocket connect id Wjd85FzpPFBTa8zgAAAB transport websocket ack {"echo":"hello"} transport websocketNo
engine upgradeline, because there was nothing to upgrade from.transports: ['websocket']makes the first request a real handshake, so a 101 does appear in the network log. It also removes the fallback: an intermediary that blocks the upgrade now breaks the client. - Step 5.
Point a plain WebSocket client at the endpoint and wait.
// raw-ws.mjs node 22, no dependency: the global WebSocket client const t0 = Date.now(); const el = () => String(Math.round((Date.now() - t0) / 1000)).padStart(2, ' ') + ' s'; const ws = new WebSocket(process.argv[2]); ws.onopen = () => console.log(el(), 'open readyState', ws.readyState); ws.onmessage = (e) => console.log(el(), 'message ', JSON.stringify(String(e.data))); ws.onerror = () => console.log(el(), 'error event fired, no reason available'); ws.onclose = (e) => console.log(el(), 'close code', e.code, 'reason', JSON.stringify(e.reason), 'wasClean', e.wasClean);node raw-ws.mjs "ws://127.0.0.1:9411/socket.io/?EIO=4&transport=websocket"0 s open readyState 1 0 s message "0{\"sid\":\"eQRkvRmbZ8VdCbwRAAAD\",\"upgrades\":[],\"pingInterval\":25000,\"pingTimeout\":20000,\"maxPayload\":1000000}" 25 s message "2" 45 s close code 1005 reason "" wasClean trueThe handshake succeeded. What follows is Engine.IO framing a plain client does not implement:
0with the session JSON, then2, the heartbeat ping. The client never answers with3, so 20 s later the server drops the session and the close arrives with code 1005 and an empty reason. From the client this reads as a server that disconnects for no reason after 45 s. - Step 6.
Ask for a protocol version the server no longer speaks.
curl -s -i "http://127.0.0.1:9411/socket.io/?EIO=3&transport=polling"HTTP/1.1 400 Bad Request Content-Type: application/json Date: Sat, 12 Sep 2026 07:27:07 GMT Connection: keep-alive Keep-Alive: timeout=5 Transfer-Encoding: chunked {"code":5,"message":"Unsupported protocol version"}EIO=3is what every Socket.IO 2 client sends. The server states the reason in one line. Keep it: the next step shows what a client makes of it. - Step 7.
Run an old client against the current server.
// client-v2.mjs node 22, npm i socket.io-client@2 import { createRequire } from 'node:module'; const io = createRequire(import.meta.url)('socket.io-client'); const socket = io('http://127.0.0.1:9411', { reconnection: false }); socket.on('connect', () => { console.log('connect id', socket.id); process.exit(0); }); socket.on('connect_error', (e) => console.log('connect_error ', e.message ?? e)); setTimeout(() => { console.log('timeout after 6000 ms, connected =', socket.connected); process.exit(1); }, 6000);node client-v2.mjsconnect_error xhr poll error timeout after 6000 ms, connected = falseThe server answered
Unsupported protocol versionand the client reportsxhr poll error. The status, the body and the word version are gone by the time the error reaches application code. Read the server response, not the client error.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 200 with a body starting 0{"sid": | A Socket.IO 4 server answers on that path | Continue with a real client. The transport is not decided yet. |
| 400 Unsupported protocol version | Client and server are on different Engine.IO versions | Align the library majors. A version 2 client and a version 4 server never connect. |
| upgrades is an empty array | The server will not move off polling | Check the proxy. Messages still flow, at polling latency. |
| Transport stays polling after connect | The upgrade was blocked, or was read too early | Sample the transport on the engine upgrade event, not at connect time. |
| 404 on the handshake URL | The server is not mounted at /socket.io/ | Compare the client path option with the server path option. |
| Socket opens, then closes near 45 s | The client is not answering the Engine.IO ping | A raw WebSocket client is talking to a Socket.IO server. Switch clients. |
Common mistakes
What to check next
- How to test websocket connection: the same test on a server that speaks plain WebSocket.
- Websocket handshake failed: reading a 101 that never arrived, with curl.
- How to check websocket connection in chrome: where the polling request and the upgrade appear in DevTools.
- Long polling test: the transport Socket.IO starts on, and what it costs.
- How to check websocket ping pong: the heartbeat that closed the raw client in step 5.
FAQ
How do I test a Socket.IO connection without writing code?
Run step 1. One curl of /socket.io/?EIO=4&transport=polling tells you the server is mounted, which protocol version it speaks, and whether it offers an upgrade.
Can I connect to a Socket.IO server with a raw WebSocket client?
The socket opens and then nothing works. Step 5 shows the server closing it after 45 s with code 1005, because the client never answers the Engine.IO ping. Use socket.io-client instead.
Why is the first request polling and not a WebSocket upgrade?
That is the Engine.IO default: open on long polling, then upgrade. Pass transports: ['websocket'] to skip it, as in step 4. The cost is the fallback.
Why does my client report xhr poll error?
The handshake returned something other than 200 and the client discarded the detail. Repeat it with curl and read the status and body. In step 6 the cause was EIO=3 against a version 4 server.
Verified
Verified by Maks Vernynode 22.23.2socket.io 4.8.3socket.io-client 4.8.3 and 2.5.0engine.io 6.6.10curl 8.21.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate9 minpublished updated Maks Verny