Skip to content
Build a Real-Time Notification System with WebSockets (Step by Step)

Build a Real-Time Notification System with WebSockets (Step by Step)

DodaTech Updated Jun 20, 2026 10 min read

Build a real-time notification system using WebSockets with a Node.js server (ws library), a browser client, multiple notification types (info, warning, error), sound alerts, and persistent history.

What You’ll Build

You’ll build a notification system where the server can push real-time notifications to all connected browser clients. Notifications have types (info, warning, error), display with appropriate styling and icons, play distinct sounds per type, and persist in a history panel. This same pattern powers the live update notifications in DodaZIP and the threat alert system in Durga Antivirus Pro.

Why Real-Time Notifications Matter

Modern applications don’t wait for users to refresh. Notifications appear instantly when a new email arrives, a payment succeeds, a deployment completes, or a security threat is detected. WebSockets provide the persistent, bidirectional connection needed for this. Building your own system teaches you the WebSocket protocol, client-server event architecture, and UX patterns for real-time feedback.

Prerequisites

Step 1: Project Setup

mkdir notification-system
cd notification-system
npm init -y
npm install ws express uuid
npm install -D nodemon
mkdir public

Project structure:

notification-system/
├── server.js
├── public/
│   ├── index.html
│   ├── style.css
│   └── app.js
└── package.json

Step 2: WebSocket Server

// server.js
const express = require('express');
const http = require('http');
const { WebSocketServer } = require('ws');
const { v4: uuidv4 } = require('uuid');
const path = require('path');

const app = express();
app.use(express.static(path.join(__dirname, 'public')));

const server = http.createServer(app);
const wss = new WebSocketServer({ server });

// Store connected clients with metadata
const clients = new Map();
// Store notification history (in production, use a database)
const notificationHistory = [];

wss.on('connection', (ws, req) => {
  const clientId = uuidv4();
  const clientInfo = {
    id: clientId,
    ws,
    connectedAt: new Date(),
    ip: req.socket.remoteAddress,
  };
  clients.set(clientId, clientInfo);

  console.log(`Client connected: ${clientId} (${clientInfo.ip})`);

  // Send client their ID
  ws.send(JSON.stringify({
    type: 'welcome',
    clientId,
    history: notificationHistory.slice(-50), // Last 50 notifications
  }));

  // Send notification to ALL connected clients that someone joined
  broadcast({
    id: uuidv4(),
    type: 'info',
    title: 'New Connection',
    message: `Client ${clientId.slice(0, 8)} connected`,
    timestamp: new Date().toISOString(),
  }, clientId);

  ws.on('message', (data) => {
    try {
      const message = JSON.parse(data.toString());
      handleClientMessage(clientId, message);
    } catch (err) {
      console.error('Invalid message:', err);
    }
  });

  ws.on('close', () => {
    clients.delete(clientId);
    console.log(`Client disconnected: ${clientId}`);
    broadcast({
      id: uuidv4(),
      type: 'warning',
      title: 'Disconnection',
      message: `Client ${clientId.slice(0, 8)} disconnected`,
      timestamp: new Date().toISOString(),
    });
  });

  ws.on('error', (err) => {
    console.error(`WebSocket error for ${clientId}:`, err.message);
    clients.delete(clientId);
  });
});

function handleClientMessage(clientId, message) {
  switch (message.action) {
    case 'send_notification':
      // A client can send a notification (e.g., trigger from dashboard)
      const notification = {
        id: uuidv4(),
        type: message.notificationType || 'info',
        title: message.title || 'Notification',
        message: message.message || '',
        source: clientId,
        timestamp: new Date().toISOString(),
      };
      addNotification(notification);
      broadcast(notification);
      break;
    case 'mark_read':
      // Mark notification as read (simplified — client tracks its own read state)
      break;
    default:
      console.log(`Unknown action from ${clientId}:`, message.action);
  }
}

function addNotification(notification) {
  notificationHistory.push(notification);
  // Keep only last 200 notifications
  if (notificationHistory.length > 200) {
    notificationHistory.shift();
  }
}

function broadcast(notification, excludeClientId = null) {
  const message = JSON.stringify(notification);
  for (const [id, client] of clients) {
    if (id === excludeClientId) continue;
    if (client.ws.readyState === 1) { // WebSocket.OPEN
      client.ws.send(message);
    }
  }
}

// Simulate periodic notifications (in production, replace with real events)
setInterval(() => {
  const types = ['info', 'warning', 'error'];
  const type = types[Math.floor(Math.random() * types.length)];
  const messages = {
    info: ['Server health check passed', 'Cache refreshed', 'Backup completed'],
    warning: ['Memory usage above 80%', 'Certificate expires in 7 days', 'Slow query detected'],
    error: ['Database connection timeout', 'Disk space low', 'Service unreachable'],
  };

  const msg = messages[type][Math.floor(Math.random() * 3)];
  addNotification({
    id: uuidv4(),
    type,
    title: type.charAt(0).toUpperCase() + type.slice(1),
    message: msg,
    timestamp: new Date().toISOString(),
  });
  broadcast({
    id: uuidv4(),
    type,
    title: type.charAt(0).toUpperCase() + type.slice(1),
    message: msg,
    timestamp: new Date().toISOString(),
  });
}, 15000);

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Notification server on http://localhost:${PORT}`);
});

Expected output: The WebSocket server accepts connections, sends welcome with history, broadcasts notifications to all clients, and generates a simulated notification every 15 seconds. Each notification has a unique ID, type, title, message, and timestamp.

Step 3: Browser Client — HTML

<!-- public/index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Real-Time Notifications</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="app">
    <header>
      <h1>Notifications</h1>
      <button id="clearBtn">Clear All</button>
    </header>

    <!-- Notification toast container -->
    <div id="toastContainer" class="toast-container"></div>

    <!-- History panel -->
    <div class="controls">
      <button onclick="sendTestNotification('info')">Info</button>
      <button onclick="sendTestNotification('warning')">Warning</button>
      <button onclick="sendTestNotification('error')">Error</button>
    </div>

    <div class="history-panel">
      <h2>History</h2>
      <div id="notificationList" class="notification-list"></div>
    </div>
  </div>

  <script src="app.js"></script>
</body>
</html>

Step 4: Browser Client — JavaScript

// public/app.js
let ws = null;
let notificationCount = 0;
const MAX_HISTORY = 100;

function connect() {
  ws = new WebSocket(`ws://${location.host}`);

  ws.onopen = () => {
    console.log('Connected to notification server');
    updateStatus('Connected');
  };

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

    if (data.type === 'welcome') {
      // Load history
      data.history.forEach(addToHistory);
      updateConnectionInfo(data.clientId);
      return;
    }

    // It's a notification
    showToast(data);
    addToHistory(data);
    playSound(data.type);
    notificationCount++;
    updateCount();
  };

  ws.onclose = () => {
    console.log('Disconnected. Reconnecting in 3s...');
    updateStatus('Reconnecting...');
    setTimeout(connect, 3000);
  };

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

function showToast(notification) {
  const container = document.getElementById('toastContainer');
  const toast = document.createElement('div');
  toast.className = `toast toast-${notification.type}`;

  const icons = { info: 'ℹ️', warning: '⚠️', error: '🚫' };
  toast.innerHTML = `
    <span class="toast-icon">${icons[notification.type] || 'ℹ️'}</span>
    <div class="toast-content">
      <strong>${notification.title}</strong>
      <p>${notification.message}</p>
    </div>
    <button class="toast-close" onclick="this.parentElement.remove()">&times;</button>
  `;

  container.appendChild(toast);

  // Auto-remove after 5 seconds
  setTimeout(() => {
    if (toast.parentElement) toast.remove();
  }, 5000);
}

function addToHistory(notification) {
  const list = document.getElementById('notificationList');
  const item = document.createElement('div');
  item.className = `history-item history-${notification.type}`;
  item.innerHTML = `
    <span class="time">${new Date(notification.timestamp).toLocaleTimeString()}</span>
    <span class="badge badge-${notification.type}">${notification.type.toUpperCase()}</span>
    <strong>${notification.title}</strong>
    <span>${notification.message}</span>
  `;
  list.prepend(item);

  // Limit history
  while (list.children.length > MAX_HISTORY) {
    list.lastChild.remove();
  }
}

