TL;DR
web-push implements the W3C Web Push Protocol directly — no third-party service required, uses VAPID (Voluntary Application Server Identification), works with any browser's Push API. firebase-admin sends Firebase Cloud Messaging (FCM) notifications — handles web, iOS, and Android in one SDK, supports topics, conditions, and rich notifications. @onesignal/node-onesignal is the OneSignal API client — a managed service that wraps FCM/APNs/Web Push into one platform with segmentation, A/B testing, and analytics. For self-hosted web push without a third party: web-push. For cross-platform (web + mobile): firebase-admin. For a managed platform with UI: OneSignal.
Key Takeaways
- web-push: ~250K weekly downloads — W3C standard, no third-party needed, VAPID auth, browser Push API
- firebase-admin: ~3M weekly downloads — FCM, cross-platform (web + iOS + Android), topics, conditions
- @onesignal/node-onesignal: managed SaaS — analytics dashboard, segmentation, A/B testing, no server needed for some features
- Web Push works only in browsers (and PWAs) — for native mobile, you need FCM/APNs
- VAPID keys are required for web-push — generate once, store securely
- FCM is free but requires a Firebase project; OneSignal has a free tier with limits
Push Notification Architecture
Web Push (browser):
Server → Web Push Protocol → Browser Push Service (Google/Mozilla/Apple) → Browser
FCM (cross-platform):
Server → FCM API → Device (Android/iOS/Web)
OneSignal:
Server → OneSignal API → OneSignal Platform → FCM/APNs/Web Push → Device
All paths require:
1. User grants notification permission (browser prompt)
2. Client registers subscription (browser) or device token (mobile)
3. Server stores subscription/token
4. Server sends notification via chosen service
web-push
web-push — implement Web Push Protocol directly:
Server setup
import webpush from "web-push"
// Generate VAPID keys (do this ONCE, store in env vars):
// const vapidKeys = webpush.generateVAPIDKeys()
// console.log(vapidKeys)
// { publicKey: "...", privateKey: "..." }
// Configure with your VAPID keys:
webpush.setVapidDetails(
"mailto:admin@pkgpulse.com", // Contact email
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
)
Client-side subscription (browser)
// Browser-side code (runs in your frontend):
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!
async function subscribeToPushNotifications() {
// Check browser support:
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.log("Push notifications not supported")
return null
}
// Register service worker:
const registration = await navigator.serviceWorker.register("/sw.js")
// Request permission:
const permission = await Notification.requestPermission()
if (permission !== "granted") return null
// Subscribe to push:
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
})
// Send subscription to your server:
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription),
})
return subscription
}
// Helper: convert VAPID key
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
const rawData = atob(base64)
return new Uint8Array([...rawData].map((c) => c.charCodeAt(0)))
}
Service worker (sw.js)
// public/sw.js — handles incoming push events:
self.addEventListener("push", (event) => {
const data = event.data?.json() ?? {}
event.waitUntil(
self.registration.showNotification(data.title ?? "PkgPulse", {
body: data.body ?? "New package health update",
icon: "/icon-192.png",
badge: "/badge-72.png",
data: { url: data.url ?? "/" },
actions: [
{ action: "view", title: "View" },
{ action: "dismiss", title: "Dismiss" },
],
})
)
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
if (event.action === "view") {
event.waitUntil(clients.openWindow(event.notification.data.url))
}
})
Sending notifications from Node.js
import webpush from "web-push"
import { db } from "./db"
// Send to a single subscription:
async function sendNotification(
subscription: webpush.PushSubscription,
payload: { title: string; body: string; url?: string }
) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload))
return { success: true }
} catch (err: any) {
if (err.statusCode === 410) {
// Gone — subscription is expired, remove from DB
await db.pushSubscription.delete({ where: { endpoint: subscription.endpoint } })
}
throw err
}
}
// Broadcast to all subscribed users:
async function broadcastNotification(payload: { title: string; body: string }) {
const subscriptions = await db.pushSubscription.findMany()
const results = await Promise.allSettled(
subscriptions.map((sub) => sendNotification(sub, payload))
)
const failed = results.filter((r) => r.status === "rejected").length
console.log(`Sent to ${subscriptions.length - failed}/${subscriptions.length} subscribers`)
}
firebase-admin (FCM)
firebase-admin — Firebase Cloud Messaging for web + mobile:
Setup
import { initializeApp, cert } from "firebase-admin/app"
import { getMessaging } from "firebase-admin/messaging"
// Initialize Firebase Admin:
initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
}),
})
const messaging = getMessaging()
Send to a single device
import { getMessaging } from "firebase-admin/messaging"
const messaging = getMessaging()
// Send to a FCM token (device token from client SDK):
async function sendToDevice(fcmToken: string, title: string, body: string) {
const message = {
notification: { title, body },
webpush: {
notification: {
icon: "/icon-192.png",
click_action: "https://pkgpulse.com",
},
},
apns: {
payload: {
aps: {
sound: "default",
badge: 1,
},
},
},
token: fcmToken,
}
const response = await messaging.send(message)
console.log("Successfully sent:", response)
return response
}
Topics (broadcast to groups)
// Topics let users subscribe to categories without server-side fan-out:
// Subscribe a device to a topic:
await messaging.subscribeToTopic([deviceToken], "package-updates")
await messaging.subscribeToTopic([deviceToken], "weekly-digest")
// Unsubscribe:
await messaging.unsubscribeFromTopic([deviceToken], "weekly-digest")
// Send to all subscribers of a topic:
await messaging.send({
notification: {
title: "react v19 Released",
body: "React 19 is now available on npm",
},
topic: "package-updates", // All devices subscribed to this topic
})
// Condition (combine topics):
await messaging.send({
notification: { title: "Alert", body: "Critical security update" },
condition: "'security-alerts' in topics && 'typescript' in topics",
})
Batch sending
import { getMessaging } from "firebase-admin/messaging"
async function sendBatch(
tokens: string[],
notification: { title: string; body: string }
) {
const messaging = getMessaging()
// Split into batches of 500 (FCM limit):
const chunkSize = 500
const chunks = []
for (let i = 0; i < tokens.length; i += chunkSize) {
chunks.push(tokens.slice(i, i + chunkSize))
}
const results = await Promise.all(
chunks.map((chunk) =>
messaging.sendEachForMulticast({
tokens: chunk,
notification,
})
)
)
const totalSuccess = results.reduce((sum, r) => sum + r.successCount, 0)
const totalFailed = results.reduce((sum, r) => sum + r.failureCount, 0)
console.log(`Sent: ${totalSuccess}, Failed: ${totalFailed}`)
}
Feature Comparison
| Feature | web-push | firebase-admin (FCM) | OneSignal |
|---|---|---|---|
| Web Push | ✅ | ✅ | ✅ |
| iOS (APNs) | ❌ | ✅ | ✅ |
| Android (FCM) | ❌ | ✅ | ✅ |
| Topics/segments | ❌ (DIY) | ✅ | ✅ Excellent |
| Third-party service | ❌ | ✅ Firebase | ✅ OneSignal |
| Analytics dashboard | ❌ | ✅ Firebase Console | ✅ OneSignal |
| A/B testing | ❌ | ❌ | ✅ |
| Scheduled sends | ❌ (DIY) | ❌ | ✅ |
| Cost | Free | Free (usage limits) | Free tier / paid |
| TypeScript | ✅ | ✅ | ✅ |
| Self-hosted | ✅ | ❌ (Firebase dep) | ❌ |
When to Use Each
Choose web-push if:
- Web-only PWA (no iOS/Android native apps)
- You want no third-party dependency
- GDPR or data sovereignty requires keeping data in-house
- Simple use case — subscribe, send, receive
Choose firebase-admin (FCM) if:
- Cross-platform: web + iOS + Android from one SDK
- You want topics for efficient broadcast without server-side fan-out
- Already using Firebase for other features
- Need delivery analytics in Firebase Console
Choose OneSignal if:
- You want a managed platform with UI — no server code for basic sends
- Rich segmentation, automation, and A/B testing required
- Team wants a non-engineering dashboard for marketing/product
- Need cross-platform + email + in-app messaging in one tool
Migration Guide
From web-push to Firebase Cloud Messaging
The fundamental architecture shift is from browser-standard push subscriptions to FCM registration tokens. Existing web-push subscribers cannot be migrated — users must re-subscribe through the FCM client SDK:
// web-push (old) — browser PushSubscription object
// Client-side (browser):
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
})
// Store subscription.endpoint + p256dh + auth in your DB
// Firebase (new) — FCM registration token
import { getMessaging, getToken } from "firebase/messaging"
const fcmToken = await getToken(getMessaging(), { vapidKey: YOUR_WEB_PUSH_CERT })
// Store fcmToken in your DB — replaces the subscription object
Server-side sending changes from webpush.sendNotification() to messaging.send(). The service worker also changes — Firebase requires a firebase-messaging-sw.js file at your root.
Community Adoption in 2026
web-push has around 500,000 weekly npm downloads, driven by self-hosted PWAs and applications where GDPR compliance or data sovereignty requirements prohibit sending user device tokens to third-party services. The package is maintained by the WebPush Protocol community and tracks the Web Push spec closely.
firebase-admin reaches over 3 million weekly downloads but is primarily consumed as a full Firebase admin SDK dependency, not exclusively for FCM. Teams already invested in Firebase for Firestore, Auth, or Storage adopt FCM naturally since it requires no additional vendor relationship. FCM's topic-based fan-out is particularly powerful for large-scale broadcasting where server-side subscription management would be expensive.
OneSignal is not primarily measured by npm downloads — its SDK is loaded via CDN snippet for most web integrations. OneSignal reports serving over 1 million apps globally. Its adoption is highest among product and marketing teams that want to send push notifications without building server infrastructure. The free tier supports up to 10,000 subscribers, making it practical for early-stage products.
Browser Compatibility, Privacy Regulations, and Notification Permission Strategy
Web push notification delivery and permissions are more complex in 2026 than they were even two years ago, driven by browser policy changes and stricter privacy regulations.
Safari support for web push (via the Push API on macOS and iOS 16.4+) changed the landscape significantly. For the first time, web push notifications work across all major browsers — Chrome, Firefox, Edge, and Safari — using the same Web Push Protocol. Libraries that implement the standard (the web-push npm package) work with Safari automatically. Commercial services like OneSignal and FCM also support Safari web push, but both required backend updates to handle Apple's APNs-based push delivery for Safari. If you implemented web push before 2023, test Safari delivery specifically.
iOS limitations remain even with web push support on Safari: web push only works for websites installed to the iOS home screen as Progressive Web Apps. Safari on iOS does not prompt for push permission on regular web browsing — users must first add the site to their home screen. This dramatically limits reach for casual web visitors on iOS and makes web push less effective for notification strategies targeting mobile-first audiences.
GDPR and privacy compliance requires explicit user consent before requesting push permission. The browser's native permission prompt counts as consent under most interpretations, but displaying it immediately on page load (without context) violates the principle of informed consent and produces low opt-in rates. The recommended pattern is a "soft ask" UI — a custom in-page prompt explaining the value of notifications — before triggering the browser permission dialog. OneSignal and FCM SDKs provide pre-built soft-ask UI components; the web-push npm package handles only the server-side push delivery and leaves permission UX entirely to you.
Notification icon and badge requirements differ by OS. Chrome on Android enforces a minimum icon size (192×192px) and requires the icon to be served over HTTPS. Firefox has different icon size recommendations. Testing notifications across browsers and operating systems before launch is essential to avoid blank notification icons that reduce click-through rates.
The delivery success rate for web push notifications in 2026 is roughly 85-92% for modern browsers with an active service worker, with the primary failure modes being service worker expiration (the browser terminates dormant service workers after several weeks) and permission revocation by users who found notifications too frequent.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on web-push v3.x, firebase-admin v12.x, and @onesignal/node-onesignal v1.x.
Compare notification and messaging packages on PkgPulse →
The subscription lifecycle management is one of the most underestimated operational concerns in any web push implementation. Push subscriptions expire silently — there is no webhook or callback when a subscription becomes invalid. The only way to discover an expired subscription is to attempt to send a notification to it: a 410 Gone response from the push service means the subscription is permanently expired and should be removed from your database. A 429 Too Many Requests means you're rate-limited by the push service. A 201 success means delivery was accepted (not necessarily delivered — the push service queues the notification for when the device is online). Building a robust web push system means handling these response codes in your send loop, cleaning up 410 subscriptions automatically, implementing exponential backoff for 429 responses, and storing subscription health metrics to understand your delivery rates over time. Firebase's topic-based architecture offloads subscription lifecycle management to FCM — invalid device tokens are automatically pruned from topics.
A web-push implementation detail that causes silent delivery failures: the VAPID public key must be stored in its raw URL-safe Base64 format when passed to the browser's pushManager.subscribe() as an applicationServerKey. If you store the key in standard Base64 (with + and / characters) and pass it directly, the subscription creation silently fails in some browsers or produces a subscription that rejects notifications. The urlBase64ToUint8Array helper function shown in the code examples above converts the URL-safe Base64 key (from webpush.generateVAPIDKeys()) to the Uint8Array format that pushManager.subscribe() expects. This conversion is required and not optional — omitting it is one of the top causes of "notifications work in Chrome but not Firefox" or "subscriptions save but notifications never deliver" debugging sessions.
See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.