Writing

Handle WebSocket API - Frontend Implementation

Posted · Updated · 4 min read#websocket#note

Handling a WebSocket with the native browser WebSocket API is simpler than most people think - the API gives you everything you need to open a connection to a server, send data, and receive data on it. These are the pieces you have to know:

WebSocket

The primary interface for connecting to a WebSocket server and sending and receiving data on the connection.

open event

Fired by the WebSocket object when the connection is established.

close event

Fired by the WebSocket object when the connection closes - check event.code to know why.

message event

Fired by the WebSocket object every time a message arrives from the server.

Example implementation

TL;DR: scroll to the bottom for the complete module.

Creating the connection - note the wss:// scheme. Browsers only allow secure wss (or plain ws on localhost):

const wsConnection = new WebSocket('wss://example.com/socket')

When the connection opens, you can send data to the server. The payload must be a string, so objects need JSON.stringify:

wsConnection.onopen = function () {
  const params = { type: 'subscribe', channel: 'chat' }
  wsConnection.send(JSON.stringify(params))
}

For long-lived connections I recommend a heartbeat: an app-level ping sent every few seconds. This is different from the automatic ping/pong frames of the WebSocket protocol - those run below your code and keep the connection alive, but they will not tell you when a connection is half-open (the network dropped without a TCP close). An app-level heartbeat that expects an app-level reply is how you detect that:

function heartbeat() {
  if (!wsConnection || wsConnection.readyState !== WebSocket.OPEN) {
    return
  }
  wsConnection.send(JSON.stringify({ type: 'ping' }))
}

Note WebSocket.OPEN instead of a magic number like 1 - same value, but the next reader does not have to look it up. Set it up with setInterval(heartbeat, 3000) on open, and clear the interval on close (this matters - the naive recursive setTimeout version keeps firing even after the socket dies).

Incoming messages arrive through onmessage. Parse defensively - servers occasionally send non-JSON frames:

wsConnection.onmessage = function (event) {
  try {
    const data = JSON.parse(event.data)
    // process the response data
  } catch {
    // not JSON - ignore or log it
  }
}

Closing is where the real logic lives. Code 1000 means a normal, intentional closure - anything else (network drop, server restart) is a good reason to reconnect. Instead of retrying after a fixed delay, back off exponentially so a downed server is not hammered every second:

wsConnection.onclose = function (e) {
  stopHeartbeat()
  if (e.code !== 1000 && retries < MAX_RETRIES) {
    const delay = Math.min(1000 * 2 ** retries, 30000)
    retries++
    reconnectTimer = setTimeout(initWS, delay)
  }
}

The complete module

Putting it all together as a small, dependency-free module:

const WS_URL = 'wss://example.com/socket'
const MAX_RETRIES = 5

let wsConnection = null
let heartbeatTimer = null
let reconnectTimer = null
let retries = 0

export function initWS() {
  // never open a second connection on top of an existing one
  if (wsConnection) return

  wsConnection = new WebSocket(WS_URL)

  wsConnection.onopen = function () {
    retries = 0
    wsConnection.send(JSON.stringify({ type: 'hello' }))
    heartbeatTimer = setInterval(heartbeat, 3000)
  }

  wsConnection.onmessage = function (event) {
    try {
      const data = JSON.parse(event.data)
      // process the response data
    } catch {
      // not JSON - ignore
    }
  }

  wsConnection.onerror = function (error) {
    console.error('WebSocket error:', error)
  }

  wsConnection.onclose = function (e) {
    stopHeartbeat()
    if (e.code !== 1000 && retries < MAX_RETRIES) {
      const delay = Math.min(1000 * 2 ** retries, 30000)
      retries++
      reconnectTimer = setTimeout(initWS, delay)
    }
  }
}

function heartbeat() {
  if (!wsConnection || wsConnection.readyState !== WebSocket.OPEN) return
  wsConnection.send(JSON.stringify({ type: 'ping' }))
}

function stopHeartbeat() {
  if (heartbeatTimer) clearInterval(heartbeatTimer)
  heartbeatTimer = null
}

export function closeWS() {
  clearTimeout(reconnectTimer)
  stopHeartbeat()
  if (wsConnection) {
    wsConnection.close(1000)
    wsConnection = null
  }
}

Two production extras worth adding:

  • Page Visibility - when the tab is hidden, setInterval gets throttled by the browser; pause the heartbeat on visibilitychange and resume when the tab is visible again.
  • Teardown in frameworks - if you open the socket inside a component, call closeWS() in your cleanup hook (onUnmounted in Vue, the return function of useEffect in React), or navigating away leaves a zombie connection behind.

Hope this helps you implement WebSockets in your own project. Thanks for reading!