DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

Building Real-Time Apps with Node.js: WebSockets, Events and Scalability

Real-time applications have changed the expectations users have from modern web products. Chat messages should appear instantly, dashboards should update without refreshes, and collaboration tools should reflect changes as they happen.

Node.js is a strong fit for these workloads because its event-driven architecture can efficiently handle many concurrent connections. Combined with WebSockets, developers can build persistent communication channels between browsers and servers without repeatedly polling an API.

In this article, we will build a small real-time notification server using Node.js and WebSockets. Along the way, we will look at connection management, broadcasting events, handling disconnections, and structuring the server so it can evolve into a production application.

Building a Real-Time Node.js Server with WebSockets

Traditional HTTP APIs follow a request-response model: the client asks for data and the server responds. That approach works extremely well for CRUD applications, but it becomes inefficient when the server needs to proactively notify connected clients about new events.

WebSockets solve this problem by creating a long-lived, bidirectional connection. After the initial handshake, both the client and server can send messages independently, which makes the protocol useful for chat applications, live dashboards, multiplayer experiences, notifications, and collaborative tools.

The example below uses the popular ws package to create a WebSocket server on top of Node.js. Each connected client receives a unique identifier, can send messages to the server, and receives broadcast events from other clients.

A useful pattern is to keep connection management separate from business logic. The server maintains a collection of connected clients, validates incoming messages, creates structured events, and broadcasts only the information that clients need. This makes the system easier to extend with authentication, persistence, rooms, rate limiting, or external message brokers later.

For a production system, a single Node.js process may eventually become a limitation when traffic grows horizontally. When multiple instances are running, a shared broker such as Redis or another messaging system can synchronize events between processes so users connected to different instances still receive the same updates.

const { WebSocketServer } = require('ws');
const crypto = require('crypto');

// Create the WebSocket server on port 8080.
const PORT = 8080;
const wss = new WebSocketServer({ port: PORT });

// Store every currently connected client.
const clients = new Map();

console.log('========================================');
console.log('Starting real-time Node.js server...');
console.log(`WebSocket server will listen on port ${PORT}`);
console.log('========================================');

// Generate a short unique identifier for every client.
function createClientId() {
  return crypto.randomBytes(4).toString('hex');
}

// Send a structured event to one client.
function sendToClient(client, event) {
  if (client.readyState !== client.OPEN) {
    return;
  }

  client.send(JSON.stringify(event));
}

// Broadcast an event to every connected client.
function broadcast(event) {
  console.log(`[BROADCAST] Sending event: ${event.type}`);

  for (const [clientId, client] of clients) {
    if (client.readyState === client.OPEN) {
      sendToClient(client, event);
    } else {
      console.log(`[CLEANUP] Removing inactive client ${clientId}`);
      clients.delete(clientId);
    }
  }
}

// Handle a new WebSocket connection.
wss.on('connection', (socket, request) => {
  const clientId = createClientId();
  clients.set(clientId, socket);

  const clientCount = clients.size;

  console.log(`\n[CONNECT] Client ${clientId} connected`);
  console.log(`[STATS] Active clients: ${clientCount}`);
  console.log(`[NETWORK] Remote address: ${request.socket.remoteAddress}`);

  // Immediately tell the new client that its connection is ready.
  sendToClient(socket, {
    type: 'connection.ready',
    clientId,
    message: 'Real-time connection established'
  });

  // Notify everyone that a new client joined.
  broadcast({
    type: 'client.joined',
    clientId,
    activeClients: clients.size,
    timestamp: new Date().toISOString()
  });

  // Process messages received from this client.
  socket.on('message', (rawMessage) => {
    console.log(`\n[MESSAGE] Received from ${clientId}`);

    try {
      const message = JSON.parse(rawMessage.toString());

      // Validate the basic message contract.
      if (typeof message.text !== 'string' || !message.text.trim()) {
        sendToClient(socket, {
          type: 'error',
          message: 'The text field must be a non-empty string.'
        });
        return;
      }

      const text = message.text.trim();

      console.log(`[MESSAGE] Content: ${text}`);
      console.log('[MESSAGE] Broadcasting to connected clients...');

      // Create a normalized event instead of forwarding raw input.
      broadcast({
        type: 'message.created',
        id: crypto.randomUUID(),
        clientId,
        text,
        timestamp: new Date().toISOString()
      });
    } catch (error) {
      console.error('[ERROR] Invalid JSON message:', error.message);

      sendToClient(socket, {
        type: 'error',
        message: 'Invalid JSON payload.'
      });
    }
  });

  // Handle connection-level errors.
  socket.on('error', (error) => {
    console.error(`[SOCKET ERROR] Client ${clientId}: ${error.message}`);
  });

  // Remove disconnected clients from memory.
  socket.on('close', () => {
    clients.delete(clientId);

    console.log(`\n[DISCONNECT] Client ${clientId} disconnected`);
    console.log(`[STATS] Active clients: ${clients.size}`);

    // Inform remaining clients about the disconnect.
    broadcast({
      type: 'client.left',
      clientId,
      activeClients: clients.size,
      timestamp: new Date().toISOString()
    });
  });
});

// Periodically publish a server heartbeat.
setInterval(() => {
  if (clients.size === 0) {
    return;
  }

  broadcast({
    type: 'server.heartbeat',
    activeClients: clients.size,
    timestamp: new Date().toISOString()
  });
}, 30000);

// Gracefully close all connections when the process stops.
function shutdown(signal) {
  console.log(`\n[SHUTDOWN] Received ${signal}`);
  console.log('[SHUTDOWN] Closing client connections...');

  for (const [clientId, socket] of clients) {
    console.log(`[SHUTDOWN] Closing client ${clientId}`);
    socket.close(1001, 'Server shutting down');
  }

  wss.close(() => {
    console.log('[SHUTDOWN] WebSocket server closed.');
    process.exit(0);
  });
}

process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));

console.log('[READY] Waiting for WebSocket clients...');

Enter fullscreen mode Exit fullscreen mode

Conclusion

The key idea behind a real-time Node.js application is not simply opening a WebSocket connection. The real engineering challenge is designing reliable event flows, managing connection state, validating messages, and making failures predictable.

Starting with a small in-memory WebSocket server is useful for understanding these fundamentals. Once the application grows, authentication, authorization, persistence, rate limiting, observability, reconnection strategies, and horizontal scaling become important parts of the architecture.

Node.js provides the event-driven foundation, while WebSockets provide the communication channel. Together, they give JavaScript developers a practical foundation for building responsive applications where users see important changes as they happen rather than waiting for the next refresh.

Top comments (0)