Skip to content

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 ​

NeedTool
Form submit, CRUD, authplain HTTP
Live feed, multiplayer presence, collaborative editsWebSocket (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 ​

wsSocket.IO
ProtocolStandard WebSocketOwn protocol + fallbacks
FE APInew WebSocket(url)io(url)
Rooms / ackDIYBuilt-in
Teaching clarityHighHigh 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/ws
ts
// 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:

  1. Query token: new WebSocket(\ws://localhost:3000/ws?token=${jwt}`)`
  2. First message: connect, then { type: 'auth', token }
  3. 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.parse in 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.clients won't see other boxes
  • Heartbeats / ping to detect dead connections
  • wss:// behind your reverse proxy
  • Don't trust client userId in 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,

-- ł.

Notes-style Express tutorial for front-end folks