Front-end Engineering Lab

Live Notifications

Build real-time notification systems with push, badges, and permission handling

Real-time notifications keep users engaged. This guide covers in-app, push, and browser notifications.

đŸŽ¯ Types of Notifications

In-App: Toast, banner, badge (user is in app)
Push: Browser notification (user may be away)
Email/SMS: Fallback (user is offline)

🔔 Pattern 1: In-App Notifications

Real-time notifications within the app.

interface Notification {
  id: string;
  type: 'info' | 'success' | 'warning' | 'error';
  title: string;
  message: string;
  timestamp: Date;
  read: boolean;
  actionUrl?: string;
  icon?: string;
}

class NotificationManager {
  private ws: WebSocket;
  private notifications: Notification[] = [];
  private listeners = new Set<(notifications: Notification[]) => void>();
  
  constructor(ws: WebSocket) {
    this.ws = ws;
    this.setupWebSocket();
    this.loadFromStorage();
  }
  
  private setupWebSocket() {
    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      
      if (message.type === 'notification') {
        this.addNotification(message.notification);
      }
    };
  }
  
  private addNotification(notification: Notification) {
    this.notifications.unshift(notification);
    
    // Keep max 100 notifications
    if (this.notifications.length > 100) {
      this.notifications = this.notifications.slice(0, 100);
    }
    
    this.saveToStorage();
    this.notifyListeners();
    
    // Show toast
    this.showToast(notification);
  }
  
  private showToast(notification: Notification) {
    // Use your preferred toast library
    toast(notification.message, {
      icon: notification.type === 'success' ? '✅' : 
            notification.type === 'error' ? '❌' :
            notification.type === 'warning' ? 'âš ī¸' : 'â„šī¸',
      duration: 5000,
      onClick: notification.actionUrl ? 
        () => window.location.href = notification.actionUrl! :
        undefined
    });
  }
  
  markAsRead(id: string) {
    const notification = this.notifications.find(n => n.id === id);
    
    if (notification && !notification.read) {
      notification.read = true;
      this.saveToStorage();
      this.notifyListeners();
      
      // Notify server
      this.ws.send(JSON.stringify({
        type: 'notification_read',
        notificationId: id
      }));
    }
  }
  
  markAllAsRead() {
    this.notifications.forEach(n => n.read = true);
    this.saveToStorage();
    this.notifyListeners();
    
    this.ws.send(JSON.stringify({
      type: 'notifications_read_all'
    }));
  }
  
  getUnreadCount(): number {
    return this.notifications.filter(n => !n.read).length;
  }
  
  getNotifications(): Notification[] {
    return this.notifications;
  }
  
  private notifyListeners() {
    this.listeners.forEach(listener => listener(this.notifications));
  }
  
  subscribe(listener: (notifications: Notification[]) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }
  
  private saveToStorage() {
    localStorage.setItem('notifications', JSON.stringify(this.notifications));
  }
  
  private loadFromStorage() {
    const stored = localStorage.getItem('notifications');
    if (stored) {
      this.notifications = JSON.parse(stored);
    }
  }
}

React Components

// Notification Bell
function NotificationBell() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const manager = useRef<NotificationManager>();
  
  useEffect(() => {
    manager.current = new NotificationManager(ws);
    
    const unsubscribe = manager.current.subscribe(setNotifications);
    
    return unsubscribe;
  }, []);
  
  const unreadCount = notifications.filter(n => !n.read).length;
  
  return (
    <div className="notification-bell">
      <button onClick={() => setIsOpen(!isOpen)}>
        🔔
        {unreadCount > 0 && (
          <span className="badge">{unreadCount}</span>
        )}
      </button>
      
      {isOpen && (
        <NotificationDropdown
          notifications={notifications}
          onMarkAsRead={(id) => manager.current?.markAsRead(id)}
          onMarkAllAsRead={() => manager.current?.markAllAsRead()}
        />
      )}
    </div>
  );
}

// Notification Dropdown
function NotificationDropdown({
  notifications,
  onMarkAsRead,
  onMarkAllAsRead
}: {
  notifications: Notification[];
  onMarkAsRead: (id: string) => void;
  onMarkAllAsRead: () => void;
}) {
  return (
    <div className="notification-dropdown">
      <div className="header">
        <h3>Notifications</h3>
        {notifications.some(n => !n.read) && (
          <button onClick={onMarkAllAsRead}>Mark all as read</button>
        )}
      </div>
      
      <div className="list">
        {notifications.length === 0 ? (
          <div className="empty">No notifications</div>
        ) : (
          notifications.map(notification => (
            <NotificationItem
              key={notification.id}
              notification={notification}
              onMarkAsRead={onMarkAsRead}
            />
          ))
        )}
      </div>
    </div>
  );
}

// Notification Item
function NotificationItem({
  notification,
  onMarkAsRead
}: {
  notification: Notification;
  onMarkAsRead: (id: string) => void;
}) {
  return (
    <div
      className={`notification-item ${notification.read ? 'read' : 'unread'}`}
      onClick={() => {
        onMarkAsRead(notification.id);
        if (notification.actionUrl) {
          window.location.href = notification.actionUrl;
        }
      }}
    >
      {notification.icon && <span className="icon">{notification.icon}</span>}
      
      <div className="content">
        <h4>{notification.title}</h4>
        <p>{notification.message}</p>
        <span className="time">{formatTimeAgo(notification.timestamp)}</span>
      </div>
      
      {!notification.read && <span className="unread-dot" />}
    </div>
  );
}

📨 Pattern 2: Push Notifications (Browser)

Send notifications even when app is closed.

class PushNotificationManager {
  async requestPermission(): Promise<NotificationPermission> {
    if (!('Notification' in window)) {
      console.error('This browser does not support notifications');
      return 'denied';
    }
    
    const permission = await Notification.requestPermission();
    console.log('Notification permission:', permission);
    
    if (permission === 'granted') {
      await this.subscribeToPush();
    }
    
    return permission;
  }
  
  async subscribeToPush() {
    if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
      console.error('Push notifications not supported');
      return;
    }
    
    try {
      const registration = await navigator.serviceWorker.ready;
      
      const subscription = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: this.urlBase64ToUint8Array(
          process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
        )
      });
      
      // Send subscription to server
      await fetch('/api/push/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(subscription)
      });
      
      console.log('Push subscription successful');
    } catch (error) {
      console.error('Failed to subscribe to push:', error);
    }
  }
  
  async unsubscribe() {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.getSubscription();
    
    if (subscription) {
      await subscription.unsubscribe();
      
      // Notify server
      await fetch('/api/push/unsubscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ endpoint: subscription.endpoint })
      });
    }
  }
  
  async showNotification(title: string, options: NotificationOptions) {
    if (Notification.permission !== 'granted') {
      console.warn('Notification permission not granted');
      return;
    }
    
    const registration = await navigator.serviceWorker.ready;
    
    await registration.showNotification(title, {
      ...options,
      badge: '/badge.png',
      icon: '/icon.png',
      vibrate: [200, 100, 200],
      tag: options.tag || 'default',
      renotify: true
    });
  }
  
  private urlBase64ToUint8Array(base64String: string): Uint8Array {
    const padding = '='.repeat((4 - base64String.length % 4) % 4);
    const base64 = (base64String + padding)
      .replace(/-/g, '+')
      .replace(/_/g, '/');
    
    const rawData = window.atob(base64);
    const outputArray = new Uint8Array(rawData.length);
    
    for (let i = 0; i < rawData.length; ++i) {
      outputArray[i] = rawData.charCodeAt(i);
    }
    
    return outputArray;
  }
}

// React Component
function PushNotificationSetup() {
  const [permission, setPermission] = useState<NotificationPermission>('default');
  const manager = useRef(new PushNotificationManager());
  
  useEffect(() => {
    if ('Notification' in window) {
      setPermission(Notification.permission);
    }
  }, []);
  
  const handleEnable = async () => {
    const result = await manager.current.requestPermission();
    setPermission(result);
  };
  
  if (permission === 'granted') {
    return <div>✅ Push notifications enabled</div>;
  }
  
  if (permission === 'denied') {
    return (
      <div>
        ❌ Push notifications blocked. Please enable in browser settings.
      </div>
    );
  }
  
  return (
    <button onClick={handleEnable}>
      Enable Push Notifications
    </button>
  );
}

Service Worker (public/sw.js)

self.addEventListener('push', (event) => {
  const data = event.data.json();
  
  const options = {
    body: data.message,
    icon: data.icon || '/icon.png',
    badge: '/badge.png',
    data: {
      url: data.url
    },
    actions: data.actions || []
  };
  
  event.waitUntil(
    self.registration.showNotification(data.title, options)
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  
  if (event.notification.data?.url) {
    event.waitUntil(
      clients.openWindow(event.notification.data.url)
    );
  }
});

đŸ”ĸ Pattern 3: Badge Count

Show unread count on icon/favicon.

class BadgeManager {
  private count = 0;
  
  setCount(count: number) {
    this.count = count;
    this.updateFavicon();
    this.updateTitle();
    
    // Update app badge (PWA)
    if ('setAppBadge' in navigator) {
      if (count > 0) {
        (navigator as any).setAppBadge(count);
      } else {
        (navigator as any).clearAppBadge();
      }
    }
  }
  
  private updateFavicon() {
    const canvas = document.createElement('canvas');
    canvas.width = 32;
    canvas.height = 32;
    
    const ctx = canvas.getContext('2d');
    if (!ctx) return;
    
    // Draw base favicon
    const favicon = new Image();
    favicon.src = '/favicon.ico';
    
    favicon.onload = () => {
      ctx.drawImage(favicon, 0, 0, 32, 32);
      
      if (this.count > 0) {
        // Draw badge
        ctx.fillStyle = '#ff0000';
        ctx.beginPath();
        ctx.arc(24, 8, 8, 0, 2 * Math.PI);
        ctx.fill();
        
        // Draw count
        ctx.fillStyle = '#ffffff';
        ctx.font = 'bold 12px Arial';
        ctx.textAlign = 'center';
        ctx.textBaseline = 'middle';
        ctx.fillText(
          this.count > 9 ? '9+' : this.count.toString(),
          24,
          8
        );
      }
      
      // Update favicon
      const link = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
      if (link) {
        link.href = canvas.toDataURL();
      }
    };
  }
  
  private updateTitle() {
    const baseTitle = 'My App';
    
    if (this.count > 0) {
      document.title = `(${this.count}) ${baseTitle}`;
    } else {
      document.title = baseTitle;
    }
  }
}

// React Hook
function useBadgeCount(notifications: Notification[]) {
  const badgeManager = useRef(new BadgeManager());
  
  useEffect(() => {
    const unreadCount = notifications.filter(n => !n.read).length;
    badgeManager.current.setCount(unreadCount);
  }, [notifications]);
}

đŸŽ¯ Pattern 4: Notification Preferences

Let users control notifications.

interface NotificationPreferences {
  enabled: boolean;
  types: {
    comments: boolean;
    likes: boolean;
    mentions: boolean;
    messages: boolean;
  };
  quietHours: {
    enabled: boolean;
    start: string; // "22:00"
    end: string;   // "08:00"
  };
  push: boolean;
  email: boolean;
}

function NotificationSettings() {
  const [prefs, setPrefs] = useState<NotificationPreferences>({
    enabled: true,
    types: {
      comments: true,
      likes: true,
      mentions: true,
      messages: true
    },
    quietHours: {
      enabled: false,
      start: '22:00',
      end: '08:00'
    },
    push: false,
    email: true
  });
  
  const updatePreferences = async (newPrefs: NotificationPreferences) => {
    setPrefs(newPrefs);
    
    await fetch('/api/settings/notifications', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(newPrefs)
    });
  };
  
  return (
    <div className="notification-settings">
      <h2>Notification Settings</h2>
      
      <div>
        <label>
          <input
            type="checkbox"
            checked={prefs.enabled}
            onChange={(e) => updatePreferences({
              ...prefs,
              enabled: e.target.checked
            })}
          />
          Enable notifications
        </label>
      </div>
      
      <h3>Notification Types</h3>
      {Object.entries(prefs.types).map(([type, enabled]) => (
        <label key={type}>
          <input
            type="checkbox"
            checked={enabled}
            onChange={(e) => updatePreferences({
              ...prefs,
              types: { ...prefs.types, [type]: e.target.checked }
            })}
          />
          {type.charAt(0).toUpperCase() + type.slice(1)}
        </label>
      ))}
      
      <h3>Quiet Hours</h3>
      <label>
        <input
          type="checkbox"
          checked={prefs.quietHours.enabled}
          onChange={(e) => updatePreferences({
            ...prefs,
            quietHours: { ...prefs.quietHours, enabled: e.target.checked }
          })}
        />
        Enable quiet hours
      </label>
      
      {prefs.quietHours.enabled && (
        <div>
          <input
            type="time"
            value={prefs.quietHours.start}
            onChange={(e) => updatePreferences({
              ...prefs,
              quietHours: { ...prefs.quietHours, start: e.target.value }
            })}
          />
          <span>to</span>
          <input
            type="time"
            value={prefs.quietHours.end}
            onChange={(e) => updatePreferences({
              ...prefs,
              quietHours: { ...prefs.quietHours, end: e.target.value }
            })}
          />
        </div>
      )}
    </div>
  );
}

📚 Key Takeaways

  1. In-app first - Always show in-app notifications
  2. Request permission wisely - Explain why before asking
  3. Respect preferences - Let users control everything
  4. Badge count - Show unread on icon
  5. Quiet hours - Don't disturb at night
  6. Group notifications - "3 new messages" not 3 separate
  7. Test on mobile - Push works differently

Notifications are powerful but easily annoying. Use them wisely!

On this page