Skip to main content

Guide

Socket.IO vs ws vs uWebSockets.js 2026

Compare Socket.IO v4, ws, and uWebSockets.js for real-time WebSocket servers in Node.js. Performance, rooms, reconnection, protocol overhead, and which to use.

·PkgPulse Team·
0

TL;DR

Socket.IO is the highest-level choice — built-in rooms, namespaces, reconnection, and events abstraction make it the fastest path to a real-time feature. ws is the standard low-level WebSocket implementation — small, fast, and correct when you want raw WebSocket protocol without abstractions. uWebSockets.js is the extreme performance option — C++ bindings give it 5-10x throughput over ws for applications needing to handle tens of thousands of concurrent connections.

Key Takeaways

  • socket.io: ~5.3M weekly downloads — built-in rooms, reconnection, namespaces, fallback transports
  • ws: ~85M weekly downloads — the Node.js standard WebSocket library, used by most frameworks
  • uWebSockets.js: ~200K weekly downloads — C++ backed, highest raw throughput, low memory
  • ws's high download count is mostly transitive — it's a dependency of VS Code, Next.js, etc.
  • Socket.IO adds ~20KB client bundle and has slightly more overhead than raw WebSocket
  • uWebSockets.js is not a drop-in for ws — different API, requires different approach
  • For most chat/notification/dashboard apps: Socket.IO is the pragmatic choice

PackageWeekly DownloadsProtocolNative API
ws~85MWebSocket only✅ Standard WS
socket.io~5.3MWS + HTTP polling❌ Custom event API
uWebSockets.js~200KWS + HTTP❌ Custom API

Architecture Overview

Raw WebSocket Flow (ws):
  Client ← RFC 6455 WebSocket → Server
  No rooms, no reconnect, no events — you build everything

Socket.IO Flow:
  Client ← Socket.IO Protocol (over WS or polling) → Server
  Built-in: events, rooms, namespaces, reconnection, binary data

uWebSockets.js Flow:
  Client ← RFC 6455 WebSocket → Server (C++ stack)
  Low-level API, but extremely fast native implementation

Socket.IO

Socket.IO v4 is the most developer-friendly real-time library — it handles reconnection, rooms, namespaces, and event broadcasting automatically.

Server Setup

import { createServer } from "http"
import { Server } from "socket.io"
import express from "express"

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

const io = new Server(httpServer, {
  cors: {
    origin: ["https://pkgpulse.com", "http://localhost:3000"],
    methods: ["GET", "POST"],
  },
  // Connection settings:
  pingTimeout: 60000,    // Close after 60s of inactivity
  pingInterval: 25000,   // Ping every 25s
  transports: ["websocket", "polling"],  // Fallback to polling if WS fails
})

// Global middleware — runs on every connection:
io.use(async (socket, next) => {
  const token = socket.handshake.auth.token
  try {
    const user = await verifyJwt(token)
    socket.data.user = user  // Attach to socket
    next()
  } catch {
    next(new Error("Unauthorized"))
  }
})

io.on("connection", (socket) => {
  const user = socket.data.user
  console.log(`${user.name} connected:`, socket.id)

  // Join rooms based on user's subscriptions:
  socket.join(`user:${user.id}`)     // Personal room
  socket.join("global-feed")         // Public room

  // Event handlers:
  socket.on("subscribe:package", async (packageName) => {
    socket.join(`package:${packageName}`)
    console.log(`${user.name} subscribed to ${packageName}`)
  })

  socket.on("unsubscribe:package", (packageName) => {
    socket.leave(`package:${packageName}`)
  })

  socket.on("disconnect", (reason) => {
    console.log(`${user.name} disconnected:`, reason)
  })
})

httpServer.listen(3001)

Broadcasting and Rooms

// Emit to all clients in a room:
io.to("global-feed").emit("package:updated", { name: "react", downloads: 25100000 })

// Emit to a specific user:
io.to(`user:${userId}`).emit("notification", { message: "Package alert triggered" })

// Emit to everyone in a package room except the sender:
socket.to(`package:react`).emit("new:subscriber", { count: 14523 })

