3 ways to send push notifications with Node.js

Send push from Node.js three ways: FCM for mobile, VAPID web push for browsers, or one notification API for both. Working code, plus how to pick the right one.

Updated Sep 11, 2026

Last updated: September 2026. Package versions verified against npm on 2026-09-11.

Push notifications reach users when they are not in your app, which makes them the most direct channel you have and the easiest to overuse. Node.js cannot deliver one on its own: every path here ends with handing your message to Apple, Google, or a browser's push service.

There are three ways to do that, and they differ mostly in how much of the fan-out you own.

Which option should you pick?

  • firebase-admin if you need mobile push and nothing else. Around ten lines to a delivered notification, and you manage device tokens.
  • web-push if you need browser push. A separate protocol from mobile, with its own keys and its own subscription objects, so it is a second integration rather than a variation on the first.
  • A notification API if you need both, or if push is one channel among several. This is the point at which owning token lifecycle, per-platform payload shapes, and two unrelated integrations stops being worth it.

The honest summary is that mobile push and web push have almost nothing in common technically. If you need both, you are writing two integrations and a fan-out layer, which is the argument for option three.

How push notifications work

A push notification is a message your server sends to a device through an intermediary the device already trusts. On iOS that is APNs, on Android it is FCM, and in a browser it is a push service run by the browser vendor. Your server never talks to the device.

Three consequences follow, and they shape every implementation:

  • The user must opt in, and on browsers a meaningful share will decline. Permission handling is not a detail you add later.
  • You address a token, not a person. The intermediary gives your app an opaque token per install, and you store it. Tokens rotate, expire on reinstall, and go stale, so a token store needs pruning or your error rate climbs quietly.
  • Delivery is best-effort. If a device is offline the intermediary holds the message briefly and then drops it. There is no guaranteed delivery and no read receipt.

1. Send mobile push with Firebase

Firebase Cloud Messaging delivers to Android directly and to iOS by forwarding to APNs, so one integration covers both platforms.

Read this before you copy an older tutorial. Google retired the legacy FCM HTTP and XMPP APIs in mid-2024. Any example posting to https://fcm.googleapis.com/fcm/send with an Authorization: key=YOUR_SERVER_KEY header no longer works. The current API is FCM HTTP v1, which authenticates with a service account. The firebase-admin SDK speaks v1, which is the main reason to use it rather than writing the HTTP call yourself.

What you need first

  • A Firebase project, and a service account JSON key from Project settings > Service accounts.
  • For iOS delivery, your APNs auth key uploaded to Firebase.
  • Node.js 22 or newer. firebase-admin 14.4.0 declares engines.node >= 22, which trips up upgrades of older services.
  • A device token collected by your client app.

Install

npm install firebase-admin

Verified against firebase-admin 14.4.0.

Send the notification

import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
initializeApp({
credential: cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT)),
});
const messageId = await getMessaging().send({
token: deviceToken,
notification: {
title: 'Your order shipped',
body: 'Track it anytime in the app.',
},
android: {
notification: { clickAction: 'OPEN_ORDER' },
},
apns: {
payload: { aps: { sound: 'default', badge: 1 } },
},
});
console.log(messageId);

Note the subpath imports (firebase-admin/app, firebase-admin/messaging) rather than a single default import. Reading the service account from an environment variable rather than a file path is what makes this deployable to a container or serverless runtime.

The android and apns blocks exist because the two platforms take genuinely different payloads. Anything platform-specific (sounds, badges, tap actions, channel IDs) goes in its own block, and this is where most of the real work lives once you get past the first send.

Sending to many devices

send() takes one token. For a user with several devices you either loop or use sendEachForMulticast() with up to 500 tokens, then walk the per-token responses to find failures:

const response = await getMessaging().sendEachForMulticast({
tokens: userDeviceTokens,
notification: { title: 'Your order shipped', body: 'Track it in the app.' },
});
response.responses.forEach((r, i) => {
if (!r.success && r.error?.code === 'messaging/registration-token-not-registered') {
// The token is dead. Delete it, or your failure rate climbs forever.
removeToken(userDeviceTokens[i]);
}
});

