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 messages
  • wss://: 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