Skip to content

Fix: Socket.IO Not Connecting (CORS, Transport, and Namespace Errors)

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix Socket.IO connection failures — CORS errors, transport fallback issues, wrong namespace, server not emitting to the right room, and how to debug Socket.IO connections in production.

The Error

The Socket.IO client fails to connect and logs errors like:

GET http://localhost:3001/socket.io/?EIO=4&transport=polling 404 (Not Found)

Or a CORS error:

Access to XMLHttpRequest at 'http://localhost:3001/socket.io/?...' from origin
'http://localhost:3000' has been blocked by CORS policy

Or the connection times out silently — the connect event never fires and the client keeps retrying:

socket.on('connect_error', (err) => {
  console.log(err.message); // "xhr poll error" or "websocket error"
});

Or the client connects but events are never received even though the server is emitting them.

Why This Happens

Socket.IO is not a thin WebSocket wrapper. It is a transport-abstraction layer with its own handshake protocol, namespace system, room registry, and reconnection state machine. Each of those layers introduces a different failure mode, and the connection error you see in the browser console usually points at the wrong layer.

The most common cause is CORS. Socket.IO performs an HTTP request to /socket.io/?EIO=4&transport=polling before it ever opens a WebSocket. That HTTP request is subject to the same CORS rules as any fetch call, so a server that lacks a CORS configuration will refuse the handshake before the WebSocket upgrade has a chance to happen. The second common cause is the transport: Socket.IO starts with HTTP long-polling and upgrades to WebSocket only after the handshake completes. If a proxy strips the Upgrade header or a load balancer routes follow-up polling requests to a different backend, the connection hangs in a half-open state where the client thinks it is connected and the server has already discarded the session.

The third bucket is logical mismatch: wrong namespace, wrong path, wrong protocol version, or a client/server pair that disagrees on event names. Socket.IO will happily complete a handshake to the default namespace / even when the application code expects /admin, leaving you with a “connected” socket that never receives any traffic. Version drift between socket.io and socket.io-client is the silent fourth cause — and because the major versions changed transports and handshake formats, a v4 client cannot talk to a v2 server without explicit opt-in.

  • CORS misconfiguration — Socket.IO requires explicit CORS configuration on the server. The default is to allow all origins in development, but in production it must be configured.
  • Wrong server URL or port — the client connects to a different port or path than where the Socket.IO server is listening.
  • Transport mismatch — by default Socket.IO starts with HTTP long-polling and upgrades to WebSocket. If the server or a proxy blocks WebSocket upgrades, the connection hangs.
  • Wrong namespace — connecting to /admin when the server defines /dashboard means the client connects but receives no events.
  • Proxy not configured for WebSocket — nginx, a load balancer, or a CDN terminates WebSocket connections without forwarding them.
  • Version mismatch — Socket.IO v4 client is not compatible with a Socket.IO v2 or v3 server.

Version History That Changes the Failure Mode

Socket.IO’s major versions changed both the wire protocol and the public API, which means the symptoms you see depend heavily on which version pair you are running:

  • Socket.IO v2.x (May 2018) — Used Engine.IO protocol v3. Clients sent the session ID as a URL parameter on every request. CORS was handled by the underlying engine.io library and defaulted to permissive. v2 clients cannot talk to a v3 or v4 server without allowEIO3: true.
  • Socket.IO v3.0 (November 2020) — Switched to Engine.IO protocol v4, removed the default CORS allow-all behavior (you now had to declare cors explicitly), changed disconnect reasons (io server disconnect, transport close, ping timeout became the canonical set), and replaced the bundled ws fork with the upstream ws package. A v3 client connecting to a v2 server fails the handshake with no useful error message — the request returns a 400.
  • Socket.IO v3.1 (January 2021) — Added the official Redis adapter API used for multi-instance deployments. Sticky sessions for HTTP long-polling became a documented requirement rather than folklore.
  • Socket.IO v4.0 (March 2021) — Redesigned the namespace API to support dynamic namespaces via regex (io.of(/^\/dynamic-\d+$/)), reworked the room broadcast API (io.to(...).emit(...) now returns a BroadcastOperator), and added typed events via TypeScript generics. The disconnect code set was finalized.
  • Socket.IO v4.4 (December 2021) — Introduced the catch-all listener (socket.onAny) and added support for HTTP/2 servers as the underlying transport on Node 16+.
  • Socket.IO v4.6 (December 2022) — Added the connection state recovery feature so a brief disconnect no longer drops queued events. Requires both client and server on v4.6+.
  • Socket.IO v4.7 (May 2023) — Added WebTransport as an experimental third transport alongside polling and WebSocket, available on Chromium-based browsers.

If the client and server are on different majors, force the matching protocol. A v4 server can accept v3 clients with allowEIO3: true, but a v3 server cannot accept a v4 client at all. The ws library transition between v2 and v3 also means that custom wsEngine overrides written for v2 will not compile against v3+.

Fix 1: Configure CORS on the Server

// server.js — Express + Socket.IO
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');

const app = express();
const httpServer = createServer(app);

const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:3000',      // Client origin — must match exactly
    methods: ['GET', 'POST'],
    credentials: true,                    // Required if client sends cookies
  },
});

// For multiple origins
const io = new Server(httpServer, {
  cors: {
    origin: [
      'http://localhost:3000',
      'https://myapp.com',
      'https://www.myapp.com',
    ],
    methods: ['GET', 'POST'],
  },
});

// For development — allow all origins (not for production)
const io = new Server(httpServer, {
  cors: { origin: '*' },
});

io.on('connection', (socket) => {
  console.log('Client connected:', socket.id);
});

httpServer.listen(3001);

Client — match the server URL:

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

const socket = io('http://localhost:3001', {  // Server port, not client port
  withCredentials: true,   // Must match server's credentials: true
});

Common Mistake: Running the Express API on port 3001 and the React dev server on port 3000. The Socket.IO client must connect to port 3001 (where Socket.IO lives), not port 3000. Set the client URL to http://localhost:3001.

Fix 2: Fix Transport Issues (WebSocket Upgrade Fails)

If connections hang on long-polling and never upgrade to WebSocket, force WebSocket-only or fix the proxy:

Client — force WebSocket transport:

const socket = io('http://localhost:3001', {
  transports: ['websocket'],  // Skip polling, use WebSocket directly
});

Client — fallback order (default behavior):

const socket = io('http://localhost:3001', {
  transports: ['polling', 'websocket'],  // Start with polling, upgrade to WS
});

Fix nginx for WebSocket proxying:

# nginx.conf — proxy both HTTP polling and WebSocket upgrade
location /socket.io/ {
    proxy_pass http://localhost:3001;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;       # Required for WebSocket
    proxy_set_header Connection "upgrade";         # Required for WebSocket
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_cache_bypass $http_upgrade;
    proxy_read_timeout 86400;                      # Keep alive for long-polling
}

Fix when deployed behind a load balancer (sticky sessions):

HTTP long-polling requires multiple requests to hit the same server instance. Without sticky sessions, requests land on different servers that don’t share socket state:

// server.js — use Redis adapter for multi-server deployments
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));

Or force WebSocket-only on the client (WebSocket is stateful — no sticky session needed):

const socket = io({ transports: ['websocket'] });

Fix 3: Fix Namespace Mismatch

Socket.IO namespaces are like separate channels. If the client and server use different namespaces, they cannot communicate:

Server — defining a namespace:

// Default namespace is '/'
io.on('connection', (socket) => { /* all clients on / */ });

// Custom namespace
const adminNsp = io.of('/admin');
adminNsp.on('connection', (socket) => { /* only /admin clients */ });

const dashboardNsp = io.of('/dashboard');
dashboardNsp.on('connection', (socket) => { /* only /dashboard clients */ });

Client — connecting to a namespace:

// Default namespace
const socket = io('http://localhost:3001');
// Equivalent to: io('http://localhost:3001/')

// Custom namespace — must match server exactly
const socket = io('http://localhost:3001/admin');
const socket = io('http://localhost:3001/dashboard');

// Wrong — this connects to '/' not '/admin'
const socket = io('http://localhost:3001', { path: '/admin' });
// 'path' is for the Socket.IO server path, not the namespace

Note: The path option (default: /socket.io) is different from a namespace. The path is the HTTP endpoint for the Socket.IO handshake. The namespace is a logical channel within a single Socket.IO server.

Fix 4: Fix Room and Event Emission Issues

If the client connects successfully but never receives events:

Verify the client is in the right room:

// Server — emit to a room
io.to('room-123').emit('message', data);

// Client must have joined the room first
socket.on('connect', () => {
  socket.emit('join-room', 'room-123');
});

// Server — handle room join
io.on('connection', (socket) => {
  socket.on('join-room', (roomId) => {
    socket.join(roomId);
    console.log(`${socket.id} joined room ${roomId}`);
  });
});

Common emission mistakes:

// Server-side emission patterns

// Wrong — emits only to the sender
socket.emit('event', data);

// Correct — emits to all clients
io.emit('event', data);

// Correct — emits to all clients in a room
io.to('room-id').emit('event', data);

// Correct — emits to all clients except the sender
socket.broadcast.emit('event', data);

// Correct — emits to a specific socket by ID
io.to(socketId).emit('event', data);

Verify event names match exactly:

// Server emits 'user-joined'
socket.emit('user-joined', { name: 'Alice' });

// Client listens for 'userJoined' — event name mismatch, never received
socket.on('userJoined', (data) => { ... }); // ← Wrong

// Fix — exact same event name
socket.on('user-joined', (data) => { ... }); // ✓

Fix 5: Fix Socket.IO Behind a Reverse Proxy (Path Configuration)

When Socket.IO is mounted at a sub-path (not the root):

Server:

const io = new Server(httpServer, {
  path: '/ws/socket.io',  // Custom path instead of default /socket.io
  cors: { origin: 'https://myapp.com' },
});

Client:

const socket = io('https://myapp.com', {
  path: '/ws/socket.io',  // Must match server path
});

nginx for sub-path:

location /ws/ {
    proxy_pass http://localhost:3001;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
}

Fix 6: Fix Version Compatibility

Socket.IO v4 (client) is not compatible with Socket.IO v2/v3 (server):

# Check installed versions
cat package.json | grep socket.io
npm list socket.io socket.io-client

# Upgrade both server and client together
npm install socket.io@latest           # Server
npm install socket.io-client@latest    # Client

Major version compatibility:

ServerClient
v4.xv4.x (compatible)
v3.xv3.x (compatible)
v4.xv3.x
// Allow older clients to connect to v4 server
const io = new Server(httpServer, {
  allowEIO3: true,  // Accept Socket.IO v2/v3 clients
});

Fix 7: Debug Socket.IO Connections

Enable debug logging:

// Browser console — enable Socket.IO debug logs
localStorage.debug = 'socket.io-client:*';

// Node.js server
DEBUG=socket.io* node server.js

Listen to all connection events on the client:

const socket = io('http://localhost:3001');

socket.on('connect', () => {
  console.log('Connected! Socket ID:', socket.id);
  console.log('Transport:', socket.io.engine.transport.name); // 'polling' or 'websocket'
});

socket.on('connect_error', (error) => {
  console.error('Connection error:', error.message, error.description);
});

socket.on('disconnect', (reason) => {
  console.log('Disconnected:', reason);
  // reason: 'io server disconnect' | 'io client disconnect' | 'ping timeout' | 'transport close' | 'transport error'
});

socket.io.on('reconnect_attempt', (attempt) => {
  console.log('Reconnect attempt:', attempt);
});

Check the Socket.IO engine status:

socket.on('connect', () => {
  const transport = socket.io.engine.transport.name;
  console.log('Initial transport:', transport); // 'polling'

  socket.io.engine.on('upgrade', () => {
    const upgraded = socket.io.engine.transport.name;
    console.log('Upgraded to:', upgraded); // 'websocket'
  });
});

Still Not Working?

Test WebSocket directly. Use wscat to check if WebSocket connections work on the server:

npm install -g wscat
wscat -c ws://localhost:3001/socket.io/?EIO=4&transport=websocket

Check if the Socket.IO server is actually running on the expected port:

lsof -i :3001
netstat -tulnp | grep 3001

Check for double-initialization. In Express, attaching Socket.IO to app instead of the HTTP server is a common mistake:

// Wrong — attaches to Express app, not HTTP server
const io = new Server(app);

// Correct — attaches to the HTTP server
const httpServer = createServer(app);
const io = new Server(httpServer);
httpServer.listen(3001);

Check for a CDN or edge proxy stripping the Upgrade header. Cloudflare, Fastly, and CloudFront all require WebSockets to be explicitly enabled on the zone or distribution. If socket.io works locally but hangs on long-polling in production, run a request through your edge with curl -i -H 'Connection: Upgrade' -H 'Upgrade: websocket' https://myapp.com/socket.io/ and confirm the response keeps the upgrade headers intact.

Check that the kernel-level connection limit is not capped. Linux defaults to 1024 open file descriptors per process, which means a Socket.IO server can refuse new connections once you cross that threshold even though there is plenty of memory free. Run ulimit -n inside the container; if it returns 1024, raise it to 65535 via ulimit -n 65535 in the entrypoint or the systemd LimitNOFILE directive. The symptom is silent — the client gets ECONNRESET and the server log shows no error at all.

Check for a stale service worker intercepting the handshake. A service worker registered against the same origin can cache the initial socket.io polling response with the wrong session ID, and the upgrade then fails because the server never issued that ID. Unregister the worker in DevTools → Application → Service Workers, hard reload, and confirm the issue disappears before you go hunting for proxy bugs.

For related real-time and WebSocket issues, see Fix: nginx WebSocket Proxy Not Working, Fix: CORS Access-Control-Allow-Origin Error, Fix: Socket.IO CORS Error, and Fix: Express CORS Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles