From Handshake to Heartbeat: Building a WebSocket Online Testing Tool
From Handshake to Heartbeat: Building a WebSocket Online Testing Tool#
Recently, I was developing a real-time chat feature with WebSocket on the backend. Debugging was painful—browser DevTools’ Network panel shows WebSocket frames, but it’s not intuitive. So I built an online testing tool and documented the implementation process.
WebSocket Connection Lifecycle#
WebSocket isn’t just a simple TCP socket—it has a complete handshake and state management mechanism:
const ws = new WebSocket('wss://example.com/socket')
// Four states
ws.readyState === WebSocket.CONNECTING // 0 - Connecting
ws.readyState === WebSocket.OPEN // 1 - Open
ws.readyState === WebSocket.CLOSING // 2 - Closing
ws.readyState === WebSocket.CLOSED // 3 - Closed
State transition diagram:
CONNECTING → OPEN → CLOSING → CLOSED
↓ ↓ ↓
(error) (error) (error)
Key point: State transitions are one-way only. You can’t go from CLOSING back to OPEN. For reconnection, you must create a new WebSocket instance.
Handshake Phase: From HTTP to WebSocket#
When establishing a WebSocket connection, the browser sends an HTTP Upgrade request:
GET /socket HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Server response:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
The Sec-WebSocket-Accept calculation algorithm:
import crypto from 'crypto'
function calculateAccept(key: string): string {
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
return crypto.createHash('sha1')
.update(key + GUID)
.digest('base64')
}
The browser handles the handshake automatically. Developers only need to provide a ws:// or wss:// URL.
Message Exchange: Frame Format#
WebSocket messages are transmitted in frames. Basic format:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
Key fields:
- FIN (1 bit): Is this the final frame?
- Opcode (4 bits): Frame type (0x1 text, 0x2 binary, 0x8 close, 0x9 Ping, 0xA Pong)
- MASK (1 bit): Client messages must be masked
- Payload length: Message length
The browser API encapsulates these details. Developers only need:
// Send text message
ws.send('Hello, WebSocket!')
// Send binary message
const buffer = new ArrayBuffer(4)
const view = new DataView(buffer)
view.setUint32(0, 12345, false)
ws.send(buffer)
// Receive message
ws.onmessage = (event) => {
if (typeof event.data === 'string') {
console.log('Text:', event.data)
} else if (event.data instanceof Blob) {
console.log('Binary Blob:', event.data)
} else if (event.data instanceof ArrayBuffer) {
console.log('Binary ArrayBuffer:', event.data)
}
}
Heartbeat Mechanism: Keep the Connection Alive#
WebSocket is a persistent connection, but intermediate proxies and firewalls may close “idle” connections. The solution is heartbeat (Ping/Pong):
class WebSocketWithHeartbeat {
private ws: WebSocket
private heartbeatTimer: number | null = null
private missedHeartbeats = 0
private readonly maxMissedHeartbeats = 3
private readonly heartbeatInterval = 30000 // 30 seconds
connect(url: string) {
this.ws = new WebSocket(url)
this.ws.onopen = () => {
this.startHeartbeat()
}
this.ws.onclose = () => {
this.stopHeartbeat()
}
// Browsers don't support manual Ping, only auto-reply Pong to server Ping
// Using custom messages for application-level heartbeat
this.ws.onmessage = (event) => {
if (event.data === 'pong') {
this.missedHeartbeats = 0
}
}
}
private startHeartbeat() {
this.heartbeatTimer = window.setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.missedHeartbeats++
if (this.missedHeartbeats > this.maxMissedHeartbeats) {
this.ws.close()
return
}
this.ws.send('ping')
}
}, this.heartbeatInterval)
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
}
Note: Browser WebSocket API doesn’t support sending Ping frames manually. You can only implement application-level heartbeat through custom messages.
Reconnection Strategy: Exponential Backoff#
When connection drops, auto-reconnect is needed. But blind reconnection adds server load—use exponential backoff:
class WebSocketReconnect {
private ws: WebSocket | null = null
private reconnectAttempts = 0
private readonly maxReconnectAttempts = 5
private readonly baseDelay = 1000 // 1 second
private readonly maxDelay = 30000 // 30 seconds
private reconnectTimer: number | null = null
connect(url: string) {
this.ws = new WebSocket(url)
this.ws.onclose = (event) => {
// Only reconnect on abnormal close
if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect(url)
}
}
this.ws.onerror = () => {
// Error triggers onclose, just log here
console.error('WebSocket error')
}
}
private scheduleReconnect(url: string) {
this.reconnectAttempts++
// Exponential backoff: delay = baseDelay * 2^(attempts - 1)
const delay = Math.min(
this.baseDelay * Math.pow(2, this.reconnectAttempts - 1),
this.maxDelay
)
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimer = window.setTimeout(() => {
this.connect(url)
}, delay)
}
disconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
}
this.ws?.close(1000, 'User disconnect')
}
}
Reconnection intervals: 1s → 2s → 4s → 8s → 16s → Give up
Message Queue: Resend After Disconnect#
When user sends a message during disconnection, it gets lost. Solution: message queue:
class WebSocketWithQueue {
private ws: WebSocket | null = null
private messageQueue: string[] = []
private isConnecting = false
connect(url: string) {
this.isConnecting = true
this.ws = new WebSocket(url)
this.ws.onopen = () => {
this.isConnecting = false
// After connection, send queued messages
this.flushQueue()
}
this.ws.onclose = () => {
this.isConnecting = false
}
}
send(message: string) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(message)
} else {
// Connection not ready, queue the message
this.messageQueue.push(message)
console.log(`Message queued (queue size: ${this.messageQueue.length})`)
}
}
private flushQueue() {
while (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
const message = this.messageQueue.shift()!
this.ws.send(message)
}
}
}
Closing Handshake: Graceful Disconnect#
When closing WebSocket, both sides exchange Close frames:
// Client initiates close
ws.close(1000, 'Normal closure')
// Listen for close event
ws.onclose = (event) => {
console.log('Code:', event.code) // Close code
console.log('Reason:', event.reason) // Close reason
console.log('Clean:', event.wasClean) // Clean close?
}
Common close codes:
| Code | Description | Use Case |
|---|---|---|
| 1000 | Normal Closure | Normal close |
| 1001 | Going Away | Page refresh/navigation |
| 1002 | Protocol Error | Protocol error |
| 1003 | Unsupported Data | Unsupported data type |
| 1006 | Abnormal Closure | Abnormal close (no Close frame) |
| 1008 | Policy Violation | Policy violation |
| 1011 | Internal Error | Server internal error |
| 1012 | Service Restart | Service restart |
| 1013 | Try Again Later | Try again later |
Note: 1006 is the most common problem code, indicating abnormal disconnection without receiving a Close frame. Possible causes:
- Network interruption
- Server crash
- Proxy/firewall forced close
- Browser blocking mixed content (HTTPS page accessing WS)
Security: WSS is Mandatory#
WebSocket can use ws:// or wss://, the difference:
ws://: Plaintext transmission, man-in-the-middle can see all messageswss://: TLS encrypted, secure
HTTPS pages can only use WSS. Browsers block mixed content:
Mixed Content: The page at 'https://example.com' was loaded over HTTPS,
but attempted to connect to the insecure WebSocket endpoint 'ws://api.example.com/socket'.
This request has been blocked.
Also, WebSocket isn’t subject to same-origin policy but is affected by CORS:
// Server needs to check Origin
const origin = request.headers.origin
if (!isAllowedOrigin(origin)) {
// Reject connection
socket.close(1008, 'Origin not allowed')
}
Common Pitfalls#
1. Connection State Check#
// ❌ Wrong: Only check existence
if (ws) {
ws.send(message)
}
// ✅ Correct: Check state
if (ws?.readyState === WebSocket.OPEN) {
ws.send(message)
}
2. Event Listener Timing#
// ❌ Wrong: Listen after connection
ws.onopen = () => { console.log('open') }
setTimeout(() => {
ws.onmessage = (e) => { console.log(e.data) }
}, 1000)
// ✅ Correct: Listen before connection
ws.onopen = () => { console.log('open') }
ws.onmessage = (e) => { console.log(e.data) }
3. Binary Message Type#
ws.binaryType = 'blob' // Default, returns Blob
ws.binaryType = 'arraybuffer' // Returns ArrayBuffer
// Choose based on needs:
// - Blob: File upload, images
// - ArrayBuffer: Binary protocols, game data
4. Memory Leak#
// ❌ Wrong: Connection not closed on unmount
useEffect(() => {
const ws = new WebSocket(url)
ws.onmessage = (e) => setMessage(e.data)
}, [])
// ✅ Correct: Clean up connection
useEffect(() => {
const ws = new WebSocket(url)
ws.onmessage = (e) => setMessage(e.data)
return () => {
ws.close(1000, 'Component unmount')
}
}, [url])
Final Result#
Based on these concepts, I built an online tool: WebSocket Tester
Main features:
- Real-time connection status display
- Message send/receive history
- Support for text and binary messages
- Auto-scroll to latest message
- Connection error alerts
WebSocket seems simple, but getting the details right takes effort. Hope this helps!
Related Tools: API Tester | JSON Formatter