Build a Real-Time Notification System with WebSockets (Step by Step)
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
- Node.js 18+ installed
- JavaScript ES6+ (browser and Node.js)
- Basic HTML and CSS
- Familiarity with WebSocket concepts
Step 1: Project Setup
mkdir notification-system
cd notification-system
npm init -y
npm install ws express uuid
npm install -D nodemon
mkdir publicProject structure:
notification-system/
├── server.js
├── public/
│ ├── index.html
│ ├── style.css
│ └── app.js
└── package.jsonStep 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()">×</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
Next Steps
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro