JavaScript APIs

JavaScript Notification API: Desktop Push Notifications

Master the Notification API to display native desktop notifications. Learn permission handling, notification options, actions, and best practices for user engagement.

By JavaScript Document Team
notification-apiweb-apispushpermissionsuser-experience

The Notification API allows web applications to display system notifications to users outside the browser window. These notifications can appear even when the browser is minimized or in the background, making them perfect for real-time updates and user engagement.

Understanding the Notification API

The Notification API provides a way to display native desktop notifications that integrate with the user's operating system notification center.

Basic Notification Setup

// Check if notifications are supported
if ('Notification' in window) {
  console.log('Notifications are supported');
} else {
  console.log('Notifications are not supported');
}

// Request permission
async function requestNotificationPermission() {
  if (Notification.permission === 'default') {
    const permission = await Notification.requestPermission();
    return permission;
  }
  return Notification.permission;
}

// Create a simple notification
function showNotification() {
  if (Notification.permission === 'granted') {
    const notification = new Notification('Hello!', {
      body: 'This is a notification from your web app',
      icon: '/icon-192x192.png',
    });

    // Handle notification click
    notification.onclick = () => {
      console.log('Notification clicked');
      window.focus();
      notification.close();
    };
  }
}

// Permission states
function checkPermissionState() {
  switch (Notification.permission) {
    case 'granted':
      console.log('Notifications are allowed');
      break;
    case 'denied':
      console.log('Notifications are blocked');
      break;
    case 'default':
      console.log('Permission not yet requested');
      break;
  }
}

Notification Options

// Full notification options
function createRichNotification() {
  const options = {
    // Visual options
    body: 'This is the notification body text',
    icon: '/images/icon-192x192.png',
    image: '/images/notification-image.jpg',
    badge: '/images/badge-72x72.png',

    // Behavior options
    tag: 'message-group-1', // Replaces notifications with same tag
    renotify: true, // Vibrate/sound even for replacement
    requireInteraction: false, // Don't auto-dismiss
    silent: false, // Play sound

    // Data
    data: {
      messageId: '123',
      timestamp: Date.now(),
    },

    // Actions (for persistent notifications via service worker)
    actions: [
      {
        action: 'reply',
        title: 'Reply',
        icon: '/images/reply-icon.png',
      },
      {
        action: 'archive',
        title: 'Archive',
        icon: '/images/archive-icon.png',
      },
    ],

    // Timestamp
    timestamp: Date.now(),

    // Direction
    dir: 'auto', // 'ltr', 'rtl', or 'auto'

    // Language
    lang: 'en-US',

    // Vibration pattern (mobile)
    vibrate: [200, 100, 200],
  };

  const notification = new Notification('New Message', options);

  // Event handlers
  notification.onshow = () => console.log('Notification shown');
  notification.onclick = () => console.log('Notification clicked');
  notification.onclose = () => console.log('Notification closed');
  notification.onerror = (e) => console.error('Notification error:', e);

  return notification;
}

// Notification with custom data
function notificationWithData(title, body, customData) {
  const notification = new Notification(title, {
    body,
    data: customData,
  });

  notification.onclick = function () {
    console.log('Notification data:', this.data);
    // Use the custom data
    if (this.data.url) {
      window.open(this.data.url);
    }
    this.close();
  };
}

// Tagged notifications (replacing previous)
function showTaggedNotification(message, tag) {
  new Notification('Chat Message', {
    body: message,
    tag: tag, // Same tag replaces previous notification
    renotify: true, // Alert user about replacement
  });
}

Practical Applications

Notification Manager

class NotificationManager {
  constructor() {
    this.permission = Notification.permission;
    this.notifications = new Map();
    this.defaultOptions = {
      icon: '/images/app-icon.png',
      badge: '/images/badge.png',
      silent: false,
    };
  }

  async init() {
    if (this.permission === 'default') {
      this.permission = await Notification.requestPermission();
    }

    // Monitor permission changes
    if ('permissions' in navigator) {
      const permissionStatus = await navigator.permissions.query({
        name: 'notifications',
      });

      permissionStatus.onchange = () => {
        this.permission = Notification.permission;
        this.onPermissionChange(this.permission);
      };
    }

    return this.permission;
  }

  show(title, options = {}) {
    if (this.permission !== 'granted') {
      console.warn('Notification permission not granted');
      return null;
    }

    const mergedOptions = { ...this.defaultOptions, ...options };
    const notification = new Notification(title, mergedOptions);

    // Store notification
    const id = Date.now().toString();
    this.notifications.set(id, notification);

    // Auto-remove after close
    notification.onclose = () => {
      this.notifications.delete(id);
    };

    return notification;
  }

