Skip to main content

Socket.IO vs ws vs uWebSockets.js: WebSocket Servers in Node.js (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.