Skip to main content

web-push vs OneSignal vs firebase-admin: Push Notifications in Node.js (2026)

·PkgPulse Team

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

Featureweb-pushfirebase-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)
CostFreeFree (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.

Compare notification and messaging packages on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.