  showWithActions(title, body, actions) {
    // Actions require service worker
    if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
      navigator.serviceWorker.ready.then((registration) => {
        registration.showNotification(title, {
          body,
          actions,
          ...this.defaultOptions,
        });
      });
    } else {
      // Fallback to regular notification
      this.show(title, { body });
    }
  }

  closeAll() {
    this.notifications.forEach((notification) => {
      notification.close();
    });
    this.notifications.clear();
  }

  closeByTag(tag) {
    this.notifications.forEach((notification, id) => {
      if (notification.tag === tag) {
        notification.close();
        this.notifications.delete(id);
      }
    });
  }

  onPermissionChange(permission) {
    console.log('Notification permission changed to:', permission);
  }
}

// Message notification system
class MessageNotifications {
  constructor() {
    this.manager = new NotificationManager();
    this.conversations = new Map();
  }

  async init() {
    await this.manager.init();
  }

  showMessage(conversationId, sender, message, avatarUrl) {
    const notification = this.manager.show(`${sender}`, {
      body: message,
      icon: avatarUrl,
      tag: `conversation-${conversationId}`,
      renotify: true,
      data: {
        conversationId,
        sender,
        timestamp: Date.now(),
      },
    });

    if (notification) {
      notification.onclick = () => {
        this.openConversation(conversationId);
        notification.close();
      };

      // Track conversation notifications
      if (!this.conversations.has(conversationId)) {
        this.conversations.set(conversationId, []);
      }
      this.conversations.get(conversationId).push(notification);
    }
  }

  showMessageGroup(conversationId, sender, messages) {
    const count = messages.length;
    const body = count === 1 ? messages[0] : `${count} new messages`;

    this.manager.show(`${sender}`, {
      body,
      tag: `conversation-${conversationId}`,
      badge: '/images/message-badge.png',
      data: { conversationId, messages },
    });
  }

  openConversation(conversationId) {
    // Navigate to conversation
    window.location.href = `/messages/${conversationId}`;

    // Clear notifications for this conversation
    if (this.conversations.has(conversationId)) {
      this.conversations.get(conversationId).forEach((n) => n.close());
      this.conversations.delete(conversationId);
    }
  }

  clearConversationNotifications(conversationId) {
    this.manager.closeByTag(`conversation-${conversationId}`);
  }
}

Task Reminder System

class TaskReminders {
  constructor() {
    this.notificationManager = new NotificationManager();
    this.reminders = new Map();
  }

  async scheduleReminder(task, reminderTime) {
    const now = Date.now();
    const delay = reminderTime - now;

    if (delay <= 0) {
      // Show immediately if time has passed
      this.showReminder(task);
      return;
    }

    // Schedule for later
    const timerId = setTimeout(() => {
      this.showReminder(task);
      this.reminders.delete(task.id);
    }, delay);

    this.reminders.set(task.id, {
      timerId,
      task,
      scheduledFor: reminderTime,
    });

    return task.id;
  }

  showReminder(task) {
    const notification = this.notificationManager.show('Task Reminder', {
      body: task.title,
      icon: this.getTaskIcon(task.priority),
      tag: `task-${task.id}`,
      requireInteraction: task.priority === 'high',
      actions: [
        { action: 'complete', title: 'Mark Complete' },
        { action: 'snooze', title: 'Snooze 10 min' },
      ],
      data: { taskId: task.id },
    });

    if (notification) {
      notification.onclick = () => {
        this.openTask(task.id);
        notification.close();
      };
    }
  }

  getTaskIcon(priority) {
    const icons = {
      high: '/images/priority-high.png',
      medium: '/images/priority-medium.png',
      low: '/images/priority-low.png',
    };
    return icons[priority] || icons.medium;
  }

  snoozeReminder(taskId, minutes = 10) {
    const reminder = this.reminders.get(taskId);
    if (reminder) {
      clearTimeout(reminder.timerId);
      const newTime = Date.now() + minutes * 60 * 1000;
      this.scheduleReminder(reminder.task, newTime);
    }
  }

  cancelReminder(taskId) {
    const reminder = this.reminders.get(taskId);
    if (reminder) {
      clearTimeout(reminder.timerId);
      this.reminders.delete(taskId);
    }
  }

  openTask(taskId) {
    window.location.href = `/tasks/${taskId}`;
  }
}

Live Update Notifications

// Real-time notification system
class LiveNotifications {
  constructor(websocketUrl) {
    this.wsUrl = websocketUrl;
    this.notificationManager = new NotificationManager();
    this.reconnectDelay = 1000;
    this.maxReconnectDelay = 30000;
  }

  async connect() {
    await this.notificationManager.init();

    if (this.notificationManager.permission !== 'granted') {
      console.warn('Notifications not permitted');
      return;
    }

    this.setupWebSocket();
  }

  setupWebSocket() {
    this.ws = new WebSocket(this.wsUrl);

    this.ws.onopen = () => {
      console.log('WebSocket connected');
      this.reconnectDelay = 1000;
    };

    this.ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        this.handleNotification(data);
      } catch (err) {
        console.error('Failed to parse notification:', err);
      }
    };

    this.ws.onclose = () => {
      console.log('WebSocket disconnected');
      this.scheduleReconnect();
    };

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

  handleNotification(data) {
    switch (data.type) {
      case 'message':
        this.showMessageNotification(data);
        break;
      case 'alert':
        this.showAlertNotification(data);
        break;
      case 'update':
        this.showUpdateNotification(data);
        break;
      default:
        this.showGenericNotification(data);
    }
  }

  showMessageNotification(data) {
    const notification = this.notificationManager.show(data.sender, {
      body: data.message,
      icon: data.senderAvatar,
      tag: 'message',
      data: { conversationId: data.conversationId },
    });

    if (notification) {
      notification.onclick = () => {
        window.focus();
        window.location.href = `/messages/${data.conversationId}`;
        notification.close();
      };
    }
  }

  showAlertNotification(data) {
    this.notificationManager.show('Alert', {
      body: data.message,
      icon: '/images/alert-icon.png',
      requireInteraction: true,
      vibrate: [200, 100, 200],
    });
  }

  showUpdateNotification(data) {
    this.notificationManager.show('Update Available', {
      body: data.message,
      icon: '/images/update-icon.png',
      actions: [
        { action: 'update', title: 'Update Now' },
        { action: 'later', title: 'Later' },
      ],
    });
  }

  showGenericNotification(data) {
    this.notificationManager.show(data.title || 'Notification', {
      body: data.body,
      icon: data.icon,
    });
  }

  scheduleReconnect() {
    setTimeout(() => {
      this.setupWebSocket();
      this.reconnectDelay = Math.min(
        this.reconnectDelay * 2,
        this.maxReconnectDelay
      );
    }, this.reconnectDelay);
  }

  disconnect() {
    if (this.ws) {
      this.ws.close();
    }
  }
}

Notification Preferences

// User preference management
class NotificationPreferences {
  constructor() {
    this.storageKey = 'notification-preferences';
    this.defaults = {
      enabled: true,
      sound: true,
      categories: {
        messages: true,
        reminders: true,
        updates: true,
        marketing: false,
      },
      quietHours: {
        enabled: false,
        start: '22:00',
        end: '08:00',
      },
    };

    this.preferences = this.load();
  }

  load() {
    const stored = localStorage.getItem(this.storageKey);
    if (stored) {
      return { ...this.defaults, ...JSON.parse(stored) };
    }
    return this.defaults;
  }

  save() {
    localStorage.setItem(this.storageKey, JSON.stringify(this.preferences));
  }

  get(key) {
    return this.preferences[key];
  }

  set(key, value) {
    this.preferences[key] = value;
    this.save();
  }

  setCategoryEnabled(category, enabled) {
    if (this.preferences.categories.hasOwnProperty(category)) {
      this.preferences.categories[category] = enabled;
      this.save();
    }
  }

  isNotificationAllowed(category) {
    if (!this.preferences.enabled) return false;
    if (!this.preferences.categories[category]) return false;
    if (this.isQuietHours()) return false;

    return true;
  }

  isQuietHours() {
    if (!this.preferences.quietHours.enabled) return false;

    const now = new Date();
    const currentTime = now.getHours() * 60 + now.getMinutes();

    const [startHour, startMin] = this.preferences.quietHours.start
      .split(':')
      .map(Number);
    const [endHour, endMin] = this.preferences.quietHours.end
      .split(':')
      .map(Number);

    const startTime = startHour * 60 + startMin;
    const endTime = endHour * 60 + endMin;

    if (startTime <= endTime) {
      return currentTime >= startTime && currentTime < endTime;
    } else {
      // Quiet hours span midnight
      return currentTime >= startTime || currentTime < endTime;
    }
  }
}

// Smart notification controller
class SmartNotifications {
  constructor() {
    this.manager = new NotificationManager();
    this.preferences = new NotificationPreferences();
    this.queue = [];
    this.rateLimits = new Map();
  }

  async show(category, title, options) {
    // Check preferences
    if (!this.preferences.isNotificationAllowed(category)) {
      console.log(`Notification blocked by preferences: ${category}`);
      return null;
    }

    // Check rate limiting
    if (this.isRateLimited(category)) {
      this.queue.push({ category, title, options });
      return null;
    }

    // Update rate limit
    this.updateRateLimit(category);

    // Show notification
    const notification = await this.manager.show(title, {
      ...options,
      silent: !this.preferences.get('sound'),
    });

    return notification;
  }