That pruning step is not optional. Skipping it is the most common reason a push integration slowly degrades.

Where it gets thin: mobile only, no browser support, and you own the token store, the fan-out, the pruning, and the per-platform payload differences. Nothing here helps with user preferences or a second channel.

2. Send web push with VAPID

Browser push uses the Web Push protocol, which is a different standard from FCM with different keys and a different recipient shape. Chrome, Firefox, Edge, and Safari all support it.

Instead of a token you get a subscription object containing an endpoint URL and two encryption keys. You store the whole object, and payloads are encrypted so the browser vendor's push service cannot read them.

What you need first

  • A VAPID key pair. Generate one once with npx web-push generate-vapid-keys and keep the private key secret.
  • A service worker on your site that listens for push events and calls showNotification.
  • A subscription object obtained in the browser via registration.pushManager.subscribe() and sent to your backend.
  • Node.js 18 or newer.

Install

npm install web-push

Verified against web-push 3.6.7. That release is from January 2024, which reflects a stable protocol rather than an abandoned package.

Send the notification

import webpush from 'web-push';
webpush.setVapidDetails(
'mailto:you@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// The subscription object your browser code sent to the backend.
const subscription = await getSubscriptionForUser('user_123');
try {
await webpush.sendNotification(
subscription,
JSON.stringify({ title: 'Your order shipped', body: 'Track it in the app.' })
);
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 410) {
// The subscription is gone. Delete it.
await removeSubscription(subscription);
} else {
throw err;
}
}

The 404 and 410 handling is the web-push equivalent of token pruning, and it matters more here because browser subscriptions expire routinely.

Note that the payload is an opaque string as far as web-push is concerned. Your service worker parses it and decides what to display, so the notification's shape is defined in your front-end code rather than in this call.

Where it gets thin: browsers only, no mobile app support, and permission rates are markedly lower than on mobile. Safari support in particular has its own requirements, so test it separately rather than assuming Chrome behaviour carries over.

3. Send push with a notification API

Options one and two are two separate integrations with two token stores, two payload formats, and two sets of pruning logic. If you need both, you are also writing a layer that decides which of a user's devices to target. That layer is what a notification API such as Courier replaces.

You send to a user, and it resolves the devices, renders the content per platform, and records what happened.

What the extra layer gives you:

  • One call reaching every device a user has, across FCM, APNs, and Expo.
  • Token sync handled by the mobile SDKs, so your backend never stores tokens.
  • Templates edited outside your codebase, so copy changes are not deploys.
  • Per-user, per-channel preferences enforced before a send.
  • Journeys for multi-step sequences, with digest and throttling nodes so twenty events do not become twenty banners.
  • Fallback to email or SMS when push is not delivered.
  • One log across every channel.

Set it up

Create a free account: the Developer plan includes 10,000 messages a month, and past that it's $0.005 per message, the same rate on every channel. Then configure a push provider on the Integrations page: Firebase FCM for Android, APNs for iOS, OneSignal for browser push, or Expo for React Native. These are the same Firebase and APNs credentials from section 1, so you are not replacing your provider.

Sync device tokens

Push goes to devices, so tokens still have to reach the platform, but the client SDK does it. Use the React Native, iOS, or Android SDK. Each signs the user in with a JWT your backend mints, then keeps FCM and APNs tokens current for that user_id. Your backend addresses the user and never sees a token.

Install

npm install @trycourier/courier

Verified against @trycourier/courier 9.5.0, which requires Node.js 20 or newer.

Send the notification

import Courier from '@trycourier/courier';
// Reads COURIER_API_KEY from the environment.
const client = new Courier();
const { requestId } = await client.send.message({
message: {
to: { user_id: 'user_123' },
content: {
title: 'Your order shipped',
body: 'Track it anytime in the app.',
},
routing: { method: 'all', channels: ['firebase-fcm', 'apn'] },
providers: {
'firebase-fcm': {
override: { body: { data: { click_action: 'https://example.com/orders' } } },
},
apn: {
override: { body: { aps: { sound: 'default', badge: 1 } } },
},
},
},
});
console.log(requestId);

