Real-time Notifications

Understanding WebSocket-based notification systems in modern web applications

Understanding Real-time Notifications

Real-time notifications have become an essential element of modern web applications. Think about how you receive instant notifications when someone messages you on social media, when your food delivery status changes, or when a new email arrives in your inbox - all without refreshing the page.

Today, we'll explore how to implement these real-time notification systems using WebSockets and Socket.io, with a focus on creating responsive, interactive user experiences.

Why Real-time Notifications Matter

Traditional web applications relied on the request-response cycle: the client makes a request, the server responds, and the process repeats. To get updated information, users had to manually refresh their page or the application had to poll the server frequently. This approach has several limitations:

Real-time notifications solve these problems by establishing persistent connections that allow servers to push updates to clients immediately when events occur. This creates more engaging, responsive applications that feel alive and dynamic.

Real World Applications

  • Social Media: New messages, likes, comments, and friend requests
  • E-commerce: Order status updates, price drops, limited-time offers
  • Collaboration Tools: Document edits, comments, new assignments
  • Financial Apps: Stock price changes, transaction alerts
  • IoT Applications: Smart home device status changes

Technologies for Real-time Communication

Several approaches have been used to achieve real-time communication, each with different advantages and limitations:

flowchart TB
    RT[Real-time Communication Techniques]
    P[Polling]
    LP[Long Polling]
    SSE[Server-Sent Events]
    WS[WebSockets]
    
    RT --> P
    RT --> LP
    RT --> SSE
    RT --> WS
    
    P -->|"Client requests at intervals
High overhead
Not truly real-time"| PE[Regular Polling] LP -->|"Client holds connection open
Server responds when data available
Less efficient than WebSockets"| LPE[Long Polling] SSE -->|"Server-to-client only
Over HTTP
Built-in reconnection"| SSEE[EventSource API] WS -->|"Full-duplex
Persistent connection
Low latency"| WSI[WebSocket API]

Today, we'll focus on WebSockets, the most robust solution for true real-time bidirectional communication, and Socket.io, a library that makes WebSocket implementation easier and more reliable.

Understanding WebSockets

WebSockets provide a persistent connection between client and server, allowing both parties to send data at any time without the overhead of HTTP requests. This makes WebSockets ideal for applications requiring low latency and frequent updates.

sequenceDiagram
    participant Client
    participant Server
    
    Note over Client,Server: HTTP Handshake
    Client->>Server: HTTP Request with Upgrade header
    Server->>Client: HTTP 101 Switching Protocols
    
    Note over Client,Server: WebSocket Connection Established
    
    Client->>Server: WebSocket message
    Server->>Client: WebSocket message
    
    Note over Server: Event occurs
    Server->>Client: Push notification without client request
    
    Client->>Server: WebSocket message
    
    Note over Client,Server: Either side can initiate communication
                

Key WebSocket Characteristics

Introducing Socket.io

While the native WebSocket API is powerful, Socket.io offers several advantages that make it the preferred choice for many developers:

Basic Socket.io Setup

Server-side (Node.js with Express)


// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// Serve static files
app.use(express.static('public'));

// Socket.io connection handling
io.on('connection', (socket) => {
  console.log('A user connected:', socket.id);
  
  // Listen for events from this client
  socket.on('join-room', (room) => {
    socket.join(room);
    console.log(`User ${socket.id} joined room: ${room}`);
  });
  
  // Listen for disconnect
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
  });
});

server.listen(3000, () => {
  console.log('Server listening on port 3000');
});
                

Client-side


// In your HTML file
<script src="/socket.io/socket.io.js"></script>
<script>
  // Connect to the Socket.io server
  const socket = io();
  
  // Listen for connection event
  socket.on('connect', () => {
    console.log('Connected to server with ID:', socket.id);
    
    // Join a specific room
    socket.emit('join-room', 'notifications');
  });
  
  // Listen for disconnection
  socket.on('disconnect', () => {
    console.log('Disconnected from server');
  });
</script>
                

Building a Notification System

Now, let's build a practical notification system using Socket.io. We'll create a system that can support different types of notifications and deliver them to specific users or groups.

flowchart LR
    E[Event Occurs] --> BE[Backend Service]
    BE --> N[Notification Service]
    N --> S[Socket.io Server]
    S --> R{Routes to Recipients}
    R --> U1[User 1]
    R --> U2[User 2]
    R --> G[Group/Room]
    G --> U3[User 3]
    G --> U4[User 4]
                

Server-side Notification System


// notification-service.js
class NotificationService {
  constructor(io) {
    this.io = io;
    this.userSockets = new Map(); // Map user IDs to socket IDs
  }
  
  // Register a user connection
  registerUser(userId, socketId) {
    if (!this.userSockets.has(userId)) {
      this.userSockets.set(userId, new Set());
    }
    this.userSockets.get(userId).add(socketId);
    console.log(`User ${userId} registered with socket ${socketId}`);
  }
  
  // Unregister a socket
  unregisterSocket(socketId) {
    for (const [userId, sockets] of this.userSockets.entries()) {
      if (sockets.has(socketId)) {
        sockets.delete(socketId);
        console.log(`Socket ${socketId} unregistered from user ${userId}`);
        if (sockets.size === 0) {
          this.userSockets.delete(userId);
          console.log(`User ${userId} has no active connections`);
        }
        break;
      }
    }
  }
  
  // Send notification to a specific user
  sendToUser(userId, notificationType, payload) {
    if (this.userSockets.has(userId)) {
      const socketIds = this.userSockets.get(userId);
      for (const socketId of socketIds) {
        this.io.to(socketId).emit('notification', {
          type: notificationType,
          data: payload,
          timestamp: new Date()
        });
      }
      console.log(`Notification sent to user ${userId}`);
      return socketIds.size; // Return number of active connections
    }
    return 0; // No active connections for this user
  }
  
  // Send notification to a room/group
  sendToRoom(room, notificationType, payload) {
    this.io.to(room).emit('notification', {
      type: notificationType,
      data: payload,
      timestamp: new Date()
    });
    console.log(`Notification sent to room ${room}`);
  }
  
  // Broadcast notification to all connected clients
  broadcast(notificationType, payload) {
    this.io.emit('notification', {
      type: notificationType,
      data: payload,
      timestamp: new Date()
    });
    console.log('Notification broadcasted to all users');
  }
}

module.exports = NotificationService;
            

Integration with Express and Socket.io


// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const NotificationService = require('./notification-service');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// Initialize notification service
const notificationService = new NotificationService(io);

// API routes for triggering notifications
app.use(express.json());

// Route to send notification to specific user
app.post('/api/notifications/user/:userId', (req, res) => {
  const { userId } = req.params;
  const { type, data } = req.body;
  
  const sentCount = notificationService.sendToUser(userId, type, data);
  
  if (sentCount > 0) {
    res.status(200).json({ success: true, sentTo: sentCount });
  } else {
    res.status(404).json({ success: false, message: 'User not connected' });
  }
});

// Route to send notification to a room
app.post('/api/notifications/room/:roomName', (req, res) => {
  const { roomName } = req.params;
  const { type, data } = req.body;
  
  notificationService.sendToRoom(roomName, type, data);
  res.status(200).json({ success: true });
});

// Socket.io connection handling
io.on('connection', (socket) => {
  console.log('A user connected:', socket.id);
  
  // Authenticate and register user
  socket.on('auth', (userData) => {
    // In a real app, you'd verify the user authentication here
    const userId = userData.userId;
    
    // Register this socket with the user ID
    notificationService.registerUser(userId, socket.id);
    
    // Join user to their personal room
    socket.join(`user:${userId}`);
    
    // Acknowledge successful authentication
    socket.emit('auth_success', { userId });
  });
  
  // Join a room/group
  socket.on('join-room', (room) => {
    socket.join(room);
    console.log(`Socket ${socket.id} joined room: ${room}`);
    
    // Notify others in the room
    socket.to(room).emit('notification', {
      type: 'user-joined',
      data: { roomName: room },
      timestamp: new Date()
    });
  });
  
  // Listen for disconnect
  socket.on('disconnect', () => {
    console.log('User disconnected:', socket.id);
    notificationService.unregisterSocket(socket.id);
  });
});

server.listen(3000, () => {
  console.log('Server listening on port 3000');
});
            

Client-side Notification Handling


class NotificationClient {
  constructor() {
    this.socket = io();
    this.notificationHandlers = new Map();
    this.userId = null;
    
    // Set up socket event listeners
    this.setupSocketListeners();
  }
  
  setupSocketListeners() {
    // Connection established
    this.socket.on('connect', () => {
      console.log('Connected to notification server');
      
      // If we have cached user ID, authenticate
      const cachedUserId = localStorage.getItem('userId');
      if (cachedUserId) {
        this.authenticate(cachedUserId);
      }
    });
    
    // Handle authentication success
    this.socket.on('auth_success', (data) => {
      console.log('Authentication successful:', data);
      this.userId = data.userId;
      localStorage.setItem('userId', data.userId);
      
      // Dispatch an event for the UI
      document.dispatchEvent(new CustomEvent('notifications:ready'));
    });
    
    // Handle incoming notifications
    this.socket.on('notification', (notification) => {
      console.log('Received notification:', notification);
      
      // Play sound for new notifications
      this.playNotificationSound();
      
      // Store notification in history
      this.storeNotification(notification);
      
      // Handle based on notification type
      if (this.notificationHandlers.has(notification.type)) {
        this.notificationHandlers.get(notification.type)(notification);
      }
      
      // Dispatch generic notification event for the UI
      document.dispatchEvent(new CustomEvent('notifications:new', { 
        detail: notification 
      }));
    });
    
    // Handle disconnection
    this.socket.on('disconnect', () => {
      console.log('Disconnected from notification server');
      document.dispatchEvent(new CustomEvent('notifications:offline'));
    });
  }
  