  isRateLimited(category) {
    const limit = this.rateLimits.get(category);
    if (!limit) return false;

    const timeSinceLastNotification = Date.now() - limit.lastShown;
    const minInterval = 60000; // 1 minute minimum between same category

    return timeSinceLastNotification < minInterval;
  }

  updateRateLimit(category) {
    this.rateLimits.set(category, {
      lastShown: Date.now(),
      count: (this.rateLimits.get(category)?.count || 0) + 1,
    });
  }

  processQueue() {
    const now = Date.now();

    this.queue = this.queue.filter((item) => {
      if (!this.isRateLimited(item.category)) {
        this.show(item.category, item.title, item.options);
        return false;
      }
      return true;
    });
  }
}

Service Worker Integration

// Service worker notification handling
// In service-worker.js
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};

  const options = {
    body: data.body || 'New notification',
    icon: data.icon || '/images/icon-192x192.png',
    badge: '/images/badge-72x72.png',
    vibrate: [200, 100, 200],
    data: data.data || {},
    actions: data.actions || [],
    tag: data.tag,
    requireInteraction: data.requireInteraction || false,
  };

  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', options)
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  if (event.action) {
    // Handle action clicks
    switch (event.action) {
      case 'reply':
        // Open reply interface
        event.waitUntil(
          clients.openWindow(
            '/messages?reply=' + event.notification.data.messageId
          )
        );
        break;
      case 'archive':
        // Archive the message
        event.waitUntil(
          fetch('/api/messages/archive', {
            method: 'POST',
            body: JSON.stringify({
              messageId: event.notification.data.messageId,
            }),
          })
        );
        break;
    }
  } else {
    // Handle notification body click
    event.waitUntil(
      clients.matchAll({ type: 'window' }).then((clientList) => {
        // Focus existing window or open new one
        for (const client of clientList) {
          if (client.url === '/' && 'focus' in client) {
            return client.focus();
          }
        }

        if (clients.openWindow) {
          return clients.openWindow('/');
        }
      })
    );
  }
});

// In main app
class PushNotifications {
  async subscribe() {
    const registration = await navigator.serviceWorker.ready;

    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: this.urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
    });

    // Send subscription to server
    await fetch('/api/push/subscribe', {
      method: 'POST',
      body: JSON.stringify(subscription),
      headers: {
        'Content-Type': 'application/json',
      },
    });
  }

  urlBase64ToUint8Array(base64String) {
    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;
  }
}

Best Practices

  1. Always check permission before showing notifications
async function safeNotification(title, options) {
  if (Notification.permission === 'default') {
    await Notification.requestPermission();
  }

  if (Notification.permission === 'granted') {
    return new Notification(title, options);
  }

  return null;
}
  1. Provide meaningful notification content
// Good
new Notification('John Doe', {
  body: 'Hey, are you available for a quick call?',
  icon: '/avatars/john-doe.jpg',
});

// Bad
new Notification('New Message', {
  body: 'You have a new message',
});
  1. Handle notification lifecycle events
function createNotification(title, options) {
  const notification = new Notification(title, options);

  notification.onshow = () => {
    // Track notification shown
    analytics.track('notification_shown', { title });
  };

  notification.onclick = () => {
    // Handle click
    window.focus();
    notification.close();
  };

  notification.onerror = (error) => {
    console.error('Notification error:', error);
    // Fallback to in-app notification
    showInAppNotification(title, options.body);
  };

  return notification;
}
  1. Respect user preferences
class RespectfulNotifications {
  constructor() {
    this.lastRequestTime = localStorage.getItem('lastNotificationRequest');
    this.deniedCount = parseInt(
      localStorage.getItem('notificationDeniedCount') || '0'
    );
  }

  async requestPermission() {
    // Don't ask too frequently
    if (this.shouldSkipRequest()) {
      return false;
    }

    const permission = await Notification.requestPermission();

    if (permission === 'denied') {
      this.deniedCount++;
      localStorage.setItem(
        'notificationDeniedCount',
        this.deniedCount.toString()
      );
    }

    localStorage.setItem('lastNotificationRequest', Date.now().toString());

    return permission === 'granted';
  }

  shouldSkipRequest() {
    // Never ask if denied multiple times
    if (this.deniedCount >= 2) return true;

    // Don't ask more than once per week
    if (this.lastRequestTime) {
      const daysSinceLastRequest =
        (Date.now() - parseInt(this.lastRequestTime)) / (1000 * 60 * 60 * 24);
      return daysSinceLastRequest < 7;
    }

    return false;
  }
}

Conclusion

The Notification API provides a powerful way to engage users with timely, relevant information even when they're not actively using your application. By combining it with service workers for push notifications, proper permission handling, and respect for user preferences, you can create a notification system that enhances user experience without being intrusive. Remember to always prioritize user consent and provide clear value with each notification to maintain trust and engagement.