Front-end Engineering Lab
PatternsMobile & PWA

Push Notifications

Implement Web Push notifications with service workers

Web Push notifications allow you to re-engage users even when your app isn't open. Used by major apps like Twitter, YouTube, and Facebook.

The Flow

1. User grants permission
2. Browser generates subscription
3. Send subscription to your server
4. Server sends push to browser push service
5. Browser delivers to service worker
6. Service worker shows notification

Basic Setup

Request Permission

// utils/push.ts
export async function requestNotificationPermission(): Promise<boolean> {
  if (!('Notification' in window)) {
    console.warn('Notifications not supported');
    return false;
  }

  if (Notification.permission === 'granted') {
    return true;
  }

  if (Notification.permission === 'denied') {
    console.warn('Notification permission denied');
    return false;
  }

  const permission = await Notification.requestPermission();
  return permission === 'granted';
}

Subscribe to Push

// Get VAPID public key from your server
const VAPID_PUBLIC_KEY = 'YOUR_PUBLIC_KEY';

export async function subscribeToPush(): Promise<PushSubscription | null> {
  const permission = await requestNotificationPermission();
  if (!permission) return null;

  const registration = await navigator.serviceWorker.ready;

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

    // Send subscription to your server
    await sendSubscriptionToServer(subscription);

    return subscription;
  } catch (error) {
    console.error('Failed to subscribe:', error);
    return null;
  }
}

function 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;
}

async function sendSubscriptionToServer(subscription: PushSubscription) {
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });
}

Service Worker - Receive Push

// sw.js
self.addEventListener('push', (event) => {
  if (!event.data) return;

  const data = event.data.json();

  const options = {
    body: data.body,
    icon: data.icon || '/icon.png',
    badge: '/badge.png',
    vibrate: [200, 100, 200],
    data: {
      url: data.url || '/',
      timestamp: Date.now(),
    },
    actions: data.actions || [],
    tag: data.tag || 'default',
    renotify: true,
  };

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

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

  // Handle action buttons
  if (event.action) {
    console.log('Action clicked:', event.action);
    // Handle specific action
  }

  // Open URL
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
      // Check if app is already open
      for (const client of clientList) {
        if (client.url === event.notification.data.url && 'focus' in client) {
          return client.focus();
        }
      }
      
      // Open new window
      if (clients.openWindow) {
        return clients.openWindow(event.notification.data.url);
      }
    })
  );
});

Server-Side (Node.js)

Generate VAPID Keys

npm install web-push
// scripts/generate-vapid-keys.js
const webpush = require('web-push');

const vapidKeys = webpush.generateVAPIDKeys();

console.log('Public Key:', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);

// Save to .env:
// VAPID_PUBLIC_KEY=...
// VAPID_PRIVATE_KEY=...
// VAPID_EMAIL=mailto:your-email@example.com

Send Push Notification

// api/push/send.ts
import webpush from 'web-push';

webpush.setVapidDetails(
  process.env.VAPID_EMAIL!,
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!
);

export async function sendPushNotification(
  subscription: PushSubscription,
  payload: any
) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify(payload));
    return { success: true };
  } catch (error) {
    console.error('Push failed:', error);
    
    // If subscription is invalid, remove from database
    if (error.statusCode === 410) {
      await removeSubscription(subscription);
    }
    
    return { success: false, error };
  }
}

Store Subscriptions

// api/push/subscribe.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const subscription = await request.json();
  const userId = request.headers.get('user-id');

  // Save subscription to database
  await db.pushSubscriptions.create({
    data: {
      userId,
      endpoint: subscription.endpoint,
      keys: subscription.keys,
    },
  });

  return NextResponse.json({ success: true });
}

Advanced Notifications

Rich Notifications

// sw.js
self.addEventListener('push', (event) => {
  const data = event.data.json();

  const options = {
    body: data.body,
    icon: '/icon-192x192.png',
    badge: '/badge-72x72.png',
    image: data.image, // Large image
    vibrate: [200, 100, 200, 100, 200],
    
    // Action buttons
    actions: [
      {
        action: 'like',
        title: 'šŸ‘ Like',
        icon: '/icons/like.png',
      },
      {
        action: 'reply',
        title: 'šŸ’¬ Reply',
        icon: '/icons/reply.png',
      },
    ],
    
    // Custom data
    data: {
      url: data.url,
      postId: data.postId,
    },
    
    // Notification behavior
    requireInteraction: false, // Auto-dismiss
    silent: false,
    tag: data.tag, // Replace previous notification with same tag
    renotify: true, // Vibrate even if replacing
    timestamp: Date.now(),
  };

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

Inline Reply

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

  if (event.action === 'reply') {
    // Show inline reply input
    event.waitUntil(
      clients.openWindow(`/reply?postId=${event.notification.data.postId}`)
    );
  } else if (event.action === 'like') {
    // Like post in background
    event.waitUntil(
      fetch('/api/posts/like', {
        method: 'POST',
        body: JSON.stringify({ postId: event.notification.data.postId }),
      })
    );
  }
});