function playSound(type) {
  const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  const oscillator = audioCtx.createOscillator();
  const gain = audioCtx.createGain();
  oscillator.connect(gain);
  gain.connect(audioCtx.destination);

  switch (type) {
    case 'info':
      oscillator.frequency.value = 800;
      gain.gain.value = 0.1;
      oscillator.start();
      oscillator.stop(audioCtx.currentTime + 0.1);
      break;
    case 'warning':
      oscillator.frequency.value = 400;
      gain.gain.value = 0.2;
      oscillator.start();
      oscillator.stop(audioCtx.currentTime + 0.2);
      break;
    case 'error':
      oscillator.frequency.value = 200;
      gain.gain.value = 0.3;
      oscillator.start();
      setTimeout(() => {
        const o2 = audioCtx.createOscillator();
        const g2 = audioCtx.createGain();
        o2.connect(g2);
        g2.connect(audioCtx.destination);
        o2.frequency.value = 150;
        g2.gain.value = 0.3;
        o2.start();
        o2.stop(audioCtx.currentTime + 0.3);
      }, 300);
      break;
  }
}

function sendTestNotification(type) {
  if (ws && ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({
      action: 'send_notification',
      notificationType: type,
      title: 'Test ' + type.charAt(0).toUpperCase() + type.slice(1),
      message: 'This is a test ' + type + ' notification',
    }));
  }
}

function updateStatus(status) {
  document.title = `Notifications [${status}]`;
}

function updateConnectionInfo(clientId) {
  console.log('Connected with ID:', clientId);
}

function updateCount() {
  // Update favicon or badge if desired
}

document.addEventListener('DOMContentLoaded', () => {
  connect();
  document.getElementById('clearBtn').addEventListener('click', () => {
    document.getElementById('notificationList').innerHTML = '';
    notificationCount = 0;
  });
});

Step 5: Styling

/* public/style.css */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; }
.app { max-width: 600px; margin: 0 auto; padding: 20px; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }

.toast-container { position: fixed; top: 20px; right: 20px; z-index: 1000; }
.toast { display: flex; align-items: center; padding: 12px 16px; margin-bottom: 8px; border-radius: 8px; min-width: 300px; animation: slideIn 0.3s ease; }
.toast-info { background: #1e3a5f; border-left: 4px solid #3b82f6; }
.toast-warning { background: #5c3d1e; border-left: 4px solid #f59e0b; }
.toast-error { background: #5c1e1e; border-left: 4px solid #ef4444; }
.toast-icon { font-size: 1.5em; margin-right: 12px; }
.toast-content { flex: 1; }
.toast-content p { font-size: 0.9em; color: #94a3b8; }
.toast-close { background: none; border: none; color: #94a3b8; font-size: 1.5em; cursor: pointer; }

@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }

.controls { display: flex; gap: 8px; margin-bottom: 20px; }
.controls button { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
.controls button:nth-child(1) { background: #3b82f6; color: white; }
.controls button:nth-child(2) { background: #f59e0b; color: white; }
.controls button:nth-child(3) { background: #ef4444; color: white; }

.history-panel { background: #1e293b; border-radius: 12px; padding: 16px; }
.history-panel h2 { margin-bottom: 12px; }
.notification-list { max-height: 500px; overflow-y: auto; }
.history-item { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 6px; margin-bottom: 4px; font-size: 0.9em; }
.history-item:nth-child(odd) { background: rgba(255,255,255,0.03); }
.time { color: #64748b; font-size: 0.85em; min-width: 70px; }
.badge { font-size: 0.75em; padding: 2px 6px; border-radius: 4px; }
.badge-info { background: #3b82f6; color: white; }
.badge-warning { background: #f59e0b; color: black; }
.badge-error { background: #ef4444; color: white; }

Architecture


sequenceDiagram
    participant Browser1 as Browser A
    participant Server as Node.js + ws Server
    participant Browser2 as Browser B

    Browser1->>Server: WebSocket handshake
    Server->>Browser1: welcome (clientId, history)
    Server->>Browser2: New connection (info)
    
    Browser2->>Server: WebSocket handshake
    Server->>Browser2: welcome (clientId, history)
    Server->>Browser1: New connection (info)
    
    Note over Server: Server broadcasts notification (from timer or event)
    Server->>Browser1: {type: "warning", title: "Memory high", ...}
    Server->>Browser2: {type: "warning", title: "Memory high", ...}
    
    Note over Browser1: Show toast + play sound + add to history
    Note over Browser2: Show toast + play sound + add to history
    
    Browser1->>Server: Disconnect
    Server->>Browser2: Disconnection (warning)

Common Errors

1. WebSocket connection fails with “Unexpected response code: 200” The server is not upgrading the connection. Ensure the WebSocket server is attached to the HTTP server correctly. The path option on WebSocketServer must match the client URL. If using server.on('upgrade', ...), make sure it’s not conflicting with Express routes.

2. toast-container shows no notifications The showToast function appends to toastContainer which has position: fixed. If the container has display: none or zero dimensions, toasts are invisible. Check CSS. Also verify the WebSocket message format — it must have type, title, and message fields.

3. Sound doesn’t play on first notification Browsers block AudioContext creation without user interaction. The first click on a test button creates the context. After that, playSound can create oscillators. To autoplay sounds, resume the context on the first user click: audioCtx.resume().

4. Notifications pile up and never disappear Toasts have setTimeout(() => toast.remove(), 5000). If the toast element is removed from DOM by something else (e.g., “Clear All”), the setTimeout still tries to remove a detached element. Add a guard: if (toast.parentElement) toast.remove().

5. Server memory grows unbounded The notificationHistory array stores all notifications in memory. After hours of runtime, this can consume gigabytes. The code limits to 200 entries. In production, use Redis with a TTL or a database with pruning.

Practice Questions

1. Why does the server send the entire history on connection? New clients need context — they don’t know what happened before they joined. Sending the last 50 notifications lets them see recent events immediately. Without this, a new tab would show an empty list until the next notification arrives.

2. What happens when the server sends a message to a disconnected client? ws.send() on a closed socket throws an error. The broadcast() function checks client.ws.readyState === 1 (WebSocket.OPEN) before sending. The close event handler removes the client from the Map, so no further broadcasts attempt.

3. How does the reconnection logic work? The close event handler calls setTimeout(connect, 3000), which creates a new WebSocket connection after 3 seconds. The old connection is fully closed. The new connection receives a fresh welcome message with history. This is a simple reconnect — production systems use exponential backoff.

4. Challenge: Notification grouping Instead of showing every notification individually, group identical notifications: “3 memory warnings in the last minute.” Show a count badge. Add a “collapse” toggle. This is how mobile OS notification centers work.

5. Challenge: Server-side notification dispatch API Add a POST /api/notifications endpoint to the Express server. Accept { type, title, message }. Validate with Joi or Zod. Broadcast to all connected clients. This allows external services (cron jobs, webhooks) to trigger notifications. Protect the endpoint with an API key.

FAQ

Can I use this with HTTPS/WSS?
Yes. Use the https module instead of http, and pass the SSL certificate options. The WebSocket server auto-detects TLS. On the client, change ws:// to wss://. For local development with HTTPS, use mkcert or a reverse proxy like Caddy.
How do I scale WebSockets beyond one server?
Use a pub/sub layer like Redis. Instead of in-memory broadcast, publish to a Redis channel. All server instances subscribe and forward to their local clients. The socket.io library wraps this pattern with automatic fallback to long-polling.
Can I send notifications to specific users instead of broadcasting?
Yes. Track which user is connected to which WebSocket (use a userId → ws Map). When sending a notification, look up the user’s WebSocket and send directly. The excludeClientId parameter already shows the pattern.

Next Steps

  • Add Redis pub/sub for multi-server scaling
  • Store notifications in MongoDB for persistent history
  • Add JWT authentication for user-specific notifications
  • Explore the WebSocket API reference for advanced features
  • Try the Real-Time Dashboard project for another WebSocket use case

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro