Building a WebSocket Debugging CLI with Node.js
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. Browser DevTools show you messages in a cramped panel with no filtering, no replay, and no way to script interactions. If you are working with a backend service that has no browser frontend at all, you are stuck reaching for generic tools like wscat that offer minimal functionality.
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. The Network tab does show WebSocket frames, but:
- No filtering: You cannot filter messages by content or pattern. In a busy connection sending dozens of messages per second, finding the one you care about is like drinking from a fire hose.
- No replay: You cannot resend a previous message without writing code in the console.
- No recording: Once you navigate away or close the tab, your message history is gone.
- No scripting: There is no way to automate a sequence of sends or set up timed pings.
- No multi-connection view: If your app opens multiple WebSocket connections, you have to switch between them manually.
- No latency measurement: You get timestamps, but no built-in round-trip latency tracking.
A dedicated CLI tool solves all of these problems and works equally well for backend services, IoT devices, and any WebSocket endpoint — not just browser-based ones.
Project Setup
Let us start with a clean project:
mkdir ws-debugger && cd ws-debugger
npm init -y
npm install ws chalk commander readline
Here is what each dependency does:
- ws — The most popular WebSocket library for Node.js, lightweight and fast.
- chalk — Terminal string styling for color-coded output.
- commander — Command-line argument parsing.
- readline — Built into Node.js, but we will use it for interactive mode.
Our project structure:
ws-debugger/
├── bin/
│ └── wsd.js # CLI entry point
├── lib/
│ ├── connection.js # WebSocket connection manager
│ ├── logger.js # Message logging and filtering
│ ├── recorder.js # Session recording and replay
│ ├── latency.js # Latency measurement
│ └── interactive.js # Interactive REPL mode
├── package.json
└── README.md
The Connection Manager
The heart of our tool is a connection manager that wraps the ws library and emits structured events. This abstraction is the most important architectural decision we make: by treating each connection as a numbered entry in a Map and routing all events through Node's EventEmitter, we decouple the WebSocket lifecycle from everything else — logging, recording, the interactive REPL. Any part of the system can subscribe to connection events without knowing how WebSocket internals work.
We also track per-connection statistics (message counts, byte totals, creation time) so that the interactive mode can display useful diagnostics on demand. The ws library handles all the low-level WebSocket protocol details — frame masking, UTF-8 validation, close handshakes — so we can focus on the debugging experience.
// lib/connection.js
const WebSocket = require('ws');
const EventEmitter = require('events');
const chalk = require('chalk');
class ConnectionManager extends EventEmitter {
constructor() {
super();
this.connections = new Map();
this.connectionCounter = 0;
}
connect(url, options = {}) {
const id = ++this.connectionCounter;
const headers = options.headers || {};
const protocols = options.protocols || [];
const ws = new WebSocket(url, protocols, {
headers,
rejectUnauthorized: options.rejectUnauthorized !== false,
});
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();
const size = Buffer.byteLength(data);
connection.messageCount.received++;
connection.bytesTransferred.received += size;
this.emit('message', {
id,
direction: 'incoming',
data: message,
size,
timestamp: Date.now(),
isBinary,
});
});
ws.on('close', (code, reason) => {
this.emit('disconnected', {
id,
url,
code,
reason: reason.toString(),
});
this.connections.delete(id);
});
ws.on('error', (error) => {
this.emit('error', { id, url, error: error.message });
});
ws.on('ping', (data) => {
this.emit('ping', { id, data });
});
ws.on('pong', (data) => {
this.emit('pong', { id, data, timestamp: Date.now() });
});
this.connections.set(id, connection);
return id;
}
send(id, data) {
const connection = this.connections.get(id);
if (!connection) {
throw new Error(`Connection ${id} not found`);
}
const payload =
typeof data === 'object' ? JSON.stringify(data) : data;
const size = Buffer.byteLength(payload);
connection.ws.send(payload);
connection.messageCount.sent++;
connection.bytesTransferred.sent += size;
this.emit('message', {
id,
direction: 'outgoing',
data: payload,
size,
timestamp: Date.now(),
isBinary: false,
});
}
disconnect(id) {
const connection = this.connections.get(id);
if (connection) {
connection.ws.close();
}
}
disconnectAll() {
for (const [id] of this.connections) {
this.disconnect(id);
}
}
getStats(id) {
const connection = this.connections.get(id);
if (!connection) return null;
return {
id: connection.id,
url: connection.url,
uptime: Date.now() - connection.createdAt,
messages: { ...connection.messageCount },
bytes: { ...connection.bytesTransferred },
readyState: connection.ws.readyState,
};
}
listConnections() {
return Array.from(this.connections.values()).map((c) => ({
id: c.id,
url: c.url,
state: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][
c.ws.readyState
],
}));
}
}
module.exports = ConnectionManager;
The ConnectionManager gives us a clean interface: connect, send, disconnect, and get stats. Each connection gets a numeric ID so we can juggle multiple connections easily. Notice that we pass the raw Buffer for binary messages but convert text messages to strings — this distinction matters because some protocols mix text and binary frames on the same connection, and our logger needs to handle both gracefully.
One subtle detail: we track bytesTransferred separately from messageCount because WebSocket message sizes vary wildly. A chat app might send thousands of small messages, while a data streaming service might send fewer but much larger frames. Having both metrics helps you understand the traffic profile quickly.
Message Logging with Filtering
Raw WebSocket traffic is noisy. A good debugger needs filtering — show me only the messages that match a pattern, or hide heartbeat pings that clutter the output. Consider a typical production scenario: you are debugging a stock trading WebSocket that sends 50 messages per second — price updates, order confirmations, heartbeats, system status updates. Without filtering, the important messages drown in noise. Our logger supports two types of filters: include filters (show only messages matching a pattern) and exclude filters (hide messages matching a pattern). You can combine them — for example, show all messages containing "order" but hide those containing "heartbeat".
// lib/logger.js
const chalk = require('chalk');
class MessageLogger {
constructor(options = {}) {
this.filters = [];
this.excludePatterns = [];
this.showTimestamps = options.timestamps !== false;
this.showSize = options.size !== false;
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);
// Check exclusions first
for (const pattern of this.excludePatterns) {
if (pattern.test(text)) return false;
}
// If no include filters, show everything
if (this.filters.length === 0) return true;
// Otherwise, must match at least one filter
return this.filters.some((pattern) => pattern.test(text));
}
formatMessage(event) {
if (!this.shouldDisplay(event.data)) return null;
const parts = [];
// Timestamp
if (this.showTimestamps) {
const time = new Date(event.timestamp).toISOString().slice(11, 23);
parts.push(chalk.gray(`[${time}]`));
}
// Connection ID (for multi-connection mode)
if (event.id) {
parts.push(chalk.cyan(`#${event.id}`));
}
// Direction arrow
if (event.direction === 'incoming') {
parts.push(chalk.green('◄──'));
} else {
parts.push(chalk.yellow('──►'));
}
// Size
if (this.showSize) {
parts.push(chalk.gray(`(${formatBytes(event.size)})`));
}
// Message body
const body = this.formatBody(event.data);
parts.push(body);
return parts.join(' ');
}
formatBody(data) {
if (typeof data !== 'string') {
return chalk.magenta('[binary]');
}
// Try to parse and pretty-print JSON
if (this.jsonPrettyPrint) {
try {
const parsed = JSON.parse(data);
return this.colorizeJson(parsed);
} catch {
// Not JSON, display as-is
}
}
return data;
}
colorizeJson(obj, indent = 0) {
const spaces = ' '.repeat(indent);
if (obj === null) return chalk.gray('null');
if (typeof obj === 'boolean') return chalk.yellow(String(obj));
if (typeof obj === 'number') return chalk.cyan(String(obj));
if (typeof obj === 'string')
return chalk.green(`"${obj}"`);
if (Array.isArray(obj)) {
if (obj.length === 0) return '[]';
const items = obj.map(
(item) => `${spaces} ${this.colorizeJson(item, indent + 1)}`
);
return `[\n${items.join(',\n')}\n${spaces}]`;
}
if (typeof obj === 'object') {
const keys = Object.keys(obj);
if (keys.length === 0) return '{}';
const entries = keys.map((key) => {
const val = this.colorizeJson(obj[key], indent + 1);
return `${spaces} ${chalk.white('"' + key + '"')}: ${val}`;
});
return `{\n${entries.join(',\n')}\n${spaces}}`;
}
return String(obj);
}
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / 1048576).toFixed(1)}MB`;
}
module.exports = MessageLogger;
The logger handles three key responsibilities: filtering messages by pattern (include and exclude), formatting output with timestamps and direction arrows, and pretty-printing JSON with syntax highlighting. That last feature alone makes this tool worth using — when your WebSocket is streaming JSON objects, seeing them color-coded and properly indented in the terminal is a massive quality-of-life improvement.
The colorizeJson method recursively walks the JSON structure and applies different chalk colors to different value types: green for strings, cyan for numbers, yellow for booleans, gray for null. Object keys get white. This makes it immediately obvious what type each value is, which is critical when you are scanning hundreds of messages looking for anomalies. The formatBytes helper keeps the size column compact — nobody wants to see "1048576 bytes" when "1.0MB" communicates the same information instantly.
One design choice worth highlighting: the shouldDisplay method checks exclusion patterns before inclusion patterns. This means exclusions always win. If you filter for "order" but exclude "order_heartbeat", the heartbeat messages stay hidden even though they match the include filter. This follows the principle of least surprise — when you say "hide heartbeats," you mean it regardless of other filters.
Latency Measurement
Measuring round-trip latency over WebSocket is essential for diagnosing real-time application issues. A chat message that takes 500ms feels sluggish. A trading signal delayed by 200ms could mean real money lost. But latency measurement over WebSocket is trickier than HTTP because there is no built-in request-response pairing — messages flow in both directions independently.
The WebSocket protocol does include built-in ping and pong control frames (defined in RFC 6455, Section 5.5.2), and the ws library exposes them. When you send a ping frame, the remote endpoint must respond with a pong frame containing the same payload. By embedding a unique identifier and timestamp in the ping, we can measure the exact round trip. However, many WebSocket servers also implement application-level heartbeat protocols using regular text messages, so we support both approaches.
// lib/latency.js
class LatencyTracker {
constructor() {
this.measurements = [];
this.pendingPings = new Map();
this.maxSamples = 1000;
}
startPing(connectionId, ws) {
const id = `${connectionId}-${Date.now()}`;
const startTime = process.hrtime.bigint();
this.pendingPings.set(id, startTime);
// Use WebSocket protocol-level ping
ws.ping(id);
return id;
}
recordPong(pingId) {
const startTime = this.pendingPings.get(pingId);
if (!startTime) return null;
const endTime = process.hrtime.bigint();
const latencyMs = Number(endTime - startTime) / 1_000_000;
this.pendingPings.delete(pingId);
this.measurements.push({
timestamp: Date.now(),
latency: latencyMs,
});
// Keep only last N samples
if (this.measurements.length > this.maxSamples) {
this.measurements = this.measurements.slice(-this.maxSamples);
}
return latencyMs;
}
// Application-level ping: send a message and time the response
startAppPing(connectionId, ws, pingMessage) {
const startTime = process.hrtime.bigint();
const id = `app-${connectionId}-${Date.now()}`;
this.pendingPings.set(id, startTime);
ws.send(typeof pingMessage === 'string'
? pingMessage
: JSON.stringify(pingMessage));
return id;
}
getStats() {
if (this.measurements.length === 0) {
return { count: 0, min: 0, max: 0, avg: 0, p95: 0, p99: 0 };
}
const latencies = this.measurements
.map((m) => m.latency)
.sort((a, b) => a - b);
const count = latencies.length;
const sum = latencies.reduce((a, b) => a + b, 0);
return {
count,
min: latencies[0].toFixed(2),
max: latencies[count - 1].toFixed(2),
avg: (sum / count).toFixed(2),
p95: latencies[Math.floor(count * 0.95)].toFixed(2),
p99: latencies[Math.floor(count * 0.99)].toFixed(2),
};
}
startPeriodicPing(connectionId, ws, intervalMs = 5000) {
return setInterval(() => {
if (ws.readyState === 1) {
this.startPing(connectionId, ws);
}
}, intervalMs);
}
}
module.exports = LatencyTracker;
Using process.hrtime.bigint() gives us nanosecond-precision timing — far more accurate than Date.now(), which only provides millisecond resolution and is subject to clock adjustments. The getStats() method calculates min, max, average, P95, and P99 latencies. The percentile values are particularly useful: an average latency of 50ms might sound fine, but if P99 is 800ms, one in a hundred messages is experiencing terrible performance — something averages alone would hide.
The startPeriodicPing method sets up automatic latency monitoring at a configurable interval. Set it to 3000ms and you get a continuous stream of latency measurements, letting you spot degradation in real time. This is especially powerful combined with the recorder (covered next) — you can graph latency over time after the fact.
Session Recording and Replay
Recording a WebSocket session and replaying it later is one of the most powerful debugging techniques available, yet almost no WebSocket tools support it out of the box. The workflow is simple: connect to a production WebSocket, start recording, wait for the bug to manifest, then stop recording. Now you have a portable JSON file containing every message with precise timing. You can replay that exact sequence against a local development server as many times as you need to reproduce and fix the issue. No more "it only happens in production" — you bring production to your machine.
// lib/recorder.js
const fs = require('fs');
const path = require('path');
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 {
startTime: this.startTime,
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,
size: event.size,
});
}
save(filepath) {
const session = {
version: 1,
recordedAt: new Date(this.startTime).toISOString(),
duration: Date.now() - this.startTime,
eventCount: this.events.length,
events: this.events,
};
const dir = path.dirname(filepath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filepath, JSON.stringify(session, null, 2));
return filepath;
}
static load(filepath) {
const raw = fs.readFileSync(filepath, 'utf-8');
return JSON.parse(raw);
}
static async replay(filepath, connectionManager, targetId, options = {}) {
const session = SessionRecorder.load(filepath);
const speed = options.speed || 1;
const sendOnly = options.sendOnly !== false;
console.log(
`Replaying session: ${session.eventCount} events, ` +
`${(session.duration / 1000).toFixed(1)}s duration ` +
`at ${speed}x speed`
);
const outgoing = sendOnly
? session.events.filter((e) => e.direction === 'outgoing')
: session.events;
for (let i = 0; i < outgoing.length; i++) {
const event = outgoing[i];
const nextEvent = outgoing[i + 1];
if (event.direction === 'outgoing') {
connectionManager.send(targetId, event.data);
}
// Wait for the time gap between this event and the next
if (nextEvent) {
const gap = (nextEvent.relativeTime - event.relativeTime) / speed;
if (gap > 0) {
await new Promise((resolve) => setTimeout(resolve, gap));
}
}
}
console.log('Replay complete.');
}
}
module.exports = SessionRecorder;
Sessions are saved as JSON files with relative timestamps rather than absolute ones. This is a deliberate design choice: relative timestamps make sessions portable. It does not matter when you recorded the session — the gaps between messages are what matters for faithful replay. The speed multiplier lets you control playback rate. Setting speed: 10 replays a 5-minute session in 30 seconds, perfect for quick iteration. Setting speed: 0.5 slows things down to half speed, useful when you need to watch each message carefully.
The sendOnly option defaults to true, meaning replay only sends the outgoing messages from the recorded session. The server's responses will be new — which is exactly what you want when testing server-side changes. If you set sendOnly: false, it replays both directions, which can be useful for generating test data or simulating traffic patterns.
Interactive Mode
The interactive mode turns our CLI into a WebSocket REPL — the most hands-on way to explore a WebSocket API. Think of it like a browser console, but purpose-built for WebSocket interactions. You type a message, hit enter, and see the response immediately. Slash commands give you access to all the advanced features (filtering, recording, connection management) without leaving the REPL. This is where all the pieces we have built come together into a cohesive debugging experience.
// lib/interactive.js
const readline = require('readline');
const chalk = require('chalk');
class InteractiveMode {
constructor(connectionManager, logger, recorder, latencyTracker) {
this.cm = connectionManager;
this.logger = logger;
this.recorder = recorder;
this.latency = latencyTracker;
this.activeConnection = null;
this.rl = null;
}
start(connectionId) {
this.activeConnection = connectionId;
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: chalk.blue('ws> '),
});
this.printHelp();
this.rl.prompt();
this.rl.on('line', (line) => {
this.handleInput(line.trim());
this.rl.prompt();
});
this.rl.on('close', () => {
this.cm.disconnectAll();
process.exit(0);
});
}
handleInput(input) {
if (!input) return;
// Command handling
if (input.startsWith('/')) {
return this.handleCommand(input);
}
// Try to parse as JSON for pretty sending
try {
const parsed = JSON.parse(input);
this.cm.send(this.activeConnection, parsed);
} catch {
// Send as plain text
this.cm.send(this.activeConnection, input);
}
}
handleCommand(input) {
const [cmd, ...args] = input.split(' ');
switch (cmd) {
case '/help':
this.printHelp();
break;
case '/connect':
if (!args[0]) {
console.log(chalk.red('Usage: /connect <url>'));
break;
}
const newId = this.cm.connect(args[0]);
console.log(chalk.green(`Connection #${newId} opening...`));
break;
case '/switch':
const switchId = parseInt(args[0]);
if (!switchId) {
console.log(chalk.red('Usage: /switch <id>'));
break;
}
this.activeConnection = switchId;
console.log(chalk.green(`Switched to connection #${switchId}`));
break;
case '/list':
const connections = this.cm.listConnections();
if (connections.length === 0) {
console.log(chalk.yellow('No active connections'));
} else {
connections.forEach((c) => {
const active = c.id === this.activeConnection ? ' ←' : '';
console.log(
` ${chalk.cyan('#' + c.id)} ${c.url} [${c.state}]${active}`
);
});
}
break;
case '/stats':
const targetId = parseInt(args[0]) || this.activeConnection;
const stats = this.cm.getStats(targetId);
if (stats) {
console.log(chalk.white.bold(`\nConnection #${stats.id} Stats:`));
console.log(` URL: ${stats.url}`);
console.log(` Uptime: ${(stats.uptime / 1000).toFixed(1)}s`);
console.log(
` Messages: ${stats.messages.sent} sent, ${stats.messages.received} received`
);
console.log(
` Bytes: ${stats.bytes.sent} sent, ${stats.bytes.received} received`
);
}
break;
case '/ping':
const latencyStats = this.latency.getStats();
console.log(chalk.white.bold('\nLatency Stats:'));
console.log(` Samples: ${latencyStats.count}`);
console.log(` Min: ${latencyStats.min}ms`);
console.log(` Max: ${latencyStats.max}ms`);
console.log(` Avg: ${latencyStats.avg}ms`);
console.log(` P95: ${latencyStats.p95}ms`);
console.log(` P99: ${latencyStats.p99}ms`);
break;
case '/record':
if (args[0] === 'start') {
this.recorder.start();
console.log(chalk.green('Recording started.'));
} else if (args[0] === 'stop') {
const result = this.recorder.stop();
console.log(
chalk.green(
`Recording stopped. ${result.eventCount} events, ${(result.duration / 1000).toFixed(1)}s`
)
);
} else if (args[0] === 'save') {
const file = args[1] || `session-${Date.now()}.json`;
this.recorder.save(file);
console.log(chalk.green(`Session saved to ${file}`));
} else {
console.log(
chalk.red('Usage: /record start|stop|save [filename]')
);
}
break;
case '/filter':
if (!args[0]) {
console.log(chalk.red('Usage: /filter <pattern>'));
break;
}
this.logger.addFilter(args.join(' '));
console.log(chalk.green(`Filter added: ${args.join(' ')}`));
break;
case '/exclude':
if (!args[0]) {
console.log(chalk.red('Usage: /exclude <pattern>'));
break;
}
this.logger.addExclusion(args.join(' '));
console.log(chalk.green(`Exclusion added: ${args.join(' ')}`));
break;
case '/json':
// Send a JSON template
const template = { type: args[0] || 'message', payload: {} };
this.cm.send(this.activeConnection, template);
break;
case '/close':
const closeId = parseInt(args[0]) || this.activeConnection;
this.cm.disconnect(closeId);
console.log(chalk.yellow(`Closing connection #${closeId}...`));
break;
case '/quit':
this.cm.disconnectAll();
process.exit(0);
break;
default:
console.log(chalk.red(`Unknown command: ${cmd}. Type /help`));
}
}
printHelp() {
console.log(chalk.white.bold('\n WebSocket Debugger — Commands:\n'));
console.log(' Type any text to send it as a message.');
console.log(' Type valid JSON to send it as a JSON message.\n');
console.log(' /connect <url> Open a new connection');
console.log(' /switch <id> Switch active connection');
console.log(' /list List all connections');
console.log(' /stats [id] Show connection statistics');
console.log(' /ping Show latency statistics');
console.log(' /filter <pattern> Show only matching messages');
console.log(' /exclude <pattern> Hide matching messages');
console.log(' /record start Start recording session');
console.log(' /record stop Stop recording');
console.log(' /record save [file] Save recorded session');
console.log(' /json [type] Send a JSON template');
console.log(' /close [id] Close a connection');
console.log(' /quit Exit\n');
}
}
module.exports = InteractiveMode;
The REPL supports both raw text and JSON input. If you type valid JSON, it gets parsed and sent as a structured message. Slash commands handle everything else — connecting, switching between connections, filtering, recording, and stats.
A few design decisions worth noting in the interactive mode. First, we use readline rather than raw stdin because readline gives us line editing, history (up/down arrow), and proper signal handling for free. Second, the /json command provides a quick way to send structured messages without typing full JSON — useful when you are rapidly testing different message types against an API. Third, every command that modifies state (adding a filter, starting a recording, switching connections) prints a confirmation message. This kind of immediate feedback is crucial in a debugging tool where you need confidence that the tool is doing what you asked.
The CLI Entry Point
Now let us wire everything together into a CLI using Commander. This is where the user-facing interface comes together — three subcommands for the three main workflows: connect for interactive debugging, listen for passive monitoring, and replay for session playback.
#!/usr/bin/env node
// bin/wsd.js
const { Command } = require('commander');
const chalk = require('chalk');
const ConnectionManager = require('../lib/connection');
const MessageLogger = require('../lib/logger');
const SessionRecorder = require('../lib/recorder');
const LatencyTracker = require('../lib/latency');
const InteractiveMode = require('../lib/interactive');
const program = new Command();
program
.name('wsd')
.description('WebSocket Debugger CLI')
.version('1.0.0');
program
.command('connect <url>')
.description('Connect to a WebSocket endpoint')
.option('-H, --header <headers...>', 'Custom headers (key:value)')
.option('-p, --protocol <protocols...>', 'WebSocket sub-protocols')
.option('-f, --filter <patterns...>', 'Show only matching messages')
.option('-x, --exclude <patterns...>', 'Hide matching messages')
.option('--no-pretty', 'Disable JSON pretty-printing')
.option('--no-timestamps', 'Hide timestamps')
.option('--ping-interval <ms>', 'Ping interval in ms', parseInt)
.option('-r, --record <file>', 'Record session to file')
.option('-i, --interactive', 'Enter interactive mode', true)
.action((url, options) => {
const cm = new ConnectionManager();
const logger = new MessageLogger({
pretty: options.pretty,
timestamps: options.timestamps,
});
const recorder = new SessionRecorder();
const latency = new LatencyTracker();
// Set up filters
if (options.filter) {
options.filter.forEach((f) => logger.addFilter(f));
}
if (options.exclude) {
options.exclude.forEach((x) => logger.addExclusion(x));
}
// Parse headers
const headers = {};
if (options.header) {
options.header.forEach((h) => {
const [key, ...rest] = h.split(':');
headers[key.trim()] = rest.join(':').trim();
});
}
// Start recording if requested
if (options.record) {
recorder.start();
}
// Set up event handlers
cm.on('connected', ({ id, url }) => {
console.log(chalk.green(`✓ Connected to ${url} (connection #${id})`));
});
cm.on('message', (event) => {
// Record if active
recorder.recordEvent(event);
// Log with formatting
const formatted = logger.formatMessage(event);
if (formatted) {
console.log(formatted);
}
});
cm.on('disconnected', ({ id, url, code, reason }) => {
console.log(
chalk.yellow(
`✗ Disconnected from ${url} (#${id}) — code: ${code}` +
(reason ? `, reason: ${reason}` : '')
)
);
// Save recording if active
if (options.record && recorder.recording) {
recorder.stop();
recorder.save(options.record);
console.log(chalk.green(`Session saved to ${options.record}`));
}
});
cm.on('error', ({ id, url, error }) => {
console.error(chalk.red(`Error on #${id} (${url}): ${error}`));
});
cm.on('pong', ({ id, data }) => {
const pingId = data.toString();
const ms = latency.recordPong(pingId);
if (ms !== null) {
console.log(chalk.gray(` pong #${id}: ${ms.toFixed(2)}ms`));
}
});
// Connect
const connId = cm.connect(url, {
headers,
protocols: options.protocol,
});
// Start periodic pings if requested
if (options.pingInterval) {
cm.on('connected', ({ id }) => {
const conn = cm.connections.get(id);
if (conn) {
latency.startPeriodicPing(id, conn.ws, options.pingInterval);
}
});
}
// Enter interactive mode
if (options.interactive) {
cm.on('connected', () => {
const interactive = new InteractiveMode(
cm, logger, recorder, latency
);
interactive.start(connId);
});
}
// Handle process exit
process.on('SIGINT', () => {
if (recorder.recording) {
recorder.stop();
if (options.record) {
recorder.save(options.record);
console.log(chalk.green(`\nSession saved to ${options.record}`));
}
}
cm.disconnectAll();
process.exit(0);
});
});
program
.command('replay <file>')
.description('Replay a recorded session')
.requiredOption('-u, --url <url>', 'Target WebSocket URL')
.option('-s, --speed <multiplier>', 'Playback speed', parseFloat, 1)
.action(async (file, options) => {
const cm = new ConnectionManager();
cm.on('connected', async ({ id }) => {
console.log(chalk.green(`Connected. Starting replay...`));
await SessionRecorder.replay(file, cm, id, {
speed: options.speed,
});
cm.disconnectAll();
});
cm.on('message', (event) => {
const logger = new MessageLogger();
const formatted = logger.formatMessage(event);
if (formatted) console.log(formatted);
});
cm.connect(options.url);
});
program
.command('listen <url>')
.description('Listen-only mode (no interactive prompt)')
.option('-f, --filter <patterns...>', 'Show only matching messages')
.option('-x, --exclude <patterns...>', 'Hide matching messages')
.option('-r, --record <file>', 'Record session to file')
.option('--no-pretty', 'Disable JSON pretty-printing')
.action((url, options) => {
const cm = new ConnectionManager();
const logger = new MessageLogger({ pretty: options.pretty });
const recorder = new SessionRecorder();
if (options.filter) {
options.filter.forEach((f) => logger.addFilter(f));
}
if (options.exclude) {
options.exclude.forEach((x) => logger.addExclusion(x));
}
if (options.record) {
recorder.start();
}
cm.on('connected', ({ url }) => {
console.log(chalk.green(`Listening on ${url}...`));
});
cm.on('message', (event) => {
recorder.recordEvent(event);
const formatted = logger.formatMessage(event);
if (formatted) console.log(formatted);
});
cm.connect(url);
process.on('SIGINT', () => {
if (options.record && recorder.recording) {
recorder.stop();
recorder.save(options.record);
console.log(chalk.green(`\nSession saved to ${options.record}`));
}
cm.disconnectAll();
process.exit(0);
});
});
program.parse();
Usage Examples
Here is the tool in action. Connect to a public WebSocket echo server:
# Basic connection with interactive mode
wsd connect wss://echo.websocket.org
# Connect with custom headers (for auth)
wsd connect wss://api.example.com/ws \
-H "Authorization:Bearer eyJ..." \
-H "X-Client-Id:debugger"
# Filter for specific message types
wsd connect wss://stream.example.com \
-f "trade" -f "order" \
-x "heartbeat"
# Record a session
wsd connect wss://api.example.com/ws \
-r session-2024-01-15.json
# Listen-only mode (no interactive prompt)
wsd listen wss://stream.binance.com:9443/ws/btcusdt@trade
# Replay a recorded session at 5x speed
wsd replay session-2024-01-15.json \
-u wss://staging.example.com/ws \
-s 5
# Monitor latency with periodic pings
wsd connect wss://api.example.com/ws \
--ping-interval 3000
In interactive mode, a session looks like this:
✓ Connected to wss://echo.websocket.org (connection #1)
ws> {"type": "subscribe", "channel": "trades"}
[14:23:01.445] #1 ──► (52B) {
"type": "subscribe",
"channel": "trades"
}
[14:23:01.612] #1 ◄── (52B) {
"type": "subscribe",
"channel": "trades"
}
ws> /stats
Connection #1 Stats:
URL: wss://echo.websocket.org
Uptime: 12.3s
Messages: 1 sent, 1 received
Bytes: 52 sent, 52 received
ws> /filter trade
Filter added: trade
ws> /record start
Recording started.
Handling Binary Messages
Not all WebSocket traffic is text. Some protocols send binary data — protobuf messages, CBOR, MessagePack, or raw binary frames. Our tool should handle these gracefully:
// Add to connection.js message handler
ws.on('message', (data, isBinary) => {
let message;
let displayData;
if (isBinary) {
message = data; // Keep as Buffer
displayData = formatBinaryPreview(data);
} else {
message = data.toString();
displayData = message;
}
// ... emit event with displayData
});
function formatBinaryPreview(buffer, maxBytes = 64) {
const hex = buffer.slice(0, maxBytes).toString('hex');
const formatted = hex.match(/.{1,2}/g).join(' ');
const suffix = buffer.length > maxBytes ? '...' : '';
return `<binary ${buffer.length}B> ${formatted}${suffix}`;
}
This gives you a hex dump preview for binary messages — enough to identify the content type without flooding the terminal.
Supporting Multiple Concurrent Connections
One of the most powerful features of our tool is handling multiple WebSocket connections simultaneously. Here is a real-world scenario: debugging a microservices architecture where your frontend connects to multiple WebSocket endpoints.
# In interactive mode:
ws> /connect wss://api.example.com/ws/notifications
Connection #2 opening...
✓ Connected to wss://api.example.com/ws/notifications (#2)
ws> /connect wss://api.example.com/ws/chat
Connection #3 opening...
✓ Connected to wss://api.example.com/ws/chat (#3)
ws> /list
#1 wss://api.example.com/ws/trades [OPEN] ←
#2 wss://api.example.com/ws/notifications [OPEN]
#3 wss://api.example.com/ws/chat [OPEN]
ws> /switch 3
Switched to connection #3
ws> Hello from the debugger!
[14:25:33.102] #3 ──► (27B) Hello from the debugger!
Messages from all connections appear in the same terminal, color-coded by connection ID. You can switch the active connection with /switch to direct your sends to a specific endpoint while still seeing messages from all of them. This is far more efficient than running multiple terminal windows — you see the full picture of how your services interact with each other in real time.
A practical use case: suppose your app connects to a notification service and a chat service via separate WebSocket endpoints. A user reports that they receive a chat message but the notification never arrives. With our tool, you connect to both endpoints, send a chat message via the chat connection, and immediately see whether the notification service received the corresponding event. Correlation across connections is trivial when everything is in one timeline.
Performance Considerations
When dealing with high-frequency WebSocket streams (market data feeds, IoT sensor telemetry, game state synchronization), performance becomes a real concern. A cryptocurrency exchange WebSocket can easily push 1,000 or more messages per second. If your debugger cannot keep up, you miss messages or, worse, create backpressure that affects the connection itself. Here are two proven optimizations:
Backpressure handling: If your terminal cannot keep up with the message rate, you need to handle backpressure. The simplest approach is a message rate counter that drops messages when the rate exceeds a threshold:
class RateLimiter {
constructor(maxPerSecond = 100) {
this.max = maxPerSecond;
this.count = 0;
this.dropped = 0;
setInterval(() => {
if (this.dropped > 0) {
console.log(chalk.yellow(
` [${this.dropped} messages dropped — rate limit ${this.max}/s]`
));
this.dropped = 0;
}
this.count = 0;
}, 1000);
}
allow() {
this.count++;
if (this.count > this.max) {
this.dropped++;
return false;
}
return true;
}
}
Buffered writes: Instead of writing each message to stdout immediately, buffer them and flush periodically. This reduces system calls dramatically:
class BufferedOutput {
constructor(flushInterval = 50) {
this.buffer = [];
setInterval(() => this.flush(), flushInterval);
}
write(line) {
this.buffer.push(line);
}
flush() {
if (this.buffer.length > 0) {
process.stdout.write(this.buffer.join('\n') + '\n');
this.buffer = [];
}
}
}
Publishing as an npm Package
To turn this into an installable CLI tool, update your package.json:
{
"name": "wsd-cli",
"version": "1.0.0",
"description": "WebSocket Debugger CLI",
"bin": {
"wsd": "./bin/wsd.js"
},
"dependencies": {
"ws": "^8.16.0",
"chalk": "^4.1.2",
"commander": "^11.1.0"
},
"engines": {
"node": ">=16.0.0"
}
}
Make the entry point executable and publish:
chmod +x bin/wsd.js
npm publish
Users can then install it globally:
npm install -g wsd-cli
wsd connect wss://echo.websocket.org
Extending the Tool
Once you have the foundation, there are many directions to take it. The modular architecture we chose makes each of these extensions straightforward to implement without touching existing code:
-
Protocol-specific decoders: Add plugins for common protocols like Socket.IO, GraphQL subscriptions, or Phoenix channels. Socket.IO, for example, wraps messages in a specific format (
42["event", data]) that benefits from custom parsing. You could add a--protocol socket.ioflag that automatically decodes these frames and presents the event name and payload separately. - Export formats: Add CSV or NDJSON export for feeding WebSocket data into analysis tools like Excel, Grafana, or custom dashboards. The recorder already captures all the data you need — it just needs a different serialization format.
-
TUI dashboard: Use a library like
blessedorinkto create a multi-pane terminal UI showing connections, messages, and stats simultaneously. Imagine a split-screen view: messages scrolling in the top pane, live latency graph in the bottom left, connection list in the bottom right. -
Proxy mode: Sit between a client and server, logging all traffic while forwarding messages transparently. This is invaluable for debugging production WebSocket connections without modifying client code. The
wslibrary supports creating WebSocket servers as well as clients, so you can accept connections on a local port and forward them to the real endpoint. - Automated testing: Use recorded sessions as test fixtures. Write assertions like "after sending message X, the server should respond with a message matching pattern Y within 500ms." This turns your debugger into a WebSocket integration testing tool.
Wrapping Up
We built a WebSocket debugging CLI that goes far beyond what browser DevTools offer. Starting from a real problem — the limitations of debugging WebSocket traffic in a browser — we designed a modular, extensible tool that handles the full debugging workflow: connect, inspect, filter, record, replay.
The key architectural decisions that make it work:
-
Event-driven connection manager — Clean separation between WebSocket handling and UI logic. The
EventEmitterpattern means adding new consumers (a TUI dashboard, a file exporter, a test harness) requires zero changes to the connection code. - Pluggable message logger — Filtering and formatting decoupled from connection management. Filters can be added at startup via CLI flags or dynamically during an interactive session.
- Session recording with relative timestamps — Enables faithful replay at any speed. Portable JSON files that can be shared with teammates, attached to bug reports, or checked into version control.
- Multi-connection support from the start — The connection map pattern scales naturally from one connection to dozens without architectural changes.
The full source weighs in at about 500 lines of JavaScript — small enough to understand completely, yet powerful enough for daily use. WebSocket debugging does not have to mean squinting at Chrome DevTools. With a purpose-built CLI, you get filtering, recording, replay, latency measurement, and multi-connection support — everything you need to diagnose real-time communication issues efficiently. The ws library handles the protocol complexity, Node.js gives you the I/O performance, and a well-designed module structure makes the tool easy to extend as your needs evolve.
Top comments (0)