WebSockets power the real-time web — chat apps, live dashboards, multiplayer games, collaborative editors. But when something goes wrong with a WebSocket connection, debugging it is surprisingly painful.
In this tutorial, we will build a full-featured WebSocket debugging CLI from scratch. By the end, you will have a tool that connects to any WebSocket endpoint, logs messages with timestamps, filters by pattern, measures latency, records sessions for replay, and supports multiple concurrent connections — all from your terminal.
Why Browser DevTools Fall Short
If you have ever tried to debug WebSocket traffic in Chrome DevTools, you know the frustration:
- No filtering: You cannot filter messages by content or pattern.
- No replay: You cannot resend a previous message without writing code.
- No recording: Once you close the tab, your message history is gone.
- No scripting: No way to automate sends or set up timed pings.
- No multi-connection view: You have to switch between connections manually.
Project Setup
mkdir ws-debugger && cd ws-debugger
npm init -y
npm install ws chalk commander readline
The Connection Manager
The heart of our tool is a connection manager that wraps the ws library and emits structured events.
const WebSocket = require('ws');
const EventEmitter = require('events');
class ConnectionManager extends EventEmitter {
constructor() {
super();
this.connections = new Map();
this.connectionCounter = 0;
}
connect(url, options = {}) {
const id = ++this.connectionCounter;
const ws = new WebSocket(url, options.protocols || [], {
headers: options.headers || {},
});
const connection = {
id, url, ws, createdAt: Date.now(),
messageCount: { sent: 0, received: 0 },
bytesTransferred: { sent: 0, received: 0 }
};
ws.on('open', () => this.emit('connected', { id, url }));
ws.on('message', (data, isBinary) => {
const message = isBinary ? data : data.toString();
connection.messageCount.received++;
this.emit('message', {
id, direction: 'incoming', data: message,
size: Buffer.byteLength(data), timestamp: Date.now()
});
});
ws.on('close', (code) => {
this.emit('disconnected', { id, url, code });
this.connections.delete(id);
});
ws.on('error', (error) => {
this.emit('error', { id, error: error.message });
});
this.connections.set(id, connection);
return id;
}
send(id, data) {
const conn = this.connections.get(id);
if (!conn) throw new Error('Connection not found');
const payload = typeof data === 'object' ? JSON.stringify(data) : data;
conn.ws.send(payload);
conn.messageCount.sent++;
}
getStats(id) {
const c = this.connections.get(id);
if (!c) return null;
return {
id: c.id, url: c.url,
uptime: Date.now() - c.createdAt,
messages: c.messageCount
};
}
}
Message Logging with Filtering
class MessageLogger {
constructor(options = {}) {
this.filters = [];
this.excludePatterns = [];
this.jsonPrettyPrint = options.pretty !== false;
}
addFilter(pattern) {
this.filters.push(new RegExp(pattern, 'i'));
}
addExclusion(pattern) {
this.excludePatterns.push(new RegExp(pattern, 'i'));
}
shouldDisplay(message) {
const text = typeof message === 'string' ? message : JSON.stringify(message);
for (const p of this.excludePatterns) {
if (p.test(text)) return false;
}
if (this.filters.length === 0) return true;
return this.filters.some(p => p.test(text));
}
}
Latency Measurement
class LatencyTracker {
constructor() {
this.measurements = [];
this.pendingPings = new Map();
}
startPing(connectionId, ws) {
const id = connectionId + '-' + Date.now();
this.pendingPings.set(id, process.hrtime.bigint());
ws.ping(id);
return id;
}
recordPong(pingId) {
const start = this.pendingPings.get(pingId);
if (!start) return null;
const latencyMs = Number(process.hrtime.bigint() - start) / 1_000_000;
this.pendingPings.delete(pingId);
this.measurements.push({ timestamp: Date.now(), latency: latencyMs });
return latencyMs;
}
getStats() {
if (!this.measurements.length) return { count: 0 };
const sorted = this.measurements.map(m => m.latency).sort((a, b) => a - b);
const n = sorted.length;
return {
count: n,
min: sorted[0].toFixed(2) + 'ms',
max: sorted[n - 1].toFixed(2) + 'ms',
avg: (sorted.reduce((a, b) => a + b, 0) / n).toFixed(2) + 'ms',
p95: sorted[Math.floor(n * 0.95)].toFixed(2) + 'ms',
};
}
}
Session Recording and Replay
class SessionRecorder {
constructor() {
this.recording = false;
this.events = [];
this.startTime = null;
}
start() {
this.recording = true;
this.events = [];
this.startTime = Date.now();
}
stop() {
this.recording = false;
return {
duration: Date.now() - this.startTime,
eventCount: this.events.length
};
}
recordEvent(event) {
if (!this.recording) return;
this.events.push({
relativeTime: Date.now() - this.startTime,
direction: event.direction,
data: event.data,
connectionId: event.id
});
}
save(filepath) {
const fs = require('fs');
fs.writeFileSync(filepath, JSON.stringify({
version: 1,
recordedAt: new Date(this.startTime).toISOString(),
events: this.events
}, null, 2));
}
}
Interactive REPL Mode
class InteractiveMode {
constructor(cm, logger, recorder, latency) {
this.cm = cm;
this.logger = logger;
this.recorder = recorder;
this.latency = latency;
}
start(connectionId) {
this.activeConnection = connectionId;
const rl = require('readline').createInterface({
input: process.stdin, output: process.stdout, prompt: 'ws> '
});
rl.prompt();
rl.on('line', (line) => {
const input = line.trim();
if (input.startsWith('/')) this.handleCommand(input);
else {
try { this.cm.send(this.activeConnection, JSON.parse(input)); }
catch { this.cm.send(this.activeConnection, input); }
}
rl.prompt();
});
}
handleCommand(input) {
const [cmd, ...args] = input.split(' ');
switch (cmd) {
case '/connect': this.cm.connect(args[0]); break;
case '/switch': this.activeConnection = parseInt(args[0]); break;
case '/list': console.log(this.cm.listConnections()); break;
case '/stats': console.log(this.cm.getStats(this.activeConnection)); break;
case '/ping': console.log(this.latency.getStats()); break;
case '/filter': this.logger.addFilter(args.join(' ')); break;
case '/exclude': this.logger.addExclusion(args.join(' ')); break;
case '/record':
if (args[0] === 'start') this.recorder.start();
else if (args[0] === 'stop') console.log(this.recorder.stop());
else if (args[0] === 'save') this.recorder.save(args[1] || 'session.json');
break;
case '/quit': process.exit(0);
}
}
}
Usage Examples
# Basic connection with interactive mode
wsd connect wss://echo.websocket.org
# Connect with custom headers
wsd connect wss://api.example.com/ws -H "Authorization:Bearer token"
# Filter for specific message types
wsd connect wss://stream.example.com -f "trade" -x "heartbeat"
# Record a session
wsd connect wss://api.example.com/ws -r session.json
# Replay at 5x speed
wsd replay session.json -u wss://staging.example.com/ws -s 5
# Monitor latency
wsd connect wss://api.example.com/ws --ping-interval 3000
Wrapping Up
We built a WebSocket debugging CLI that goes far beyond what browser DevTools offer:
- Event-driven connection manager -- Clean separation between WebSocket handling and UI logic.
- Pluggable message logger -- Filtering and formatting decoupled from connection management.
- Session recording with relative timestamps -- Enables faithful replay at any speed.
- Multi-connection support -- The connection map pattern scales naturally.
The full source is about 500 lines of JavaScript -- small enough to understand completely, yet powerful enough for daily use.
Top comments (0)