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

// 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');
// 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

  1. 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 0 is the Engine.IO packet type for open. sid is the session every following request must carry. An empty upgrades array means the server stays on polling. pingInterval and pingTimeout are the heartbeat contract: a ping every 25 s, session dropped if no reply within 20 s.

  2. Step 2.

    Connect the real client with default options.

    node sio-client.mjs
    
    engine open     transport polling
    engine upgrade  transport websocket
    connect         id 5s2JcjhiEj1d0XabAAAA transport websocket
    ack              {"echo":"hello"} transport websocket

    The session opened on polling, moved to websocket on its own, and a round trip with an acknowledgement worked. The id on the connect line is the socket id, not the sid from step 1.

  3. Step 3.

    Read the same connection from the server side.

    tail -4 server.log
    
    engine 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 disconnect

    The connection event fires while the transport is still polling. Server code reading socket.conn.transport.name inside that handler records polling for every client, including the ones that upgrade milliseconds later. Sample it on the engine upgrade event instead.

  4. Step 4.

    Skip the polling step and open on a socket directly.

    node sio-client.mjs ws
    
    engine open     transport websocket
    connect         id Wjd85FzpPFBTa8zgAAAB transport websocket
    ack              {"echo":"hello"} transport websocket

    No engine upgrade line, 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.

  5. 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 true

    The handshake succeeded. What follows is Engine.IO framing a plain client does not implement: 0 with the session JSON, then 2, the heartbeat ping. The client never answers with 3, 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.

  6. 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=3 is 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.

  7. 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.mjs
    
    connect_error    xhr poll error
    timeout after 6000 ms, connected = false

    The server answered Unsupported protocol version and the client reports xhr 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

Sign: The network log shows no 101, so the connection is reported as failed.Cause: With default options Socket.IO opens over HTTP long polling and upgrades after. The first request is a GET returning the handshake JSON, and the 101 arrives later as a separate entry. Filtering the log for 101 hides a working connection.
Sign: A plain WebSocket client points at the Socket.IO port and the connection dies with no explanation.Cause: Two different failures wear the same face. At the engine path the socket opens and the server closes it 45 s later with code 1005, because the client ignores the heartbeat. At the root path the server destroys the TCP connection with no HTTP response at all: curl exits 52, empty reply from server, and Node's global WebSocket fires error with readyState stuck at 0 and never fires close. Neither result says protocol mismatch.
Sign: Server metrics report every client on polling.Cause: The transport is read inside the connection handler, which fires before the upgrade. Step 3 shows the connect line on polling and the upgrade on the next line, for the same client. Sample the transport on the engine upgrade event.
Sign: connect_error says xhr poll error and the search for a cause starts at the firewall.Cause: socket.io-client 2 collapses any non-200 handshake into that one string. The same request run with curl returns 400 and the message Unsupported protocol version. The client error names the transport that failed, never the reason.

What to check next

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.

intermediate9 minpublished updated Maks Verny