  // Authenticate with the notification server
  authenticate(userId) {
    this.socket.emit('auth', { userId });
  }
  
  // Join a notification room/channel
  joinRoom(roomName) {
    this.socket.emit('join-room', roomName);
  }
  
  // Register a handler for a specific notification type
  registerHandler(notificationType, handlerFunction) {
    this.notificationHandlers.set(notificationType, handlerFunction);
  }
  
  // Play a sound when notification arrives
  playNotificationSound() {
    const audio = new Audio('/sounds/notification.mp3');
    audio.play().catch(err => console.log('Could not play notification sound', err));
  }
  
  // Store notification in browser storage
  storeNotification(notification) {
    const notifications = JSON.parse(localStorage.getItem('notifications') || '[]');
    notifications.push(notification);
    
    // Keep only the latest 50 notifications
    if (notifications.length > 50) {
      notifications.shift();
    }
    
    localStorage.setItem('notifications', JSON.stringify(notifications));
  }
  
  // Get notification history
  getNotificationHistory() {
    return JSON.parse(localStorage.getItem('notifications') || '[]');
  }
}

// Usage example:
document.addEventListener('DOMContentLoaded', () => {
  const notificationClient = new NotificationClient();
  
  // Register handlers for different notification types
  notificationClient.registerHandler('message', (notification) => {
    showMessageNotification(notification.data);
  });
  
  notificationClient.registerHandler('friend-request', (notification) => {
    showFriendRequestNotification(notification.data);
  });
  
  // Function to display notification in UI
  function showMessageNotification(data) {
    const notificationElement = document.createElement('div');
    notificationElement.className = 'notification message-notification';
    notificationElement.innerHTML = `
      <div class="notification-icon">
        <i class="fas fa-envelope"></i>
      </div>
      <div class="notification-content">
        <h4>New Message from ${data.sender}</h4>
        <p>${data.preview}</p>
      </div>
      <div class="notification-actions">
        <button class="view-btn">View</button>
      </div>
    `;
    
    document.getElementById('notification-container').appendChild(notificationElement);
    
    // Auto-remove after 5 seconds
    setTimeout(() => {
      notificationElement.classList.add('notification-hiding');
      setTimeout(() => notificationElement.remove(), 500);
    }, 5000);
  }
  
  // Function to display friend request notification
  function showFriendRequestNotification(data) {
    // Similar implementation...
  }
  
  // When the login button is clicked
  document.getElementById('login-btn').addEventListener('click', () => {
    const userId = document.getElementById('user-id').value;
    notificationClient.authenticate(userId);
  });
});
            

Designing the Notification UI

A good notification system needs an effective UI. Let's explore some design patterns for displaying notifications:

Common Notification UI Patterns

  • Toast Notifications: Small, non-intrusive messages that appear briefly and disappear automatically
  • Notification Center: A centralized location where users can view all their notifications
  • Badge Counters: Small numbered indicators showing unread notification count
  • Push Notifications: System-level notifications that appear even when the app isn't active

HTML/CSS for Toast Notifications


<!-- HTML structure for toast notifications -->
<div id="toast-container"></div>

<!-- CSS for notifications -->
<style>
  #toast-container {
    position: fixed;
    bottom: 20px;
    right: 20px;
    z-index: 1000;
  }
  
  .toast-notification {
    background-color: #fff;
    border-left: 4px solid #4caf50;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    border-radius: 4px;
    padding: 16px;
    margin-bottom: 10px;
    display: flex;
    align-items: center;
    min-width: 300px;
    max-width: 400px;
    animation: slideIn 0.3s, fadeIn 0.3s;
  }
  
  .toast-notification.hiding {
    animation: slideOut 0.3s, fadeOut 0.3s;
  }
  
  .toast-notification.error {
    border-left-color: #f44336;
  }
  
  .toast-notification.warning {
    border-left-color: #ff9800;
  }
  
  .toast-notification.info {
    border-left-color: #2196f3;
  }
  
  .notification-icon {
    margin-right: 12px;
    font-size: 20px;
    width: 24px;
    text-align: center;
  }
  
  .notification-content {
    flex-grow: 1;
  }
  
  .notification-content h4 {
    margin: 0 0 4px 0;
    font-size: 16px;
  }
  
  .notification-content p {
    margin: 0;
    color: #666;
    font-size: 14px;
  }
  
  .notification-close {
    color: #999;
    background: none;
    border: none;
    cursor: pointer;
    font-size: 16px;
  }
  
  @keyframes slideIn {
    from { transform: translateX(100%); }
    to { transform: translateX(0); }
  }
  
  @keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
  }
  
  @keyframes slideOut {
    from { transform: translateX(0); }
    to { transform: translateX(100%); }
  }
  
  @keyframes fadeOut {
    from { opacity: 1; }
    to { opacity: 0; }
  }