// Emit to multiple rooms at once:
io.to(["package:react", "package:vue"]).emit("ecosystem:update", eventData)

// Broadcast to everyone (including sender):
io.emit("server:announcement", { message: "Scheduled maintenance tonight" })

// Emit with acknowledgment (confirm receipt):
socket.emit("data:sync", { packages: latestData }, (ack) => {
  if (ack.success) {
    console.log("Client confirmed data received")
  }
})

Namespaces

// Split functionality into namespaces:
const packagesNs = io.of("/packages")
const analyticsNs = io.of("/analytics")

packagesNs.on("connection", (socket) => {
  socket.emit("packages:list", cachedPackages)
})

analyticsNs.on("connection", (socket) => {
  socket.on("track:view", (packageName) => {
    incrementViewCount(packageName)
  })
})

React Client

// components/PackageWatcher.tsx
import { useEffect, useState } from "react"
import { io, Socket } from "socket.io-client"

let socket: Socket | null = null

function getSocket(token: string) {
  if (!socket) {
    socket = io("https://api.pkgpulse.com", {
      auth: { token },
      transports: ["websocket"],  // Skip polling, WS only
    })
  }
  return socket
}

function PackageWatcher({ packageName, token }: Props) {
  const [downloads, setDownloads] = useState(0)
  const [connected, setConnected] = useState(false)

  useEffect(() => {
    const ws = getSocket(token)

    ws.on("connect", () => setConnected(true))
    ws.on("disconnect", () => setConnected(false))
    ws.on("package:updated", (data) => {
      if (data.name === packageName) setDownloads(data.downloads)
    })

    ws.emit("subscribe:package", packageName)

    return () => {
      ws.emit("unsubscribe:package", packageName)
      ws.off("package:updated")
    }
  }, [packageName, token])

  return <div>{connected ? `${downloads.toLocaleString()}/wk` : "Connecting..."}</div>
}

ws

ws is the standard Node.js WebSocket implementation — you get raw WebSocket protocol, nothing more. No reconnect, no rooms, no events — just the wire protocol.

import { WebSocketServer, WebSocket } from "ws"
import { IncomingMessage } from "http"

// Server:
const wss = new WebSocketServer({
  port: 3001,
  // Verify client on connection:
  verifyClient: async ({ req }, callback) => {
    const token = new URL(req.url!, "http://localhost").searchParams.get("token")
    try {
      const user = await verifyJwt(token!)
      (req as any).user = user
      callback(true)
    } catch {
      callback(false, 401, "Unauthorized")
    }
  },
})

// Track connected clients:
const clients = new Map<string, Set<WebSocket>>()  // packageName → clients

wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
  const user = (req as any).user
  let subscribedPackage: string | null = null

  ws.on("message", (data) => {
    const msg = JSON.parse(data.toString())

    switch (msg.type) {
      case "subscribe":
        subscribedPackage = msg.packageName
        if (!clients.has(msg.packageName)) {
          clients.set(msg.packageName, new Set())
        }
        clients.get(msg.packageName)!.add(ws)
        break

      case "unsubscribe":
        if (subscribedPackage) {
          clients.get(subscribedPackage)?.delete(ws)
          subscribedPackage = null
        }
        break
    }
  })

  ws.on("close", () => {
    if (subscribedPackage) {
      clients.get(subscribedPackage)?.delete(ws)
    }
  })

  // Send welcome message:
  ws.send(JSON.stringify({ type: "connected", userId: user.id }))
})

// Broadcast to all subscribers of a package:
function broadcastPackageUpdate(packageName: string, data: PackageUpdate) {
  const packageClients = clients.get(packageName)
  if (!packageClients) return

  const message = JSON.stringify({ type: "package:updated", data })

  packageClients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message)
    }
  })
}

ws for testing/utilities:

// ws is great for scripts, CLI tools, testing:
import WebSocket from "ws"

const ws = new WebSocket("wss://api.pkgpulse.com/ws?token=abc123")

ws.on("open", () => {
  ws.send(JSON.stringify({ type: "subscribe", packageName: "react" }))
})

ws.on("message", (data) => {
  const msg = JSON.parse(data.toString())
  console.log("Received:", msg)
})

