Add Bidirectional Real-Time Messaging to a Koa Server
WebSockets in Koa
Attach a ws WebSocket server to Koa's underlying http.Server via app.callback(), handle upgrade events, and implement a simple broadcast room pattern for group messaging.
What you'll learn
- Attach the ws library to Koa's http.Server without third-party wrappers
- Handle WebSocket connections, messages, and disconnections in Koa
- Implement a room-based broadcast so messages reach only relevant clients
WebSockets provide a persistent, bidirectional channel between client and server. Unlike SSE, the client can also send messages — making WebSockets the right choice for chat, collaborative editing, and live game state.
Attaching ws to Koa
The ws package is framework-agnostic. Retrieve Koa’s native http.Server via
app.callback() and pass it to the WebSocket server.
npm install ws import Koa from 'koa';
import http from 'node:http';
import { WebSocketServer } from 'ws';
import router from './routes/index.js';
const app = new Koa();
app.use(router.routes()).use(router.allowedMethods());
const server = http.createServer(app.callback());
const wss = new WebSocketServer({ server });
wss.on('connection', (ws, req) => {
console.log('Client connected from', req.socket.remoteAddress);
ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString());
handleMessage(ws, msg);
});
ws.on('close', () => {
console.log('Client disconnected');
leaveAllRooms(ws);
});
ws.on('error', (err) => console.error('WS error:', err));
});
server.listen(3000, () => console.log('Listening on :3000')); Room-Based Broadcast
Rooms are just Set instances keyed by room name. When a message arrives,
broadcast it only to clients in the same room.
// rooms.js
const rooms = new Map(); // roomId -> Set<WebSocket>
export function joinRoom(roomId, ws) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
}
export function leaveAllRooms(ws) {
for (const members of rooms.values()) members.delete(ws);
}
export function broadcast(roomId, payload, sender) {
const members = rooms.get(roomId);
if (!members) return;
const data = JSON.stringify(payload);
for (const client of members) {
if (client !== sender && client.readyState === 1 /* OPEN */) {
client.send(data);
}
}
} // handleMessage.js
import { joinRoom, broadcast } from './rooms.js';
export function handleMessage(ws, msg) {
if (msg.type === 'join') {
joinRoom(msg.room, ws);
ws.send(JSON.stringify({ type: 'joined', room: msg.room }));
return;
}
if (msg.type === 'chat') {
broadcast(msg.room, { type: 'chat', text: msg.text, from: msg.from }, ws);
return;
}
} Heartbeat (Ping/Pong)
Proxies and load balancers close idle connections. Send a ping every 30 seconds and terminate any connection that does not respond.
wss.on('connection', (ws) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) { ws.terminate(); continue; }
ws.isAlive = false;
ws.ping();
}
}, 30_000); koa-websocket (Alternative)
koa-websocket wraps the above pattern with Koa-style middleware routing if
you prefer to keep WebSocket handlers alongside REST routes. It exposes
app.ws.use() and works with @koa/router, though the plain ws approach
above is often simpler and requires fewer dependencies.
Up Next
Explore GraphQL over Koa using graphql-http for a type-safe query layer on
top of your data services.