</style>
            

JavaScript for Toast Notifications


class ToastNotification {
  constructor(options = {}) {
    this.container = document.getElementById(options.containerId || 'toast-container');
    if (!this.container) {
      this.container = document.createElement('div');
      this.container.id = 'toast-container';
      document.body.appendChild(this.container);
    }
    
    this.defaultDuration = options.defaultDuration || 5000;
  }
  
  show(options) {
    const { type = 'info', title, message, icon, duration = this.defaultDuration } = options;
    
    // Create notification element
    const notification = document.createElement('div');
    notification.className = `toast-notification ${type}`;
    
    // Add content
    notification.innerHTML = `
      ${icon ? `<div class="notification-icon">${icon}</div>` : ''}
      <div class="notification-content">
        ${title ? `<h4>${title}</h4>` : ''}
        ${message ? `<p>${message}</p>` : ''}
      </div>
      <button class="notification-close" aria-label="Close">×</button>
    `;
    
    // Add to container
    this.container.appendChild(notification);
    
    // Add close button handler
    const closeButton = notification.querySelector('.notification-close');
    closeButton.addEventListener('click', () => this.close(notification));
    
    // Set auto-close timer
    if (duration) {
      setTimeout(() => this.close(notification), duration);
    }
    
    return notification;
  }
  
  close(notification) {
    notification.classList.add('hiding');
    setTimeout(() => {
      if (notification.parentNode) {
        notification.parentNode.removeChild(notification);
      }
    }, 300); // Match the animation duration
  }
  
  // Convenience methods for different notification types
  success(options) {
    return this.show({ 
      ...options, 
      type: 'success', 
      icon: '<i class="fas fa-check-circle"></i>' 
    });
  }
  
  error(options) {
    return this.show({ 
      ...options, 
      type: 'error', 
      icon: '<i class="fas fa-exclamation-circle"></i>' 
    });
  }
  
  warning(options) {
    return this.show({ 
      ...options, 
      type: 'warning', 
      icon: '<i class="fas fa-exclamation-triangle"></i>' 
    });
  }
  
  info(options) {
    return this.show({ 
      ...options, 
      type: 'info', 
      icon: '<i class="fas fa-info-circle"></i>' 
    });
  }
}

// Usage example
const toast = new ToastNotification();

// Connect to our notification client
document.addEventListener('notifications:new', (event) => {
  const notification = event.detail;
  
  switch (notification.type) {
    case 'message':
      toast.info({
        title: `New message from ${notification.data.sender}`,
        message: notification.data.preview
      });
      break;
    
    case 'friend-request':
      toast.success({
        title: 'Friend Request',
        message: `${notification.data.name} wants to connect`
      });
      break;
      
    case 'error':
      toast.error({
        title: 'Error',
        message: notification.data.message
      });
      break;
      
    default:
      toast.info({
        title: notification.type,
        message: JSON.stringify(notification.data)
      });
  }
});
            

Real-world Integration Examples

E-commerce Order Updates


// Server-side event handler for order status changes
function handleOrderStatusChange(order) {
  const { orderId, userId, status, estimatedDelivery } = order;
  
  // Send notification to the customer
  notificationService.sendToUser(userId, 'order-update', {
    orderId,
    status,
    estimatedDelivery,
    message: getOrderStatusMessage(status),
    timestamp: new Date()
  });
  
  // If it's a delivery status, also notify the delivery team
  if (status === 'out-for-delivery') {
    notificationService.sendToRoom('delivery-team', 'new-delivery', {
      orderId,
      customer: {
        id: userId,
        address: order.shippingAddress
      },
      estimatedDelivery
    });
  }
}

function getOrderStatusMessage(status) {
  switch (status) {
    case 'processing':
      return 'Your order is being processed.';
    case 'shipped':
      return 'Great news! Your order has been shipped.';
    case 'out-for-delivery':
      return 'Your order is out for delivery.';
    case 'delivered':
      return 'Your order has been delivered. Enjoy!';
    default:
      return `Your order status is: ${status}`;
  }
}

