How to check websocket connection in chrome
Open DevTools, Network panel, click the WS filter, then reload. The handshake appears as one entry with status 101 Switching Protocols. Click it and open the Messages panel, the only place frame payloads are visible. The close code is not there: read it from the page's own onclose handler.
Why check this
Run this when a realtime feature works from a command-line client and fails in the browser, and after any change to the proxy or the origin check. The browser sends an Origin header and a cookie jar that the command line does not.
The failure it prevents is a rejected handshake filed as a flaky network. When the edge answers the upgrade with 403, the page fires a generic error event, then close with code 1006 and an empty reason. The ticket says the connection drops at random, the server logs show no session, and the 403 is in one console line.
Prerequisites
- Chrome 152.0.7977.76, driven headless over the DevTools Protocol on one machine on 2026-09-12, because a headless run has no panel to click. DevTools is a protocol client too, so the Messages panel lists the frames captured below. The Network panel reference names each control.
- Node 22.23.2 and
npm i ws@8. This page used ws 8.21.3. - The fixture below, saved as
wsx-fixture.mjs. It serves the page and the socket on one port, echoes what it receives, sends one unprompted message, a ping, then closes with code 1000. Port 9473 was free here.
// wsx-fixture.mjs node 22, npm i ws@8
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
const PORT = 9473;
const page = (path) => `<!doctype html><meta charset="utf-8"><title>ws fixture</title>
<script>
const ws = new WebSocket('ws://127.0.0.1:${PORT}${path}');
ws.onopen = () => { console.log('open readyState=' + ws.readyState); ws.send('hello'); };
ws.onmessage = (e) => console.log('message ' + e.data);
ws.onerror = (e) => console.log('error type=' + e.type + ' fields=' + Object.keys(e).length);
ws.onclose = (e) => console.log('close code=' + e.code + ' reason="' + e.reason + '" wasClean=' + e.wasClean);
</script>`;
const server = createServer((req, res) => {
const path = new URL(req.url, 'http://127.0.0.1').searchParams.get('path') ?? '/live';
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page(path));
});
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (ws) => {
ws.on('message', (d) => ws.send('echo:' + d));
setTimeout(() => ws.send('tick'), 500);
setTimeout(() => ws.ping(), 1000);
setTimeout(() => ws.close(1000, 'done'), 2000);
});
server.on('upgrade', (req, socket, head) => {
if (!req.url.startsWith('/live')) {
socket.end('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n');
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
server.listen(PORT, () => console.log('http and ws on ' + PORT));
- The driver below,
wsx-drive.mjs, subscribes to the socket events the Messages panel is built from.
// wsx-drive.mjs node 22, puppeteer-core through scripts/browser/session.mjs
// node wsx-drive.mjs live the handshake that succeeds
// node wsx-drive.mjs private the handshake the server rejects with 403
import { open } from '../scripts/browser/session.mjs';
const path = '/' + (process.argv[2] ?? 'live');
const EVENTS = [
'webSocketCreated',
'webSocketWillSendHandshakeRequest',
'webSocketHandshakeResponseReceived',
'webSocketFrameSent',
'webSocketFrameReceived',
'webSocketFrameError',
'webSocketClosed',
];
const s = await open();
try {
const log = [];
await s.cdp.send('Network.enable');
for (const name of EVENTS) s.cdp.on(`Network.${name}`, (p) => log.push([name, p]));
await s.goto(`http://127.0.0.1:9473/?path=${path}`);
await new Promise((r) => setTimeout(r, 3000));
console.log('--- page console');
for (const m of s.console) console.log(m);
console.log('--- CDP Network websocket events');
for (const [name, p] of log) {
let detail = '';
if (name === 'webSocketCreated') detail = p.url;
else if (name === 'webSocketWillSendHandshakeRequest') detail = `headers ${Object.keys(p.request.headers).length}`;
else if (name === 'webSocketHandshakeResponseReceived') detail = `${p.response.status} ${p.response.statusText}`;
else if (name === 'webSocketFrameSent' || name === 'webSocketFrameReceived') detail = `opcode ${p.response.opcode} mask ${p.response.mask} ${JSON.stringify(p.response.payloadData)}`;
else if (name === 'webSocketFrameError') detail = p.errorMessage;
console.log(name.padEnd(34), detail);
}
console.log('--- page.on(response) rows, resourceType');
console.log(JSON.stringify(s.requests, null, 1));
} finally {
await s.close();
}
- A second driver,
wsx-headers.mjs, for the handshake headers and what the API offers. Steps 4 and 5 read one run of it.
// wsx-headers.mjs node 22, puppeteer-core through scripts/browser/session.mjs
import { open } from '../scripts/browser/session.mjs';
const s = await open();
try {
await s.cdp.send('Network.enable');
s.cdp.on('Network.webSocketWillSendHandshakeRequest', (p) =>
console.log('request headers\n' + JSON.stringify(p.request.headers, null, 1)));
s.cdp.on('Network.webSocketHandshakeResponseReceived', (p) =>
console.log('response ' + p.response.status + '\n' + JSON.stringify(p.response.headers, null, 1)));
await s.goto('http://127.0.0.1:9473/?path=/live');
await new Promise((r) => setTimeout(r, 2500));
console.log('WebSocket.prototype members');
console.log(await s.page.evaluate(() => Object.getOwnPropertyNames(WebSocket.prototype).sort().join(' ')));
} finally {
await s.close();
}
Steps
- Step 1.
Open DevTools with F12, select the Network panel, click the WS filter, and reload. One entry appears for the socket. Click it for three tabs: Headers, Messages and Initiator. Messages is the only view in Chrome that shows frame payloads.
- Step 2.
Drive the same page and read what it logged.
node wsx-drive.mjs live--- page console log: open readyState=1 log: message echo:hello log: message tick log: close code=1000 reason="done" wasClean=truereadyState=1is OPEN. The echo proves the round trip,tickproves the server can push unprompted, and code 1000 withwasClean=trueis an agreed close. This is everything page JavaScript is given. - Step 3.
Read the frame log from the same run, the events behind the Messages panel.
--- CDP Network websocket events webSocketCreated ws://127.0.0.1:9473/live webSocketWillSendHandshakeRequest headers 12 webSocketHandshakeResponseReceived 101 Switching Protocols webSocketFrameSent opcode 1 mask true "hello" webSocketFrameReceived opcode 1 mask false "echo:hello" webSocketFrameReceived opcode 1 mask false "tick" webSocketClosedOpcode 1 is a text frame.
mask trueon the sent frame andmask falseon the received ones is RFC 6455: a client masks, a server does not. Two frames are missing: the ping, opcode 9, and the close frame, opcode 8.webSocketClosedcarries no code and no reason.The same run's response rows carry no socket.
--- page.on(response) rows, resourceType [ { "url": "http://127.0.0.1:9473/?path=/live", "status": 200, "type": "document" }, { "url": "http://127.0.0.1:9473/favicon.ico", "status": 200, "type": "other" } ]A driver that collects responses, or a check that filters by resource type, reports a page that opened no socket.
- Step 4.
Read the handshake as the Headers tab shows it.
node wsx-headers.mjsrequest headers { "Upgrade": "websocket", "Origin": "http://127.0.0.1:9473", "Cache-Control": "no-cache", "Accept-Language": "uk-UA,uk;q=0.9,en-US;q=0.8,en;q=0.7", "Pragma": "no-cache", "Connection": "Upgrade", "Sec-WebSocket-Key": "/kQcYCZP7NxZSjIhGhT9RQ==", "Accept-Encoding": "gzip, deflate, br, zstd", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/152.0.0.0 Safari/537.36", "Sec-WebSocket-Version": "13", "Host": "127.0.0.1:9473", "Sec-WebSocket-Extensions": "permessage-deflate; client_max_window_bits" } response 101 { "Upgrade": "websocket", "Sec-WebSocket-Accept": "yMkJjWjoTz0x1isMpLqzUbZ9stA=", "Connection": "Upgrade" }Originis the header a command-line client leaves out, so an origin check that passes on the terminal can still reject the browser. The request offeredpermessage-deflateand the 101 did not echo it back. - Step 5.
Ask the same page what the socket API can do.
WebSocket.prototype members CLOSED CLOSING CONNECTING OPEN binaryType bufferedAmount close constructor extensions onclose onerror onmessage onopen protocol readyState send urlTwo methods:
sendandclose, noping. A browser cannot originate a protocol heartbeat, and the ping in step 3 was answered by Chrome without page code seeing it. - Step 6.
Break the handshake and compare the two sides.
node wsx-drive.mjs private--- page console error: WebSocket connection to 'ws://127.0.0.1:9473/private' failed: Error during WebSocket handshake: Unexpected response code: 403 log: error type=error fields=1 log: close code=1006 reason="" wasClean=falseThe first line is Chrome writing to the Console, not something the page can read. The two under it are what the page handlers received: an event whose only own field is
type, and a close with code 1006, an empty reason andwasClean=false. Neither mentions 403.--- CDP Network websocket events webSocketCreated ws://127.0.0.1:9473/private webSocketWillSendHandshakeRequest headers 12 webSocketFrameError Error during WebSocket handshake: Unexpected response code: 403 webSocketClosedThere is no
webSocketHandshakeResponseReceivedline, so no status field here either. The 403 survives only in the text ofwebSocketFrameError, the string Chrome printed to the Console. Parse it, or read the server log.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Entry with 101 Switching Protocols | The upgrade completed | Open Messages. An empty panel now means a frame problem, not a connection problem. |
| No entry under the WS filter | No socket was created for this load | Check the console for a constructor error, and confirm the script ran. |
| close code=1000 and wasClean=true | Both ends agreed to close | Normal shutdown. Compare the reason string with what the server sent. |
| close code=1006 and an empty reason | No close frame arrived | Read the Console line. The real status is only there and in webSocketFrameError. |
| Frames sent, none received | The server accepted the socket and answers nothing | Check the handler path on the server, not the browser. |
| Sec-WebSocket-Extensions offered but absent from the 101 | The server declined compression | The offer is not the agreement. Read the response, not the request. |
Common mistakes
What to check next
- How to test websocket connection: the same session from a Node client, which sends no Origin header.
- Websocket handshake failed: the refused upgrade read as an HTTP response, with a status line.
- Websocket close code 1006: what 1006 covers, and how to tell it from a real close.
- Socket io connection test: why a Socket.IO page shows a polling GET before any 101.
- Websocket compression: the permessage-deflate offer in step 4, and whether it was accepted.
FAQ
How do I see WebSocket messages in Chrome DevTools?
Network panel, WS filter, click the entry, Messages tab. Text frame payloads appear there. Ping, pong and close frames do not.
Why does my error handler give no reason?
The browser event carries one own field, type. In step 6 the reason for the 403 reached the Console and the protocol error string, not the page. Log the Console text too.
Can I send a ping from browser JavaScript?
No. WebSocket.prototype in Chrome 152 lists send and close and no ping, as step 5 shows. Chrome answers server pings for you. Heartbeats have to be ordinary messages both ends agree on.
Where is the close code in DevTools?
Not in the Messages panel. The close frame never reaches the frame log and webSocketClosed carries no payload. Read event.code in your own onclose.
Verified
Verified by Maks VernyChrome 152.0.7977.76node 22.23.2ws 8.21.3
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny