Use when seeing WebSocket errors like "Invalid frame header", "RSV1 must be clear", or "WS_ERR_UNEXPECTED_RSV_1" - covers multiple WebSocketServer conflicts, compression issues, and raw frame debugging techniques
WebSocket "invalid frame header" errors often stem from raw HTTP being written to an upgraded socket, not actual frame corruption. The most common cause is multiple WebSocketServer instances conflicting on the same HTTP server.
Invalid WebSocket frame: RSV1 must be clearWS_ERR_UNEXPECTED_RSV_1Invalid frame header| Symptom | Likely Cause | Fix |
|---|---|---|
| RSV1 must be clear | Multiple WSS on same server OR compression mismatch | Use noServer: true mode |
Hex starts with 48545450 |
| Raw HTTP on WebSocket (0x48='H') |
| Check for conflicting upgrade handlers |
| Code 1006, no reason | Abnormal closure, often server-side abort | Check abortHandshake calls |
| Works isolated, fails in app | Something else writing to socket | Audit all upgrade listeners |
When attaching multiple WebSocketServer instances to the same HTTP server using the server option:
// ❌ BAD - Both servers add upgrade listeners, causing conflicts
const wss1 = new WebSocketServer({ server, path: '/ws' });
const wss2 = new WebSocketServer({ server, path: '/ws/other' });
What happens:
/wswss1 matches path, handles upgrade successfullywss2 doesn't match, calls abortHandshake(socket, 400)HTTP/1.1 400 Bad Request written to the now-WebSocket socket0x48 ('H') interpreted as: RSV1=1, opcode=8 → invalid frameUse noServer: true and manually route upgrades:
// ✅ GOOD - Single upgrade handler routes to correct server
const wss1 = new WebSocketServer({ noServer: true, perMessageDeflate: false });
const wss2 = new WebSocketServer({ noServer: true, perMessageDeflate: false });
server.on('upgrade', (request, socket, head) => {
const pathname = new URL(request.url || '', `http://${request.headers.host}`).pathname;
if (pathname === '/ws') {
wss1.handleUpgrade(request, socket, head, (ws) => {
wss1.emit('connection', ws, request);
});
} else if (pathname === '/ws/other') {
wss2.handleUpgrade(request, socket, head, (ws) => {
wss2.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
Hook into the socket to see actual bytes received:
ws.on('open', () => {
const socket = ws._socket;
const originalPush = socket.push.bind(socket);
socket.push = function(chunk, encoding) {
if (chunk) {
console.log('First 20 bytes (hex):', chunk.slice(0, 20).toString('hex'));
const byte0 = chunk[0];
console.log(`FIN: ${!!(byte0 & 0x80)}, RSV1: ${!!(byte0 & 0x40)}, Opcode: ${byte0 & 0x0f}`);
// Check if it's actually HTTP text
if (chunk.slice(0, 4).toString() === 'HTTP') {
console.log('*** RECEIVED RAW HTTP ON WEBSOCKET ***');
}
}
return originalPush(chunk, encoding);
};
});
81 = FIN + text frame (normal)82 = FIN + binary frame (normal)88 = FIN + close frame (normal)48545450 = "HTTP" - raw HTTP on WebSocket (bug!)c1 or similar with bit 6 set = compressed frame (RSV1=1)| Mistake | Result | Fix |
|---|---|---|
Multiple WSS with server option | HTTP 400 written to socket | Use noServer: true |
perMessageDeflate: true (default in older ws) | RSV1 set on frames | Explicitly set perMessageDeflate: false |
| Not checking upgrade headers | Miss compression negotiation | Log sec-websocket-extensions header |
| Assuming RSV1 error = compression | Could be raw HTTP | Check if bytes decode as ASCII "HTTP" |
After fixing, verify:
RSV1: false in frame inspectionExtensions header: NONE in upgrade responseHTTP/1.1 in raw frame data