ws.on("close", () => console.log("Disconnected"))
ws.on("error", (err) => console.error("Error:", err))

uWebSockets.js

uWebSockets.js binds to a C++ WebSocket/HTTP implementation — it's significantly faster than ws for high-concurrency workloads.

import { App, WebSocket } from "uWebSockets.js"

interface UserData {
  userId: string
  subscribedPackage?: string
}

App()
  .ws<UserData>("/*", {
    // Connection upgrade handler:
    upgrade: (res, req, context) => {
      const token = req.getQuery("token")
      // Async auth — must be synchronous in upgrade handler:
      const userId = verifyJwtSync(token)

      if (!userId) {
        res.writeStatus("401").end("Unauthorized")
        return
      }

      res.upgrade(
        { userId },  // UserData
        req.getHeader("sec-websocket-key"),
        req.getHeader("sec-websocket-protocol"),
        req.getHeader("sec-websocket-extensions"),
        context
      )
    },

    // Connection opened:
    open: (ws) => {
      console.log("Client connected:", ws.getUserData().userId)
    },

    // Message received:
    message: (ws, message, isBinary) => {
      const msg = JSON.parse(Buffer.from(message).toString())
      const userData = ws.getUserData()

      if (msg.type === "subscribe") {
        // uWS native topic subscriptions — extremely efficient:
        ws.subscribe(`package:${msg.packageName}`)
        userData.subscribedPackage = msg.packageName
      }
    },

    // Backpressure handling (important at scale):
    drain: (ws) => {
      console.log("WebSocket backpressure:", ws.getBufferedAmount())
    },

    close: (ws, code, message) => {
      console.log("Disconnected:", ws.getUserData().userId)
    },
  })
  .listen(3001, (token) => {
    if (token) console.log("Listening on port 3001")
  })

// uWS built-in pub/sub — publish to all subscribers of a topic:
// Must be done from within a message handler or a server context:
App().publish(`package:react`, JSON.stringify({ downloads: 25000000 }), false)

uWebSockets.js performance characteristics:

  • Handles 50K+ concurrent connections on a single core
  • Memory usage ~5x lower than ws at the same connection count
  • Sub-millisecond message dispatch latency
  • Built-in publish/subscribe avoids manual client tracking

Feature Comparison

FeatureSocket.IOwsuWebSockets.js
Rooms/channels✅ Built-in❌ Manual✅ Pub/sub native
Reconnection✅ Auto❌ Manual❌ Manual
Event system✅ Named events❌ Raw binary❌ Raw binary
HTTP polling fallback
TypeScript
Binary messages
Compression
Authentication✅ Middleware✅ verifyClient✅ upgrade handler
Performance (msg/s)~100K~400K~2M+
Memory (10K conns)~400MB~200MB~40MB
Client library needed✅ socket.io-client❌ (native WS)❌ (native WS)
Ecosystem/plugins✅ Large✅ Middleware❌ Small

When to Use Each

Choose Socket.IO if:

  • Building chat, live comments, collaborative features, notifications
  • You want built-in rooms, reconnection, and event broadcasting without boilerplate
  • The 20KB client bundle is acceptable
  • You need HTTP polling fallback for corporate firewalls or proxies

Choose ws if:

  • You need raw WebSocket with no protocol overhead
  • Building tooling, scripts, or test utilities that connect to WebSocket servers
  • You want a dependency used by most Node.js frameworks (stable, trusted)
  • The application doesn't need rooms or reconnection

Choose uWebSockets.js if:

  • Expected concurrent connections exceed 10,000
  • Memory efficiency is critical (embedded systems, low-cost servers)
  • You're building the WebSocket server for a gaming, trading, or IoT application
  • You've benchmarked and confirmed ws is the bottleneck

Scaling Patterns: Horizontal Scaling and Redis Adapters

A single-process WebSocket server works for development and small production deployments, but production applications serving thousands of users typically run behind a load balancer with multiple Node.js processes. This is where the three libraries diverge significantly in how much infrastructure they require.

With raw ws, you build horizontal scaling yourself. Each process has its own in-memory client map, so a message published on process A will not reach clients connected to process B. The standard pattern is a Redis pub/sub layer: each process subscribes to a Redis channel, and broadcasting a message means publishing to Redis so all processes re-broadcast to their local clients. This is correct but requires building the fan-out logic manually, handling Redis reconnection, and managing the publish/subscribe lifecycle.

Socket.IO solves this with first-class adapter support. The @socket.io/redis-adapter package wraps this exact pattern — replacing Socket.IO's in-memory room store with a Redis-backed one. Switching a single-process Socket.IO server to multi-process horizontal scaling is a few lines of configuration:

import { createAdapter } from "@socket.io/redis-adapter"
import { createClient } from "redis"

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

await Promise.all([pubClient.connect(), subClient.connect()])

io.adapter(createAdapter(pubClient, subClient))
// io.to("room").emit() now works across all processes

uWebSockets.js's native pub/sub system is process-local by default. For horizontal scaling, you apply the same Redis fan-out pattern manually — the App().publish() method reaches all local subscribers, but inter-process broadcasting requires your own Redis layer or a message queue.

Authentication Patterns and Security Considerations

Authentication is handled differently across the three libraries in ways that affect security posture. With ws, authentication happens in the verifyClient callback during the HTTP upgrade handshake — you have access to the raw IncomingMessage and must synchronously call the callback with a boolean (true to allow, false to reject). This is limiting because async operations (like a database lookup to verify a token) cannot naturally fit into the synchronous callback interface; workarounds exist but add complexity.

Socket.IO's middleware system is more ergonomic for auth: the middleware function receives a socket object and a next callback, supports full async/await, and can attach validated user data to socket.data before any event handlers run. JWT verification, session lookup, and role-based room assignment all happen naturally in a single async middleware function.

uWebSockets.js handles auth in the upgrade handler, which must be synchronous for the upgrade itself but can defer actual message handling after the fact. The uWS documentation recommends storing auth tokens in UserData during upgrade and validating them before processing messages if the upgrade step cannot validate them fully.

For all three libraries, the most common security mistake is attaching a DataLoader or database client to a WebSocket without scoping it to the connection. A global DataLoader instance accumulates cache from all users across all connections — the same class of bug that affects GraphQL APIs. Per-connection context objects, similar to the per-request context pattern in GraphQL, are the correct mental model.

Choosing a Deployment Strategy

The deployment model interacts with library choice in ways that matter at production scale. Socket.IO requires sticky sessions when deployed behind a load balancer without the Redis adapter — WebSocket upgrade requests must reach the same server process as the initial HTTP polling request, which is the default transport before the WebSocket upgrade. Most load balancers (nginx, AWS ALB) support sticky sessions via cookie or source IP, but misconfigured sticky sessions are a common source of unexpected disconnections and debugging headaches.

ws and uWebSockets.js have no such constraint because they use pure WebSocket connections from the first request. Any load balancer that supports WebSocket proxying (all modern ones do, via Upgrade: websocket header forwarding) works without sticky session configuration.

Methodology

Download data from npm registry (weekly average, February 2026). Performance benchmarks referenced from published community benchmarks (note: ws's download count includes all transitive dependents). Feature comparison based on Socket.IO 4.x, ws 8.x, and uWebSockets.js 20.x.

Compare realtime and networking packages on PkgPulse →

A point often missed in WebSocket library comparisons is how graceful disconnection and reconnection interplay with your session model. Socket.IO's client-side reconnection is automatic and exponential-backoff configurable, but after reconnection the socket.id changes — any server-side logic that keyed off socket.id (like tracking which user is connected) must be re-executed in the connect event handler on the client. The session data stored in socket.data is lost on reconnect and must be re-established via middleware (re-running auth). With raw ws, you implement reconnection logic yourself, which means you can design it around your session model from the start — storing the user token in closure scope so reconnection re-authenticates seamlessly. uWebSockets.js has no reconnection concept at all on the server side; the client is responsible for reconnecting, and the server treats each new connection as fresh. For long-lived real-time features like collaborative editing, the reconnection and state restoration architecture is more important than raw throughput.

See also: SSE vs WebSocket vs Long Polling and cac vs meow vs arg 2026, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.