Writing
Handle WebSocket API - Frontend Implementation
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,
setIntervalgets throttled by the browser; pause the heartbeat onvisibilitychangeand resume when the tab is visible again. - Teardown in frameworks - if you open the socket inside a component, call
closeWS()in your cleanup hook (onUnmountedin Vue, the return function ofuseEffectin React), or navigating away leaves a zombie connection behind.
Hope this helps you implement WebSockets in your own project. Thanks for reading!