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.
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.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.
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:
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
firebase-admin 14.4.0 declares engines.node >= 22, which trips up upgrades of older services.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.
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
npx web-push generate-vapid-keys and keep the private key secret.push events and calls showNotification.registration.pushManager.subscribe() and sent to your backend.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.
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:
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.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.
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.
firebase-admin | web-push | Notification API | |
|---|---|---|---|
| Package | firebase-admin 14.4.0 | web-push 3.6.7 | @trycourier/courier 9.5.0 |
| Node floor | 22+ | 18+ | 20+ |
| Reaches mobile apps | yes, iOS and Android | no | yes |
| Reaches browsers | no | yes | yes, via OneSignal |
| Recipient shape | device token | subscription object | user_id |
| Who stores tokens | you | you | platform, via mobile SDKs |
| Stale-recipient cleanup | you, per token | you, on 404 and 410 | handled |
| Per-platform payloads | android / apns blocks | your service worker | providers overrides |
| Fan-out across devices | you loop or multicast | you loop | one call |
| Other channels | none | none | email, SMS, in-app, chat |
| Preferences and opt-outs | you build it | you build it | built in |
| Best for | mobile-only apps | browser-only sites | both, 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.
FAQ
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.fcm.googleapis.com/fcm/send endpoint with a server key no longer deliver. The replacement is the FCM HTTP v1 API, which authenticates with a service account rather than a server key. The firebase-admin SDK uses v1 for you, which is the main reason to use it instead of hand-rolling HTTP.engines.node >= 22, so older Node releases will warn or fail to install. This catches people out when upgrading an existing service, because the SDK's Node floor has moved several times. If you are pinned to an older runtime, either stay on an older firebase-admin major or call the FCM HTTP v1 API directly.user_id and never stores raw tokens.firebase-admin alone, where you send per token and handle the fan-out yourself. With a notification API you send to a user_id with routing.channels: ["firebase-fcm", "apn"] and method: "all", and it delivers to every device token synced for that user. The provider keys are firebase-fcm, apn, onesignal, and expo, not a generic push.Keep exploring
One API, every channel
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.