Building Real-Time Applications with WebSockets and Server-Sent Events

Building Real-Time Applications with WebSockets and Server-Sent Events

BySanjay Goraniya
2 min read
Share:

Building Real-Time Applications with WebSockets and Server-Sent Events

Real-time features have become expected in modern applications. Whether it's chat, live notifications, or collaborative editing, users expect instant updates. After building real-time features for multiple production applications, I've learned the patterns and pitfalls. Let me share what works.

Understanding Real-Time Communication

HTTP Limitations

Traditional HTTP is request-response:

  • Client requests → Server responds → Connection closes
  • No server-initiated communication
  • Polling is inefficient

Real-Time Solutions

  1. WebSockets - Full-duplex communication
  2. Server-Sent Events (SSE) - Server-to-client streaming
  3. Long Polling - Extended HTTP requests

WebSockets

How WebSockets Work

  1. Client initiates HTTP upgrade request
  2. Server upgrades to WebSocket protocol
  3. Persistent bidirectional connection
  4. Both sides can send messages anytime

WebSocket Server (Node.js)

Code
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const clients = new Map();

wss.on('connection', (ws, req) => {
  const userId = getUserIdFromRequest(req);
  clients.set(userId, ws);
  
  console.log(`Client connected: ${userId}`);
  
  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message);
      handleMessage(ws, data);
    } catch (error) {
      ws.send(JSON.stringify({ error: 'Invalid message format' }));
    }
  });
  
  ws.on('close', () => {
    clients.delete(userId);
    console.log(`Client disconnected: ${userId}`);
  });
  
  ws.on('error', (error) => {
    console.error('WebSocket error:', error);
    clients.delete(userId);
  });
});

function handleMessage(ws, data) {
  switch (data.type) {
    case 'chat':
      broadcastMessage(data);
      break;
    case 'ping':
      ws.send(JSON.stringify({ type: 'pong' }));
      break;
    default:
      ws.send(JSON.stringify({ error: 'Unknown message type' }));
  }
}

function broadcastMessage(message) {
  const data = JSON.stringify(message);
  clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  });
}

WebSocket Client (Browser)

Code
const ws = new WebSocket('ws://localhost:8080');

ws.onopen = () => {
  console.log('Connected to server');
  // Send authentication
  ws.send(JSON.stringify({
    type: 'auth',
    token: getAuthToken()
  }));
};

ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  handleServerMessage(data);
};

ws.onerror = (error) => {
  console.error('WebSocket error:', error);
};

ws.onclose = () => {
  console.log('Disconnected from server');
  // Reconnect logic
  setTimeout(connectWebSocket, 5000);
};

function sendMessage(text) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({
      type: 'chat',
      message: text,
      timestamp: Date.now()
    }));
  }
}

function handleServerMessage(data) {
  switch (data.type) {
    case 'chat':
      displayMessage(data.message);
      break;
    case 'notification':
      showNotification(data);
      break;
    case 'error':
      handleError(data);
      break;
  }
}

Reconnection Strategy

Code
class WebSocketManager {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectDelay = 1000;
  }

  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('Connected');
      this.reconnectAttempts = 0;
      this.onOpen();
    };
    
    this.ws.onclose = () => {
      console.log('Disconnected');
      this.reconnect();
    };
    
    this.ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
    
    this.ws.onmessage = (event) => {
      this.onMessage(JSON.parse(event.data));
    };
  }

  reconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
      console.log(`Reconnecting in ${delay}ms...`);
      setTimeout(() => this.connect(), delay);
    } else {
      console.error('Max reconnection attempts reached');
    }
  }

  send(data) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }

  close() {
    if (this.ws) {
      this.ws.close();
    }
  }
}

Server-Sent Events (SSE)

When to Use SSE

  • One-way communication - Server to client only
  • Simple implementation - Easier than WebSockets
  • Automatic reconnection - Browser handles it
  • HTTP-based - Works through proxies/firewalls

SSE Server (Node.js)

Code
const express = require('express');
const app = express();

app.get('/events', (req, res) => {
  // Set headers for SSE
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*');

  // Send initial connection message
  res.write('data: {"type":"connected"}\n\n');

  // Send periodic updates
  const interval = setInterval(() => {
    const data = {
      type: 'update',
      timestamp: Date.now(),
      message: 'Server update'
    };
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  }, 5000);

  // Clean up on client disconnect
  req.on('close', () => {
    clearInterval(interval);
    res.end();
  });
});

app.listen(3000);

SSE Client (Browser)

Code
const eventSource = new EventSource('/events');

eventSource.onopen = () => {
  console.log('SSE connection opened');
};

eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  handleUpdate(data);
};

eventSource.onerror = (error) => {
  console.error('SSE error:', error);
  // Browser automatically reconnects
};

eventSource.addEventListener('custom-event', (event) => {
  const data = JSON.parse(event.data);
  handleCustomEvent(data);
});

// Close connection
eventSource.close();

Choosing Between WebSockets and SSE

Use WebSockets When

  • Bidirectional communication needed
  • Low latency required
  • High frequency messages
  • Binary data transmission

Examples: Chat apps, gaming, collaborative editing

Use SSE When

  • One-way communication (server → client)
  • Simple implementation preferred
  • Automatic reconnection needed
  • HTTP compatibility required

Examples: Notifications, live feeds, progress updates

Scaling Real-Time Applications

Challenge: Single Server Limitation

WebSocket connections are stateful. With multiple servers, a client might connect to Server A, but the message needs to go to Server B.

Solution: Message Broker

Code
// Using Redis Pub/Sub
const redis = require('redis');
const publisher = redis.createClient();
const subscriber = redis.createClient();

// Server A receives message
wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message);
    
    // Publish to Redis
    publisher.publish('messages', JSON.stringify(data));
  });
});

// All servers subscribe
subscriber.subscribe('messages');
subscriber.on('message', (channel, message) => {
  const data = JSON.parse(message);
  
  // Broadcast to local clients
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(message);
    }
  });
});

Using Socket.io (Production-Ready)

Socket.io handles scaling, reconnection, and fallbacks:

Code
// Server
const io = require('socket.io')(server);
const redisAdapter = require('socket.io-redis');

io.adapter(redisAdapter({ host: 'localhost', port: 6379 }));

io.on('connection', (socket) => {
  socket.on('chat message', (msg) => {
    io.emit('chat message', msg);
  });
});

// Client
const socket = io();
socket.on('connect', () => {
  console.log('Connected');
});

socket.on('chat message', (msg) => {
  displayMessage(msg);
});

socket.emit('chat message', 'Hello!');

Best Practices

1. Authentication

Code
// Authenticate on connection
wss.on('connection', (ws, req) => {
  const token = getTokenFromRequest(req);
  
  verifyToken(token)
    .then(user => {
      ws.userId = user.id;
      // Connection established
    })
    .catch(() => {
      ws.close(1008, 'Unauthorized');
    });
});

2. Heartbeat/Ping-Pong

Code
// Server
setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      return ws.terminate();
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

ws.on('pong', () => {
  ws.isAlive = true;
});

3. Rate Limiting

Code
const rateLimiter = new Map();

wss.on('connection', (ws, req) => {
  const ip = req.socket.remoteAddress;
  const limiter = {
    count: 0,
    resetTime: Date.now() + 60000
  };
  rateLimiter.set(ip, limiter);
  
  ws.on('message', () => {
    const limiter = rateLimiter.get(ip);
    if (Date.now() > limiter.resetTime) {
      limiter.count = 0;
      limiter.resetTime = Date.now() + 60000;
    }
    
    if (limiter.count > 100) {
      ws.close(1008, 'Rate limit exceeded');
      return;
    }
    
    limiter.count++;
  });
});

4. Error Handling

Code
ws.on('error', (error) => {
  console.error('WebSocket error:', error);
  // Log error
  // Notify monitoring
  // Clean up resources
});

ws.on('close', (code, reason) => {
  console.log(`Connection closed: ${code} - ${reason}`);
  // Clean up
});

Real-World Example

Feature: Real-time order updates for e-commerce

Requirements:

  • Customers see order status updates
  • Support team sees new orders
  • Admin sees system metrics

Implementation:

Code
// WebSocket for bidirectional (admin dashboard)
const adminWs = new WebSocket('ws://api/admin');

// SSE for one-way (customer updates)
const customerSSE = new EventSource('/api/orders/123/updates');

// Different channels
io.on('connection', (socket) => {
  socket.on('join-room', (room) => {
    socket.join(room);
  });
});

// Send to specific room
io.to('order-123').emit('status-update', {
  orderId: '123',
  status: 'shipped',
  timestamp: Date.now()
});

Result:

  • Real-time updates for all users
  • Scalable across multiple servers
  • Handles 10,000+ concurrent connections

Conclusion

Real-time features are essential in modern applications. The key is choosing the right tool:

  • WebSockets - Full-duplex, low latency
  • SSE - Simple, one-way, HTTP-compatible
  • Socket.io - Production-ready, handles scaling

Remember: Start simple, add complexity when needed. Real-time features can be challenging, but with the right patterns and tools, they're manageable.

What real-time features have you built? What challenges did you face? I'd love to hear about your experiences.

Share: