Appearance
WebSockets
HTTP is request → response. WebSockets keep a duplex pipe open so the server can push - chat, live cursors, "note updated", progress bars. FE already met this via Socket.IO tutorials; here's the Express-adjacent version.
When you actually need them
| Need | Tool |
|---|---|
| Form submit, CRUD, auth | plain HTTP |
| Live feed, multiplayer presence, collaborative edits | WebSocket (or SSE for server→client only) |
Don't WebSocket your login form. 🙂
Mental model
Same origin concerns as HTTP (auth, CORS-ish). The browser opens ws://localhost:3000 (or wss:// in prod).
Two common libraries
ws | Socket.IO | |
|---|---|---|
| Protocol | Standard WebSocket | Own protocol + fallbacks |
| FE API | new WebSocket(url) | io(url) |
| Rooms / ack | DIY | Built-in |
| Teaching clarity | High | High for app features |
We'll use ws attached to the same HTTP server as Express - fewer magic layers. Socket.IO is fine in products; concepts transfer.
Attach to Express
bash
npm install ws
npm install -D @types/wsts
// src/index.ts
import http from 'node:http'
import { WebSocketServer } from 'ws'
import { createApp } from './app.js'
import { config } from './config.js'
import { initDb } from './db.js'
import { attachRealtime } from './realtime.js'
initDb()
const app = createApp()
const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws' })
attachRealtime(wss)
server.listen(config.port, () => {
console.log(`http+ws on http://localhost:${config.port}`)
})Important: app.listen() creates a server internally. For WS you need http.createServer(app) then server.listen so both share the port.
Auth on the socket
Browsers can't set arbitrary Authorization headers on the WebSocket constructor easily. Common patterns:
- Query token:
new WebSocket(\ws://localhost:3000/ws?token=${jwt}`)` - First message: connect, then
{ type: 'auth', token } - Cookie session: cookies go automatically on same-site WS
We'll verify JWT from the query string:
ts
// src/realtime.ts
import type { WebSocketServer, WebSocket } from 'ws'
import { verifyToken } from './tokens.js'
import { findUserById, publicUser } from './db.js'
type Client = WebSocket & { userId?: number }
export function attachRealtime(wss: WebSocketServer) {
wss.on('connection', (socket: Client, req) => {
try {
const url = new URL(req.url ?? '', 'http://localhost')
const token = url.searchParams.get('token')
if (!token) return socket.close(1008, 'unauthorized')
const userId = verifyToken(token)
const user = findUserById(userId)
if (!user) return socket.close(1008, 'unauthorized')
socket.userId = user.id
socket.send(JSON.stringify({ type: 'welcome', user: publicUser(user) }))
} catch {
socket.close(1008, 'unauthorized')
return
}
socket.on('message', (raw) => {
let msg: { type?: string; text?: string }
try {
msg = JSON.parse(String(raw))
} catch {
return
}
if (msg.type === 'ping') {
socket.send(JSON.stringify({ type: 'pong' }))
return
}
if (msg.type === 'chat' && typeof msg.text === 'string') {
const payload = JSON.stringify({
type: 'chat',
userId: socket.userId,
text: msg.text.slice(0, 500),
at: new Date().toISOString(),
})
// broadcast to everyone (toy chat)
for (const client of wss.clients) {
if (client.readyState === 1) client.send(payload)
}
}
})
})
}Comments for the code above:
- Close code
1008≈ policy violation / unauthorized - Always
JSON.parsein try/catch - clients send garbage - Broadcast is the simplest realtime pattern; rooms come next in real apps
Push from HTTP handlers
When a note is created over REST, notify sockets:
ts
// keep a module-level wss ref or event bus
export function broadcast(event: unknown) {
const raw = JSON.stringify(event)
for (const client of wss.clients) {
if (client.readyState === 1) client.send(raw)
}
}
// in notesRouter.post:
broadcast({ type: 'note:created', note })That's the "SPA list updates live" trick without polling.
SPA client
ts
const token = localStorage.getItem('token')
const ws = new WebSocket(`ws://localhost:3000/ws?token=${token}`)
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'ping' }))
})
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(String(ev.data))
if (msg.type === 'note:created') {
// update your store / query cache
}
})
ws.addEventListener('close', () => {
// optional: reconnect with backoff
})Vite proxy tip - proxy /ws with ws: true in server.proxy if you want same-origin during dev.
SSE alternative
Server-Sent Events (text/event-stream) are one-way (server → client) over HTTP. Simpler for "live notifications" without a full duplex protocol. WebSockets win when the client also streams up (games input, collab).
Production footguns
- Sticky sessions / pub-sub (Redis) when you have multiple Node processes - in-memory
wss.clientswon't see other boxes - Heartbeats / ping to detect dead connections
wss://behind your reverse proxy- Don't trust client
userIdin messages - use the id you set at auth time
Companion
examples/notes-api listens with HTTP + /ws. After login, connect with your JWT and try a { "type": "chat", "text": "hi" } message (wscat, browser console, or a tiny FE).
Next: caching - so you don't hit the DB on every polite GET.
Best,
-- ł.