Three things worth knowing:

  • routing.channels takes provider keys, not a generic "push". The valid ones are firebase-fcm, apn, onesignal, and expo. Getting this wrong is the most common first-send failure.
  • method: "all" sends every listed channel. "single" tries them in order and stops at the first success, which is how you get failover.
  • Provider override blocks are where per-platform payloads go, the same split you saw in the firebase-admin example.

For browser push, route the onesignal channel and store either oneSignalExternalUserId or oneSignalPlayerID on the user's profile so Courier can target the subscription:

await client.send.message({
message: {
to: { user_id: 'user_123' },
content: { title: 'New reply', body: 'You have a new message.' },
routing: { method: 'all', channels: ['onesignal'] },
},
});

To send a template instead of inline content, replace content with template: 'YOUR_TEMPLATE_ID' and add a data object for your variables. Note that provider override fields such as sounds and badges are not compatible with a template on the same send.

If what you actually want is a notification feed inside your web app rather than a browser banner, Inbox renders one from the same send.

Where it gets thin: one more service in the path, and a concept (users and profiles) to learn before your first send. If you only ever need Android push, firebase-admin on its own is less machinery.

Building this with an AI agent

If you are writing this in Cursor, Claude Code, or Codex, connect the agent to Courier directly instead of pasting snippets. It then works from the current SDK shapes rather than the 2022-era API that most training data still contains, which is the difference between Node.js that compiles and Node.js that does not.

The MCP server gives the agent typed tool calls. In Claude Code:

claude mcp add --transport http courier https://mcp.courier.com --header api_key:YOUR_API_KEY

Courier Skills adds the practices that are easy to get wrong, such as never batching a one-time passcode:

npx skills add trycourier/courier-skills

Both sit alongside the Courier CLI for scripting and CI. courier.com/agents collects the setup for Cursor, Claude Code, Codex, and VS Code in one place, and Build with AI has the machine-readable docs index an agent can fetch on its own.

Then ask in plain language: "push a delivery update to every device a user has, and fall back to email." The agent picks the right primitive, and knows which channels need a recorded opt-in.

This matters for the same reason this guide version-stamps every snippet. An agent working from stale training data will confidently write a send call that no longer exists.

Comparing the three options

firebase-adminweb-pushNotification API
Packagefirebase-admin 14.4.0web-push 3.6.7@trycourier/courier 9.5.0
Node floor22+18+20+
Reaches mobile appsyes, iOS and Androidnoyes
Reaches browsersnoyesyes, via OneSignal
Recipient shapedevice tokensubscription objectuser_id
Who stores tokensyouyouplatform, via mobile SDKs
Stale-recipient cleanupyou, per tokenyou, on 404 and 410handled
Per-platform payloadsandroid / apns blocksyour service workerproviders overrides
Fan-out across devicesyou loop or multicastyou loopone call
Other channelsnonenoneemail, SMS, in-app, chat
Preferences and opt-outsyou build ityou build itbuilt in
Best formobile-only appsbrowser-only sitesboth, or push plus other channels

If you need one platform, use its library directly and skip the rest of this page. The moment you need mobile and browser, you are maintaining two integrations that share nothing, and the notification layer stops being overhead and starts being the thing that saves you a fan-out service.

Next steps


FAQ

Frequently asked questions

For mobile, install firebase-admin, initialize it with a service account, and call getMessaging().send({ token, notification }) with a device token you collected in your app. For browsers, install web-push, set VAPID keys, and call webpush.sendNotification(subscription, payload). Node cannot reach a device directly in either case: you always hand the message to Apple, Google, or a browser push service.

One API, every channel

Ship notifications without the boilerplate

Courier gives you one API for email, SMS, push, and chat, with templates, routing, retries, and delivery logs built in.

Last updated Sep 11, 2026. Code samples are illustrative; provider APIs and pricing change over time, so check each provider’s docs before relying on them.