web-push vs OneSignal vs firebase-admin: Push Notifications in Node.js (2026)
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
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.