Socket.IO vs ws vs uWebSockets.js: WebSocket Servers in Node.js (2026)
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
Download Trends
| Package | Weekly Downloads | Protocol | Native API |
|---|---|---|---|
ws | ~85M | WebSocket only | ✅ Standard WS |
socket.io | ~5.3M | WS + HTTP polling | ❌ Custom event API |
uWebSockets.js | ~200K | WS + 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
| Feature | Socket.IO | ws | uWebSockets.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.