Notification Badges

// Update app icon badge
navigator.setAppBadge(5); // Show "5"
navigator.clearAppBadge(); // Clear badge

React Components

Permission Button

// components/NotificationButton.tsx
import { useState, useEffect } from 'react';
import { requestNotificationPermission, subscribeToPush } from '@/utils/push';

export function NotificationButton() {
  const [permission, setPermission] = useState<NotificationPermission>('default');
  const [subscribed, setSubscribed] = useState(false);

  useEffect(() => {
    if ('Notification' in window) {
      setPermission(Notification.permission);
    }
  }, []);

  const handleSubscribe = async () => {
    const granted = await requestNotificationPermission();
    
    if (granted) {
      const subscription = await subscribeToPush();
      setSubscribed(!!subscription);
      setPermission('granted');
    } else {
      setPermission(Notification.permission);
    }
  };

  if (permission === 'denied') {
    return (
      <div className="notification-blocked">
        <p>Notifications blocked. Enable in browser settings.</p>
      </div>
    );
  }

  if (permission === 'granted' && subscribed) {
    return (
      <div className="notification-enabled">
        <p>āœ“ Notifications enabled</p>
      </div>
    );
  }

  return (
    <button onClick={handleSubscribe}>
      šŸ”” Enable Notifications
    </button>
  );
}

Notification Prompt

// components/NotificationPrompt.tsx
import { useState, useEffect } from 'react';

export function NotificationPrompt() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    // Show prompt after 30 seconds if not already decided
    const timer = setTimeout(() => {
      if (Notification.permission === 'default') {
        setShow(true);
      }
    }, 30000);

    return () => clearTimeout(timer);
  }, []);

  if (!show) return null;

  return (
    <div className="notification-prompt">
      <p>Get notified of important updates</p>
      <button onClick={async () => {
        await requestNotificationPermission();
        setShow(false);
      }}>
        Enable Notifications
      </button>
      <button onClick={() => setShow(false)}>
        Not Now
      </button>
    </div>
  );
}

Real-World Examples

Chat Notification

// Send from server
await sendPushNotification(userSubscription, {
  title: 'New message from John',
  body: 'Hey, how are you?',
  icon: '/avatars/john.jpg',
  badge: '/badge.png',
  tag: 'chat-john',
  data: {
    url: '/chat/john',
    chatId: '123',
  },
  actions: [
    { action: 'reply', title: 'Reply' },
    { action: 'mark-read', title: 'Mark as read' },
  ],
});

E-commerce Order Update

await sendPushNotification(userSubscription, {
  title: 'Order Shipped! šŸ“¦',
  body: 'Your order #12345 is on its way',
  icon: '/logo.png',
  image: '/products/order-12345.jpg',
  tag: 'order-12345',
  data: {
    url: '/orders/12345',
    orderId: '12345',
  },
  actions: [
    { action: 'track', title: 'Track Package' },
    { action: 'details', title: 'Order Details' },
  ],
});

Breaking News

await sendPushNotification(userSubscription, {
  title: 'Breaking News',
  body: 'Important announcement...',
  icon: '/icon.png',
  badge: '/badge.png',
  tag: 'breaking-news',
  requireInteraction: true, // Don't auto-dismiss
  vibrate: [500, 200, 500],
  data: {
    url: '/news/breaking',
  },
});

Testing

// Test notification locally
if ('Notification' in window && Notification.permission === 'granted') {
  new Notification('Test Notification', {
    body: 'This is a test',
    icon: '/icon.png',
  });
}

// Test push with curl
curl -X POST https://fcm.googleapis.com/fcm/send \
  -H "Authorization: key=SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "DEVICE_TOKEN",
    "notification": {
      "title": "Test",
      "body": "Test message"
    }
  }'

Best Practices

  1. Ask at right time: Not immediately on page load
  2. Explain value: Tell users why notifications are useful
  3. Respect denial: Don't ask again if denied
  4. Group notifications: Use tags to replace similar notifications
  5. Actionable: Include relevant actions
  6. Timely: Send at appropriate times
  7. Relevant: Only send important updates
  8. Test thoroughly: Different devices and browsers
  9. Handle errors: Remove invalid subscriptions
  10. Privacy: Don't send sensitive data in notifications

Common Pitfalls

āŒ Asking immediately: Annoys users
āœ… Wait for engagement, explain value

āŒ No action buttons: Missed engagement
āœ… Add relevant actions (like, reply, etc.)

āŒ Spam: Too many notifications
āœ… Only send important, relevant updates

āŒ Generic messages: "New update"
āœ… Specific, actionable content

āŒ No error handling: Stale subscriptions
āœ… Remove invalid subscriptions (410 errors)

Push notifications are powerful for re-engagement—use them wisely and respect user preferences!

On this page