Web Push Isn't Chrome-Only Anymore
For years, web push was a desktop-only Chrome feature. Safari users got nothing. Mobile web was a dead zone for notifications.
Plot twist: iOS 16.4 (May 2023) shipped Web Push support in Safari. Two years later, it's stable and widely deployed. Combined with Android Chrome's mature implementation, you can now send push notifications to 85%+ of the web.
The result? Velocity X SaaS apps can alert users without email. Activity notifications. Billing reminders. Team mentions. Real-time updates that land on their lock screen, not buried in a spam inbox.
This isn't theoretical. We ship push on Velocity X for every production deployment. Users get instant notifications when a job is assigned, a quote is approved, or a team member replies. Zero email dependency. No more "I didn't see it in my inbox".
Why Push Beats Email for In-App Alerts
Email is slow. By the time the notification arrives, the context is gone. Your user got 47 other emails in the meantime. The notification is buried.
Push notifications are instant. They land on the device seconds after the event. They interrupt politely—the user sees it on their lock screen or as a banner. If they're already in the app, a toast bubbles up silently.
For SaaS, push transforms user experience:
- Activity alerts: "Sarah replied to your job note" arrives in 1 second, not 5 minutes.
- Billing reminders: "Your trial ends in 3 days" hits before they forget.
- Team urgency: "New hail job assigned to you" wakes them up immediately.
- Zero friction: No login to email. No "mark as read". Just swipe and move on.
Real example: a field team uses Velocity X to manage job assignments. Push notifications mean foreman can assign a new job and the tradie knows within seconds. No more "why didn't you start the job?" — they got the alert, they chose to delay. Accountability improves.
What You Need to Know About VAPID Keys
VAPID (Voluntary Application Server Identification) is a spec that lets you prove to the browser that YOU own the push service. Without it, any website could send pushes claiming to be you.
VAPID uses two asymmetric keys: a public key (shared with the client) and a private key (kept secret on the server). When you send a push, you sign it with your private key. The browser verifies it with your public key.
Generate VAPID keys once, usually during setup:
npm install -g web-push
web-push generate-vapid-keys
You'll get:
Public Key: BGq...xyz
Private Key: abc...123
Store these in your .env:
VITE_VAPID_PUBLIC_KEY=BGq...xyz
VAPID_PRIVATE_KEY=abc...123
The public key is sent to the browser (it's not sensitive). The private key stays on your server and signs every push request.
Service Worker + Subscription Pattern
When a user visits your app, you ask for push permission. If they grant it, the browser creates a PushSubscription—a unique endpoint tied to that device + browser combo. Store this subscription in Supabase (or your backend). Later, when you want to send a push, you POST to that subscription endpoint.
Setup happens in your app:
// src/lib/push-notifications.ts
export async function requestPushPermission() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('Push not supported');
return;
}
const registration = await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Push permission denied');
return;
}
// Subscribe the user
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(
import.meta.env.VITE_VAPID_PUBLIC_KEY
),
});
// Send subscription to backend
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
console.log('Push subscribed:', subscription.endpoint);
}
function urlB64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return new Uint8Array([...rawData].map(char => char.charCodeAt(0)));
}
Call this when the user taps a notification bell:
// src/components/NotificationBell.tsx
const handleEnablePush = async () => {
await requestPushPermission();
setNotificationsEnabled(true);
};
Now your backend receives the subscription and stores it:
// /api/push/subscribe (server)
const supabase = createClient();
await supabase.from('push_subscriptions').insert({
user_id: user.id,
endpoint: subscription.endpoint,
auth_key: subscription.keys.auth,
p256dh: subscription.keys.p256dh,
created_at: new Date(),
});
Supabase Push Subscription Schema
Schema design is simple:
create table push_subscriptions (
id bigint primary key generated always as identity,
user_id uuid not null references auth.users on delete cascade,
endpoint text not null unique,
auth_key text not null,
p256dh text not null,
created_at timestamp default now(),
updated_at timestamp default now()
);
create index on push_subscriptions(user_id);
Store the subscription only once per device. On repeat visits, check if the endpoint already exists (query by endpoint). If it does, skip insertion—no duplicates.
When the user logs out, delete the subscription from Supabase. When they log back in on the same device, re-subscribe. This keeps your list clean.
Sending Pushes via web-push Library
The web-push npm library handles signing and delivery. Install it:
npm install web-push
When an event happens (job assigned, reply posted, etc.), fetch the subscriptions and send:
// src/lib/push-sender.ts
import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:admin@aidxn.com',
process.env.VITE_VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function sendPushToUser(userId: string, payload: {
title: string;
body: string;
icon?: string;
badge?: string;
tag?: string;
}) {
const supabase = createClient();
// Fetch all subscriptions for this user
const { data: subscriptions } = await supabase
.from('push_subscriptions')
.select('*')
.eq('user_id', userId);
for (const sub of subscriptions || []) {
try {
await webpush.sendNotification(
{
endpoint: sub.endpoint,
keys: {
auth: sub.auth_key,
p256dh: sub.p256dh,
},
},
JSON.stringify(payload)
);
} catch (error: any) {
if (error.statusCode === 410) {
// Subscription expired, delete it
await supabase
.from('push_subscriptions')
.delete()
.eq('endpoint', sub.endpoint);
}
}
}
}
Trigger this from your backend when events fire:
// /api/jobs/assign (server)
const assignedJob = await assignJobToUser(jobId, userId);
// Send push immediately
await sendPushToUser(userId, {
title: 'New Job Assigned',
body: <code>Hail damage assessment at 42 Main St</code>,
tag: <code>job-${jobId}</code>,
badge: '/badge-icon.png',
});
The push lands on their device in < 1 second.
Handle Pushes in the Service Worker
The service worker receives push events and displays them:
// src/service-worker.ts
self.addEventListener('push', event => {
const data = event.data?.json() || {};
const options: NotificationOptions = {
body: data.body,
icon: data.icon || '/logo-icon.png',
badge: data.badge || '/badge.png',
tag: data.tag || 'notification',
requireInteraction: false,
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Handle user clicking the notification
self.addEventListener('notificationclick', event => {
event.notification.close();
// Route to the relevant page (job, message, etc.)
const url = event.notification.tag?.includes('job-')
? <code>/jobs/${event.notification.tag.split('-')[1]}</code>
: '/dashboard';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
// If app is already open, focus it and navigate
for (const client of clientList) {
if (client.url === '/' && 'focus' in client) {
(client as WindowClient).focus();
client.postMessage({ type: 'NAVIGATE', url });
return;
}
}
// Open new window
return clients.openWindow(url);
})
);
});
Now the flow is complete:
- User subscribes → subscription stored in Supabase
- Job assigned → backend calls
sendPushToUser() - Push delivered → service worker catches it
- Notification displayed on device
- User clicks → service worker routes to job page
Six FAQs
Q: Does web push work on Android Chrome? A: Yes, since 2015. Android Chrome has mature push support via Firebase Cloud Messaging (FCM). Desktop Chrome works too.
Q: Does web push work on iOS Safari? A: Yes, since iOS 16.4 (May 2023). Works on iPhone and iPad. PWA push is also supported if the app is added to home screen.
Q: What if the user denies push permission? A: Graceful degradation. They still get email notifications (you run a fallback task). Push is an enhancement, not a requirement.
Q: Can I send pushes to a million users at once?
A: Yes, but batch them. web-push sends sequentially. For large campaigns, queue the pushes (e.g., 100 at a time, staggered) or use Firebase Cloud Messaging which is designed for scale.
Q: How long do subscriptions last? A: Browser-dependent. Chrome keeps them indefinitely. Safari clears them if the user doesn't interact with the app for 30 days. Always check for 410 (gone) responses and clean up your database.
Q: Should I use FCM or web-push library?
A: FCM is better for scale (cross-platform, monitoring, analytics built-in). web-push is simpler to start (fewer dependencies). For Velocity X, we use web-push because our deployments are SaaS multi-tenant (not millions of users at once).
The Verdict
Web push is no longer the Chrome feature everyone ignored. iOS support flipped the script. You can now reach users on desktop, tablet, and mobile—all from the same Web API.
For SaaS apps managing field teams, notifications are critical. Email is too slow. In-app alerts only work if the app is open. Push fills the gap: instant, persistent, reliable.
The pattern is straightforward: VAPID keys → service worker → Supabase subscriptions → web-push library → delivery in 1 second.
For Velocity X, push is table stakes. Every deployment includes:
- VAPID key rotation (secure key management)
- Subscription store (Supabase with user indexing)
- Event-driven sends (job assigned, reply posted, etc.)
- Stale subscription cleanup (HTTP 410 handling)
One service worker. One subscription table. Infinite scale. Check out our pricing page to see how Velocity X stacks handle real-time notifications at scale. For more on service workers and offline-first design, read our service worker post—notifications are the flip side of the offline coin.
Your users will feel the difference. Instant alerts. No email noise. Notification-driven engagement that actually works. That's web push done right.