← Back to Blog

Introduction to WebSockets: Building Real-Time Web Applications

A friendly exploration of WebSocket technology, from basic concepts to practical implementation. We'll compare approaches, build some examples together, and discuss when WebSockets are the right choice for your application.

David Kim
David KimSoftware Engineer & Open Source Contributor

I remember the first time I needed to build a real-time feature. Our team was adding live notifications to an internal dashboard, and I naively started with what I knew: polling. Every five seconds, the client would ask the server, "Anything new? Anything new? Anything new?"

It worked, technically. But it felt wasteful. All those requests, mostly returning empty responses. There had to be a better way.

That is when I discovered WebSockets, and it completely changed how I think about web applications. Let me share what I have learned, and hopefully save you some of the confusion I experienced along the way.

What Are WebSockets, Anyway?

At its core, a WebSocket is a persistent, bidirectional communication channel between a client and a server. Unlike traditional HTTP where the client always initiates communication, WebSockets let both sides send messages whenever they want.

Think of regular HTTP like sending letters. You write a letter, send it, and wait for a response. Each exchange is independent. WebSockets are more like a phone call. You establish a connection once, and then both parties can talk freely until someone hangs up.

The technical bits: WebSockets use a single TCP connection. They start life as an HTTP request (the "handshake") and then upgrade to the WebSocket protocol. After that, data flows in both directions with minimal overhead.

The WebSocket Handshake

The connection starts with a regular HTTP request containing some special headers:

GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13

The server responds with a 101 status code, confirming the protocol switch:

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After this handshake, both sides can send messages freely. The connection stays open until explicitly closed.

I find it helpful to visualize the difference:

HTTP (Traditional):
Client ─request─> Server ─response─> Client ─request─> Server ─response─>
   |                  |                   |                  |
 close              close               close              close

WebSocket:
Client ──handshake──> Server
   |                    |
   <──── open ──────────>
   |                    |
   ─────message────────>
   <────message─────────
   ─────message────────>
   ─────message────────>
   <────message─────────
   |                    |
   <──── close ─────────>

WebSockets vs. The Alternatives

Before committing to WebSockets, let's look at the other options. Each has its place.

Short Polling

The simplest approach: make regular HTTP requests at fixed intervals.

// Short polling example setInterval(async () => { const response = await fetch('/api/notifications'); const data = await response.json(); updateUI(data); }, 5000);

Pros: Simple to implement. Works with any server. No special infrastructure needed.

Cons: Wasteful when there is no new data. Delays between polls mean updates are not truly real-time. High server load with many clients.

Long Polling

A clever optimization: the server holds the request open until there is data to send.

// Long polling example async function longPoll() { try { const response = await fetch('/api/notifications/subscribe'); const data = await response.json(); updateUI(data); } catch (error) { // Connection interrupted, wait before retry await sleep(1000); } // Immediately start the next poll longPoll(); }

Pros: More efficient than short polling. Near real-time updates. Relatively simple server implementation.

Cons: Still creates new connections frequently. Headers add overhead. Can be tricky to implement correctly.

Server-Sent Events (SSE)

A standard for server-to-client streaming over HTTP.

// SSE client example const eventSource = new EventSource('/api/events'); eventSource.onmessage = (event) => { const data = JSON.parse(event.data); updateUI(data); }; eventSource.onerror = (error) => { console.error('SSE error:', error); };

Pros: Built into browsers. Automatic reconnection. Simple server implementation. Works through proxies that support HTTP streaming.

Cons: Unidirectional (server to client only). Limited browser connection pooling. Text-based only.

WebSockets

Full bidirectional communication.

// WebSocket client example const ws = new WebSocket('wss://example.com/socket'); ws.onopen = () => { console.log('Connected!'); ws.send(JSON.stringify({ type: 'subscribe', channel: 'notifications' })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); handleMessage(data); }; ws.onclose = () => { console.log('Disconnected'); };

Pros: True bidirectional communication. Minimal overhead after handshake. Binary data support. Lowest latency.

Cons: More complex infrastructure. Requires connection state management. Proxy and firewall considerations.

When to Choose What

Here is my mental framework:

  • Short polling: Good enough for infrequent updates (every few minutes) or when simplicity matters most
  • Long polling: Reasonable middle ground when WebSocket infrastructure is not available
  • SSE: Great for server-to-client streaming (live feeds, notifications) when you do not need client-to-server messages
  • WebSockets: Best for truly interactive applications (chat, gaming, collaborative editing) where both sides send frequent messages

Building with the Native WebSocket API

Let's build something practical. The browser's WebSocket API is surprisingly straightforward.

class WebSocketClient { constructor(url, options = {}) { this.url = url; this.options = options; this.ws = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = options.maxReconnectAttempts || 5; this.reconnectDelay = options.reconnectDelay || 1000; this.listeners = new Map(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { console.log('WebSocket connected'); this.reconnectAttempts = 0; this.emit('connected'); }; this.ws.onmessage = (event) => { try { const message = JSON.parse(event.data); this.emit(message.type, message.payload); } catch (error) { console.error('Failed to parse message:', error); } }; this.ws.onclose = (event) => { console.log('WebSocket closed:', event.code, event.reason); this.emit('disconnected', { code: event.code, reason: event.reason }); if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) { this.scheduleReconnect(); } }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); this.emit('error', error); }; } scheduleReconnect() { const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts); console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`); setTimeout(() => { this.reconnectAttempts++; this.connect(); }, delay); } send(type, payload) { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type, payload })); } else { console.warn('WebSocket not connected, message not sent'); } } on(event, callback) { if (!this.listeners.has(event)) { this.listeners.set(event, []); } this.listeners.get(event).push(callback); } emit(event, data) { const callbacks = this.listeners.get(event) || []; callbacks.forEach(callback => callback(data)); } disconnect() { if (this.ws) { this.ws.close(1000, 'Client disconnecting'); } } } // Usage const client = new WebSocketClient('wss://api.example.com/socket'); client.connect(); client.on('connected', () => { client.send('join', { room: 'general' }); }); client.on('message', (data) => { console.log('New message:', data); }); client.on('user-joined', (data) => { console.log('User joined:', data.username); });

This wrapper handles reconnection with exponential backoff, message parsing, and event-based message handling. It is a good foundation for most applications.

Introducing Socket.IO

While the native WebSocket API is powerful, many developers reach for Socket.IO. It is a library that adds helpful abstractions on top of WebSockets.

What Socket.IO provides:

  • Automatic reconnection
  • Fallback to polling when WebSockets are unavailable
  • Room and namespace support
  • Acknowledgments and callbacks
  • Binary data support
  • Multiplexing

Here is the same chat functionality with Socket.IO:

// Server (Node.js) import { Server } from 'socket.io'; const io = new Server(server, { cors: { origin: 'https://example.com', methods: ['GET', 'POST'] } }); io.on('connection', (socket) => { console.log('User connected:', socket.id); socket.on('join', (room) => { socket.join(room); socket.to(room).emit('user-joined', { userId: socket.id }); }); socket.on('message', (data) => { io.to(data.room).emit('message', { userId: socket.id, text: data.text, timestamp: Date.now() }); }); socket.on('disconnect', () => { console.log('User disconnected:', socket.id); }); });
// Client import { io } from 'socket.io-client'; const socket = io('https://api.example.com', { reconnection: true, reconnectionAttempts: 5, reconnectionDelay: 1000 }); socket.on('connect', () => { console.log('Connected:', socket.id); socket.emit('join', 'general'); }); socket.on('message', (data) => { displayMessage(data); }); socket.on('user-joined', (data) => { showNotification(`User ${data.userId} joined`); }); // Sending with acknowledgment socket.emit('message', { room: 'general', text: 'Hello!' }, (response) => { console.log('Server acknowledged:', response); });

Socket.IO makes common patterns easier, but it comes with trade-offs. The protocol is not compatible with standard WebSockets, so you need Socket.IO on both ends. The library adds size to your bundle. For simple use cases, native WebSockets might be enough.

Real-World Use Cases

Let me walk through some scenarios where WebSockets shine.

Chat Applications

The classic example. Users expect messages to appear instantly without refreshing.

// A minimal chat implementation class ChatClient { constructor(url) { this.socket = io(url); this.currentRoom = null; } joinRoom(roomId) { if (this.currentRoom) { this.socket.emit('leave', this.currentRoom); } this.socket.emit('join', roomId); this.currentRoom = roomId; } sendMessage(text) { this.socket.emit('message', { room: this.currentRoom, text, timestamp: Date.now() }); } onMessage(callback) { this.socket.on('message', callback); } onUserJoined(callback) { this.socket.on('user-joined', callback); } onUserLeft(callback) { this.socket.on('user-left', callback); } }

Live Dashboards

Real-time metrics, stock prices, or monitoring data.

// Dashboard that subscribes to multiple data streams const socket = io('/dashboard'); socket.on('connect', () => { // Subscribe to specific metrics socket.emit('subscribe', ['cpu', 'memory', 'requests-per-second']); }); socket.on('metric', (data) => { switch (data.type) { case 'cpu': updateCpuChart(data.value); break; case 'memory': updateMemoryChart(data.value); break; case 'requests-per-second': updateRpsCounter(data.value); break; } });

Collaborative Editing

Multiple users editing the same document simultaneously.

// Simplified collaborative editor class CollaborativeEditor { constructor(documentId) { this.socket = io('/documents'); this.documentId = documentId; this.socket.on('connect', () => { this.socket.emit('join-document', documentId); }); this.socket.on('remote-change', (change) => { this.applyRemoteChange(change); }); this.socket.on('cursor-moved', (data) => { this.showRemoteCursor(data.userId, data.position); }); } onLocalChange(change) { // Send local changes to server this.socket.emit('change', { documentId: this.documentId, change }); } onCursorMove(position) { this.socket.emit('cursor-move', { documentId: this.documentId, position }); } applyRemoteChange(change) { // Apply the change to the local editor // This is where operational transformation or CRDTs come in } showRemoteCursor(userId, position) { // Display other users' cursor positions } }

Multiplayer Games

Games need low-latency bidirectional communication for player actions and game state.

// Game client example class GameClient { constructor(gameId) { this.socket = io('/game'); this.gameId = gameId; this.socket.on('connect', () => { this.socket.emit('join-game', gameId); }); this.socket.on('game-state', (state) => { this.updateGameState(state); }); this.socket.on('player-action', (action) => { this.handlePlayerAction(action); }); } sendAction(action) { this.socket.emit('action', { gameId: this.gameId, action, timestamp: Date.now() }); } updateGameState(state) { // Update local game state } handlePlayerAction(action) { // Handle other players' actions } }

Scaling WebSocket Applications

Here is where things get interesting. Scaling stateless HTTP is relatively straightforward. Scaling stateful WebSocket connections requires more thought.

The Challenge

WebSocket connections are stateful. A client connects to a specific server instance and stays connected. This creates problems when you have multiple servers:

  1. How do messages reach users connected to different servers?
  2. How do you distribute connections evenly?
  3. What happens when a server goes down?

Solutions

Sticky Sessions: Route reconnecting clients to the same server. Simple but creates hotspots.

Pub/Sub Backbone: Use Redis or another pub/sub system to broadcast messages across servers.

// Using Redis for cross-server communication import { createClient } from 'redis'; const publisher = createClient(); const subscriber = createClient(); subscriber.subscribe('chat-messages'); subscriber.on('message', (channel, message) => { const data = JSON.parse(message); // Broadcast to all local connections in this room io.to(data.room).emit('message', data); }); // When a user sends a message socket.on('message', (data) => { // Publish to all servers publisher.publish('chat-messages', JSON.stringify(data)); });

Dedicated WebSocket Services: Services like Pusher, Ably, or AWS AppSync handle the scaling complexity for you.

Connection Management

With many connections, you need to think about:

  • Heartbeats: Detect dead connections before they time out
  • Connection limits: Prevent single clients from opening too many connections
  • Graceful shutdown: Drain connections before restarting servers
// Heartbeat implementation const HEARTBEAT_INTERVAL = 30000; const HEARTBEAT_TIMEOUT = 10000; io.on('connection', (socket) => { let isAlive = true; socket.on('pong', () => { isAlive = true; }); const heartbeat = setInterval(() => { if (!isAlive) { console.log('Connection dead, terminating'); socket.disconnect(true); return; } isAlive = false; socket.emit('ping'); }, HEARTBEAT_INTERVAL); socket.on('disconnect', () => { clearInterval(heartbeat); }); });

Security Considerations

WebSocket security is easy to overlook but critical.

Authentication

Authenticate during the handshake, not after.

// Server-side authentication io.use((socket, next) => { const token = socket.handshake.auth.token; try { const user = verifyToken(token); socket.user = user; next(); } catch (error) { next(new Error('Authentication failed')); } });

Origin Validation

Only accept connections from expected origins.

const io = new Server(server, { cors: { origin: ['https://example.com', 'https://app.example.com'], methods: ['GET', 'POST'] } });

Input Validation

Never trust client messages. Validate everything.

socket.on('message', (data) => { // Validate input if (typeof data.text !== 'string' || data.text.length > 1000) { socket.emit('error', { message: 'Invalid message format' }); return; } // Sanitize const sanitized = sanitizeHtml(data.text); // Check permissions if (!socket.user.rooms.includes(data.room)) { socket.emit('error', { message: 'Not authorized for this room' }); return; } // Now safe to broadcast io.to(data.room).emit('message', { userId: socket.user.id, text: sanitized, timestamp: Date.now() }); });

Wrapping Up

WebSockets opened up a world of possibilities for me. Features that seemed complex or impossible with traditional HTTP became straightforward. Real-time updates, live collaboration, instant notifications, all became achievable.

But WebSockets are not always the answer. For many applications, simpler approaches work fine. The key is understanding your requirements:

  • Do both client and server need to initiate communication?
  • How frequently will messages be exchanged?
  • How critical is latency?
  • What is your scaling strategy?

If you have read this far and you are excited to try WebSockets, start small. Build a simple notification system or a basic chat. Experience the satisfaction of seeing data flow in real-time without polling. Then gradually tackle more complex scenarios.

The web is becoming more interactive every day. Understanding real-time communication patterns is no longer optional for web developers. WebSockets are just one tool in that toolkit, but they are an important one.

Now go build something real-time. I am curious to see what you create.

David Kim
Written byDavid KimSoftware Engineer & Open Source Contributor
Read more articles