// Integrate with order processing system
orderProcessor.on('status-change', handleOrderStatusChange);
            

Chat Application


// Server-side message handler
io.on('connection', (socket) => {
  // ... other connection handling
  
  // Handle new messages
  socket.on('send-message', async (data) => {
    const { roomId, message, sender } = data;
    
    try {
      // Save message to database
      const savedMessage = await chatService.saveMessage({
        roomId,
        sender,
        content: message,
        timestamp: new Date()
      });
      
      // Broadcast to room
      socket.to(roomId).emit('notification', {
        type: 'new-message',
        data: {
          messageId: savedMessage.id,
          roomId,
          sender,
          preview: message.substring(0, 50) + (message.length > 50 ? '...' : ''),
          timestamp: savedMessage.timestamp
        }
      });
      
      // For users not in the room but part of the conversation, send a different notification
      const roomMembers = await chatService.getRoomMembers(roomId);
      for (const memberId of roomMembers) {
        if (memberId !== sender) {
          // Check if user is online but not in this room
          if (notificationService.isUserOnline(memberId) && 
              !notificationService.isUserInRoom(memberId, roomId)) {
            
            notificationService.sendToUser(memberId, 'unread-message', {
              messageId: savedMessage.id,
              roomId,
              sender,
              preview: message.substring(0, 50) + (message.length > 50 ? '...' : ''),
              timestamp: savedMessage.timestamp
            });
          }
        }
      }
      
      socket.emit('message-sent', { id: savedMessage.id, status: 'sent' });
    } catch (error) {
      console.error('Error sending message:', error);
      socket.emit('message-error', { error: 'Failed to send message' });
    }
  });
});
            

Collaborative Document Editing


// Server-side document edit handler
io.on('connection', (socket) => {
  // ... other connection handling
  
  // Handle document edits
  socket.on('document-change', (data) => {
    const { documentId, change, user } = data;
    
    // Save the change to the database
    documentService.saveChange(documentId, change, user)
      .then(() => {
        // Broadcast to everyone editing the document except sender
        socket.to(`document:${documentId}`).emit('notification', {
          type: 'document-change',
          data: {
            documentId,
            change,
            user,
            timestamp: new Date()
          }
        });
        
        // Notify document owner if they're not currently editing
        return documentService.getDocumentOwner(documentId);
      })
      .then(ownerId => {
        if (ownerId !== user.id && !notificationService.isUserInRoom(ownerId, `document:${documentId}`)) {
          notificationService.sendToUser(ownerId, 'document-edited', {
            documentId,
            editorName: user.name,
            documentName: data.documentName,
            timestamp: new Date()
          });
        }
      })
      .catch(error => {
        console.error('Error handling document change:', error);
        socket.emit('document-error', { error: 'Failed to save changes' });
      });
  });
  
  // Handle document comments
  socket.on('add-comment', (data) => {
    const { documentId, comment, user, mentionedUsers } = data;
    
    // Save comment to database
    documentService.saveComment(documentId, comment, user)
      .then(savedComment => {
        // Broadcast to document room
        socket.to(`document:${documentId}`).emit('notification', {
          type: 'new-comment',
          data: {
            documentId,
            comment: savedComment,
            user,
            timestamp: new Date()
          }
        });
        
        // Send specific notifications to mentioned users
        if (mentionedUsers && mentionedUsers.length > 0) {
          for (const mentionedUser of mentionedUsers) {
            notificationService.sendToUser(mentionedUser.id, 'user-mention', {
              documentId,
              commentId: savedComment.id,
              mentionedBy: user.name,
              documentName: data.documentName,
              timestamp: new Date()
            });
          }
        }
        
        return savedComment;
      })
      .then(savedComment => {
        socket.emit('comment-added', { id: savedComment.id, status: 'added' });
      })
      .catch(error => {
        console.error('Error adding comment:', error);
        socket.emit('comment-error', { error: 'Failed to add comment' });
      });
  });
});
            

Best Practices for Real-time Notifications

Performance Considerations

Design Considerations

Security Considerations

Socket.io with Redis Adapter for Scaling


// server.js
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

// Set up Redis adapter for horizontal scaling
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
  io.adapter(createAdapter(pubClient, subClient));
  console.log('Socket.io connected to Redis adapter');
});

// ... rest of your Socket.io setup
                

Notification Preference Management


// User preference schema
const notificationPreferenceSchema = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  preferences: {
    email: {
      type: Boolean,
      default: true
    },
    push: {
      type: Boolean,
      default: true
    },
    inApp: {
      type: Boolean,
      default: true
    }
  },
  channels: {
    messages: {
      type: Boolean,
      default: true
    },
    friendRequests: {
      type: Boolean,
      default: true
    },
    comments: {
      type: Boolean,
      default: true
    },
    // Add more notification channels as needed
  },
  doNotDisturbMode: {
    enabled: {
      type: Boolean,
      default: false
    },
    startTime: {
      type: String,
      default: "22:00" // 10 PM
    },
    endTime: {
      type: String,
      default: "08:00" // 8 AM
    },
    timezone: {
      type: String,
      default: "UTC"
    }
  }
});

// Enhanced notification service that respects user preferences
function shouldSendNotification(userId, notificationType) {
  return UserPreference.findOne({ userId })
    .then(preferences => {
      if (!preferences) {
        return true; // Default to sending if no preferences set
      }
      
      // Check if user is in do-not-disturb mode
      if (preferences.doNotDisturbMode.enabled) {
        const now = new Date();
        const userTz = preferences.doNotDisturbMode.timezone;
        const userTime = new Date(now.toLocaleString('en-US', { timeZone: userTz }));
        const userHour = userTime.getHours();
        const userMinute = userTime.getMinutes();
        
        const startParts = preferences.doNotDisturbMode.startTime.split(':');
        const endParts = preferences.doNotDisturbMode.endTime.split(':');
        
        const startHour = parseInt(startParts[0], 10);
        const startMinute = parseInt(startParts[1], 10);
        const endHour = parseInt(endParts[0], 10);
        const endMinute = parseInt(endParts[1], 10);
        
        const currentTime = userHour * 60 + userMinute;
        const startTime = startHour * 60 + startMinute;
        const endTime = endHour * 60 + endMinute;
        
        if (startTime < endTime) {
          // Normal time range (e.g., 22:00 to 08:00)
          if (currentTime >= startTime && currentTime <= endTime) {
            return false; // In do-not-disturb time range
          }
        } else {
          // Overnight time range (e.g., 22:00 to 08:00)
          if (currentTime >= startTime || currentTime <= endTime) {
            return false; // In do-not-disturb time range
          }
        }
      }
      
      // Check if user has enabled in-app notifications
      if (!preferences.preferences.inApp) {
        return false;
      }
      
      // Map notification type to preference channel
      const channelMap = {
        'message': 'messages',
        'friend-request': 'friendRequests',
        'comment': 'comments',
        // Map other notification types to preference channels
      };
      
      const preferenceChannel = channelMap[notificationType] || notificationType;
      
      // Check if this notification channel is enabled
      return preferences.channels[preferenceChannel] !== false;
    })
    .catch(error => {
      console.error('Error checking notification preferences:', error);
      return true; // Default to sending if there's an error
    });
}

// Enhance the notification service
class EnhancedNotificationService extends NotificationService {
  async sendToUser(userId, notificationType, payload) {
    const shouldSend = await shouldSendNotification(userId, notificationType);
    
    if (shouldSend) {
      return super.sendToUser(userId, notificationType, payload);
    }
    
    return 0; // Notification suppressed due to user preferences
  }
}
                

Advanced Topics in Real-time Notifications

Push Notifications

Extend your real-time notification system to include push notifications using Web Push API or services like Firebase Cloud Messaging (FCM).


// Using web-push library for Web Push API
const webpush = require('web-push');

// VAPID keys should be generated only once
const vapidKeys = webpush.generateVAPIDKeys();

webpush.setVapidDetails(
  'mailto:example@yourdomain.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);

// Store user's subscription object
app.post('/api/push-subscribe', async (req, res) => {
  const { subscription, userId } = req.body;
  
  try {
    await saveSubscription(userId, subscription);
    res.status(201).json({ success: true });
  } catch (error) {
    console.error('Error saving push subscription:', error);
    res.status(500).json({ error: 'Failed to save subscription' });
  }
});

// Send push notification
async function sendPushNotification(userId, title, body, data = {}) {
  try {
    const subscriptions = await getSubscriptionsForUser(userId);
    
    if (!subscriptions || subscriptions.length === 0) {
      return { success: false, reason: 'No subscriptions found' };
    }
    
    const payload = JSON.stringify({
      title,
      body,
      data
    });
    
    const results = await Promise.all(
      subscriptions.map(async (subscription) => {
        try {
          await webpush.sendNotification(subscription, payload);
          return { success: true, subscription };
        } catch (error) {
          if (error.statusCode === 410) {
            // Subscription has expired or is no longer valid
            await removeSubscription(subscription);
          }
          return { success: false, subscription, error };
        }
      })
    );
    
    return {
      success: true,
      results
    };
  } catch (error) {
    console.error('Error sending push notification:', error);
    return { success: false, error };
  }
}

// Enhance notification service to also send push notification
class PushNotificationService extends EnhancedNotificationService {
  async sendToUser(userId, notificationType, payload) {
    // Send socket.io notification
    const socketResult = await super.sendToUser(userId, notificationType, payload);
    
    // If no active connections, try to send push notification
    if (socketResult === 0) {
      // Check user preferences for push
      const shouldPush = await shouldSendPushNotification(userId, notificationType);
      
      if (shouldPush) {
        // Format payload for push notification
        const pushPayload = formatPushPayload(notificationType, payload);
        
        // Send push notification
        await sendPushNotification(
          userId,
          pushPayload.title,
          pushPayload.body,
          {
            type: notificationType,
            url: pushPayload.url,
            ...pushPayload.data
          }
        );
      }
    }
    
    return socketResult;
  }
}

// Helper to format push notification content
function formatPushPayload(type, payload) {
  switch (type) {
    case 'message':
      return {
        title: `New message from ${payload.sender}`,
        body: payload.preview,
        url: `/messages/${payload.conversationId}`,
        data: { messageId: payload.messageId }
      };
    
    case 'friend-request':
      return {
        title: 'New Friend Request',
        body: `${payload.name} wants to connect with you`,
        url: '/friends/requests',
        data: { requestId: payload.requestId }
      };
    
    // Add more notification types as needed
    
    default:
      return {
        title: 'New Notification',
        body: 'You have a new notification',
        url: '/notifications',
        data: {}
      };
  }
}
            

Offline Support and Syncing

Handle cases where users are offline and need to receive missed notifications when they reconnect.


// Enhanced notification service with offline support
class OfflineAwareNotificationService extends PushNotificationService {
  constructor(io, db) {
    super(io);
    this.db = db; // Database connection for storing notifications
  }
  
  async sendToUser(userId, notificationType, payload) {
    // Always store notification in database
    await this.storeNotification(userId, notificationType, payload);
    
    // Attempt to deliver via socket.io and push
    return super.sendToUser(userId, notificationType, payload);
  }
  
  async storeNotification(userId, type, payload) {
    try {
      await this.db.collection('notifications').insertOne({
        userId,
        type,
        payload,
        read: false,
        delivered: false,
        createdAt: new Date()
      });
    } catch (error) {
      console.error('Error storing notification:', error);
    }
  }
  
  async markAsDelivered(notificationIds) {
    try {
      await this.db.collection('notifications').updateMany(
        { _id: { $in: notificationIds } },
        { $set: { delivered: true, deliveredAt: new Date() } }
      );
    } catch (error) {
      console.error('Error marking notifications as delivered:', error);
    }
  }
  
  async markAsRead(notificationIds) {
    try {
      await this.db.collection('notifications').updateMany(
        { _id: { $in: notificationIds } },
        { $set: { read: true, readAt: new Date() } }
      );
    } catch (error) {
      console.error('Error marking notifications as read:', error);
    }
  }
  
  async getUndeliveredNotifications(userId) {
    try {
      return await this.db.collection('notifications')
        .find({ userId, delivered: false })
        .sort({ createdAt: 1 })
        .toArray();
    } catch (error) {
      console.error('Error getting undelivered notifications:', error);
      return [];
    }
  }
}

// Socket.io handling for reconnection and notification syncing
io.on('connection', (socket) => {
  // ... other connection handling
  
  // Handle authentication and user registration
  socket.on('auth', async (userData) => {
    const userId = userData.userId;
    
    // Register this socket with the user ID
    notificationService.registerUser(userId, socket.id);
    
    // Fetch undelivered notifications
    const undeliveredNotifications = await notificationService.getUndeliveredNotifications(userId);
    
    if (undeliveredNotifications.length > 0) {
      // Send undelivered notifications to the client
      socket.emit('notification-sync', undeliveredNotifications);
      
      // Mark notifications as delivered
      await notificationService.markAsDelivered(
        undeliveredNotifications.map(notification => notification._id)
      );
    }
    
    // Acknowledge successful authentication
    socket.emit('auth_success', { userId });
  });
  
  // Handle notification read status updates
  socket.on('mark-notifications-read', async (data) => {
    const { notificationIds } = data;
    
    await notificationService.markAsRead(notificationIds);
    socket.emit('notifications-marked-read', { success: true, count: notificationIds.length });
  });
});
            

Analytics and Insights

Track notification engagement to improve your notification strategy.


// Add analytics capabilities to notification service
class AnalyticsEnabledNotificationService extends OfflineAwareNotificationService {
  constructor(io, db, analyticsService) {
    super(io, db);
    this.analyticsService = analyticsService;
  }
  
  async sendToUser(userId, notificationType, payload) {
    // Track notification send event
    this.analyticsService.trackEvent('notification_sent', {
      userId,
      notificationType,
      timestamp: new Date()
    });
    
    return super.sendToUser(userId, notificationType, payload);
  }
  
  async markAsDelivered(notificationIds) {
    // Get notifications before marking them as delivered
    const notifications = await this.db.collection('notifications')
      .find({ _id: { $in: notificationIds } })
      .toArray();
    
    // Track delivery events
    for (const notification of notifications) {
      this.analyticsService.trackEvent('notification_delivered', {
        userId: notification.userId,
        notificationId: notification._id.toString(),
        notificationType: notification.type,
        deliveryLatency: new Date() - notification.createdAt,
        timestamp: new Date()
      });
    }
    
    return super.markAsDelivered(notificationIds);
  }
  
  async markAsRead(notificationIds) {
    // Get notifications before marking them as read
    const notifications = await this.db.collection('notifications')
      .find({ _id: { $in: notificationIds } })
      .toArray();
    
    // Track read events
    for (const notification of notifications) {
      const deliveredAt = notification.deliveredAt || notification.createdAt;
      
      this.analyticsService.trackEvent('notification_read', {
        userId: notification.userId,
        notificationId: notification._id.toString(),
        notificationType: notification.type,
        readLatency: new Date() - deliveredAt,
        totalLatency: new Date() - notification.createdAt,
        timestamp: new Date()
      });
    }
    
    return super.markAsRead(notificationIds);
  }
  
  // Get engagement metrics for notifications
  async getEngagementMetrics(options = {}) {
    const { startDate, endDate, notificationType } = options;
    
    const query = {};
    
    if (startDate || endDate) {
      query.createdAt = {};
      if (startDate) query.createdAt.$gte = new Date(startDate);
      if (endDate) query.createdAt.$lte = new Date(endDate);
    }
    
    if (notificationType) {
      query.type = notificationType;
    }
    
    try {
      const totalCount = await this.db.collection('notifications')
        .countDocuments(query);
        
      const deliveredCount = await this.db.collection('notifications')
        .countDocuments({ ...query, delivered: true });
        
      const readCount = await this.db.collection('notifications')
        .countDocuments({ ...query, read: true });
        
      // Calculate average delivery and read times
      const deliveryTimes = await this.db.collection('notifications')
        .find({ ...query, delivered: true, deliveredAt: { $exists: true } })
        .project({ deliveryTime: { $subtract: ['$deliveredAt', '$createdAt'] } })
        .toArray();
        
      const readTimes = await this.db.collection('notifications')
        .find({ ...query, read: true, readAt: { $exists: true } })
        .project({ readTime: { $subtract: ['$readAt', '$deliveredAt'] } })
        .toArray();
        
      const avgDeliveryTime = deliveryTimes.length > 0 
        ? deliveryTimes.reduce((sum, item) => sum + item.deliveryTime, 0) / deliveryTimes.length 
        : null;
        
      const avgReadTime = readTimes.length > 0 
        ? readTimes.reduce((sum, item) => sum + item.readTime, 0) / readTimes.length 
        : null;
      
      return {
        totalCount,
        deliveredCount,
        readCount,
        deliveryRate: totalCount > 0 ? deliveredCount / totalCount : 0,
        readRate: deliveredCount > 0 ? readCount / deliveredCount : 0,
        overallReadRate: totalCount > 0 ? readCount / totalCount : 0,
        avgDeliveryTime,
        avgReadTime
      };
    } catch (error) {
      console.error('Error calculating engagement metrics:', error);
      return null;
    }
  }
}
            

Practice Activities

Activity 1: Setting Up Basic Socket.io Notifications

Create a simple notification system using Socket.io that sends real-time alerts when events occur.

  1. Set up a basic Express server with Socket.io
  2. Create a simple front-end with a notification area
  3. Implement an API endpoint that triggers notifications
  4. Display toast notifications when events are received

Activity 2: Building a Notification Center

Expand your notification system to include a notification center that stores and displays a history of notifications.

  1. Create a database schema for storing notifications
  2. Implement APIs for fetching, marking as read, and deleting notifications
  3. Build a UI for the notification center with unread indicators
  4. Add filtering and sorting options for notifications

Activity 3: Implementing Notification Preferences

Add user preferences to your notification system to allow users to control what notifications they receive.

  1. Design a schema for notification preferences
  2. Create a UI for users to manage their preferences
  3. Modify your notification service to respect user preferences
  4. Implement a "Do Not Disturb" mode for notifications

Further Reading and Resources