Skip to content

Instantly share code, notes, and snippets.

@jonahgeek
Created November 25, 2025 14:48
Show Gist options
  • Select an option

  • Save jonahgeek/faa21c261c446d0ef39e57029d9e78c7 to your computer and use it in GitHub Desktop.

Select an option

Save jonahgeek/faa21c261c446d0ef39e57029d9e78c7 to your computer and use it in GitHub Desktop.
A guide to push notifications using Firebase & OneSignal

Push notifications — full end-to-end guide

Seamless flow between Node.js API and React web + React Native (Android & iOS). Covers architecture, setup, server code, client code (web + mobile), token lifecycle, testing, best practices, and troubleshooting. Ready for production.


Quick architecture (high level)

  1. Client registers for push and sends device token to your API (/devices/register).
  2. API stores token (userId, platform, providerId) and optionally subscribes token to topics/segments.
  3. When you want to notify, your API enqueues or sends to push provider (FCM or OneSignal).
  4. Provider delivers to devices; client handles foreground/background messages and deep links.

We’ll cover two backend options:

  • FCM (firebase-admin) — full control, common choice.
  • OneSignal — managed provider, easier dashboard & segmentation (OneSignal still uses FCM for Android under the hood).

1 — Decide push provider

  • Use FCM if you want full control, single provider, no SaaS dependency.
  • Use OneSignal if you want a dashboard, campaigns, analytics, and faster setup.
  • You can support both (OneSignal for marketing, FCM for system messages).

This guide includes both server examples.


2 — Server (Node.js) — common pieces

Environment variables (example .env)

PORT=8000
MONGO_URI=mongodb://localhost:27017/notifications
REDIS_URL=redis://localhost:6379
API_TOKEN=supersecretapikey

# Firebase (service account JSON string or path)
FCM_CREDENTIALS_JSON='{"type":"service_account",... }'

# OneSignal
ONESIGNAL_APP_ID=your-onesignal-app-id
ONESIGNAL_API_KEY=your-onesignal-rest-api-key

Minimal device model (Mongo)

Store tokens and metadata so you can target devices:

// src/models/Device.js (mongoose)
const mongoose = require('mongoose');
const DeviceSchema = new mongoose.Schema({
  userId: { type: String, index: true },
  provider: { type: String, enum: ['fcm', 'onesignal'], required: true },
  token: { type: String, required: true, index: true },
  platform: { type: String, enum: ['web', 'android', 'ios'], required: true },
  createdAt: { type: Date, default: Date.now },
  meta: { type: Object }
});
DeviceSchema.index({ userId: 1, provider: 1 });
module.exports = mongoose.model('Device', DeviceSchema);

API endpoints (core)

POST /api/v1/devices/register        -> register device token
POST /api/v1/devices/unregister      -> remove token
POST /api/v1/notifications/send      -> send a notification (single/bulk)
POST /api/v1/notifications/preview   -> preview rendering

Implement auth (API_TOKEN / JWT) for these endpoints.


3 — Server: FCM provider (node)

Install

npm install firebase-admin axios

Initialize firebase-admin

// src/services/fcm.provider.js
const admin = require('firebase-admin');
const logger = require('../utils/logger');

function initFirebase() {
  if (!admin.apps.length) {
    const creds = JSON.parse(process.env.FCM_CREDENTIALS_JSON);
    admin.initializeApp({ credential: admin.credential.cert(creds) });
    logger.info('[FCM] Initialized');
  }
}

async function sendToDevice(deviceToken, title, body, data = {}) {
  initFirebase();
  const message = {
    token: deviceToken,
    notification: { title, body },
    data: Object.fromEntries(Object.entries(data).map(([k,v])=>[k, String(v)]))
  };
  const res = await admin.messaging().send(message);
  return res; // messageId
}

async function sendToMultiple(tokens, title, body, data = {}) {
  initFirebase();
  const message = {
    tokens,
    notification: { title, body },
    data: Object.fromEntries(Object.entries(data).map(([k,v])=>[k, String(v)]))
  };
  const res = await admin.messaging().sendMulticast(message);
  return res; // { successCount, failureCount, responses }
}

module.exports = { sendToDevice, sendToMultiple };

4 — Server: OneSignal provider (node)

Install

npm install axios

Provider module

// src/services/onesignal.provider.js
const axios = require('axios');
const logger = require('../utils/logger');
const ONESIGNAL_API = 'https://onesignal.com/api/v1/notifications';

async function sendToPlayerIds(playerIds, title, body, data = {}) {
  const payload = {
    app_id: process.env.ONESIGNAL_APP_ID,
    include_player_ids: Array.isArray(playerIds) ? playerIds : [playerIds],
    headings: { en: title },
    contents: { en: body },
    data
  };
  const res = await axios.post(ONESIGNAL_API, payload, {
    headers: { Authorization: `Basic ${process.env.ONESIGNAL_API_KEY}` }
  });
  return res.data;
}

module.exports = { sendToPlayerIds };

OneSignal identifies devices by player_id (their SDK returns it). For web and mobile, you’ll register these IDs to your API.


5 — Registering device tokens (API side)

Route: /api/v1/devices/register

Request body examples (web, RN):

Web (FCM token):

{
  "userId":"user_123",
  "provider":"fcm",
  "platform":"web",
  "token":"fcm_device_token_from_client",
  "meta": { "browser":"chrome" }
}

React Native (FCM):

{
  "userId":"user_123",
  "provider":"fcm",
  "platform":"android",
  "token":"fcm_device_token",
  "meta": { "appVersion":"1.2.3" }
}

OneSignal (player id):

{
  "userId":"user_123",
  "provider":"onesignal",
  "platform":"ios",
  "token":"onesignal_player_id",
  "meta": {}
}

Server handler (simplified):

// src/controllers/device.controller.js
const Device = require('../models/Device');

exports.register = async (req, res) => {
  const { userId, provider, platform, token, meta } = req.body;
  if (!userId || !provider || !token) return res.status(400).send({ error: 'invalid' });

  // upsert so we avoid duplicates
  await Device.findOneAndUpdate(
    { token },
    { userId, provider, platform, token, meta, updatedAt: new Date() },
    { upsert: true, new: true }
  );
  res.json({ success: true });
};

Also implement unregister (delete by token).


6 — React Web (Firebase messaging) — quick setup

Install (web)

npm install firebase

Add Firebase config to the web app (use project settings)

public/firebase-messaging-sw.js (service worker) — required to receive background messages:

importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.22.0/firebase-messaging-compat.js');

firebase.initializeApp({
  apiKey: 'XXX',
  authDomain: 'xxx',
  projectId: 'xxx',
  messagingSenderId: 'xxx',
  appId: 'xxx'
});

const messaging = firebase.messaging();

messaging.onBackgroundMessage(function(payload) {
  // Customize notification here
  const notificationTitle = payload.notification.title;
  const notificationOptions = { body: payload.notification.body, data: payload.data };
  self.registration.showNotification(notificationTitle, notificationOptions);
});

Make sure this file is served at /firebase-messaging-sw.js.

In your React app

// src/push/firebaseWeb.js
import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';

const firebaseConfig = { /* from console */ };
const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

export async function requestPermissionAndRegister(userId) {
  try {
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') throw new Error('permission denied');

    const vapidKey = 'YOUR_WEB_VAPID_KEY'; // set in Firebase console
    const token = await getToken(messaging, { vapidKey });

    // send token to your API
    await fetch('/api/v1/devices/register', {
      method: 'POST',
      headers: { 'Content-Type':'application/json','Authorization':'Bearer ...' },
      body: JSON.stringify({ userId, provider:'fcm', platform:'web', token })
    });
    return token;
  } catch (err) {
    console.error('push register failed', err);
  }
}

// foreground handler
export function initForegroundHandler(onMessageCallback) {
  onMessage(messaging, (payload) => {
    onMessageCallback(payload);
  });
}

Web caveats

  • Use VAPID key configured in Firebase console.
  • Service worker must be at root or same scope as page.
  • Browsers show push only if user granted permission.

7 — React Native (Android/iOS) — using @react-native-firebase/messaging

Install (RN CLI)

yarn add @react-native-firebase/app @react-native-firebase/messaging

Follow native setup docs (Android: add google-services.json, iOS: add GoogleService-Info.plist and enable push capabilities).

Request permission & get token (RN)

import messaging from '@react-native-firebase/messaging';

export async function registerForPush(userId) {
  const authStatus = await messaging().requestPermission();
  const enabled = authStatus === messaging.AuthorizationStatus.AUTHORIZED || authStatus === messaging.AuthorizationStatus.PROVISIONAL;
  if (!enabled) throw new Error('no-permission');

  const token = await messaging().getToken();
  // send to your API
  await fetch('/api/v1/devices/register', {
    method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer ...'},
    body: JSON.stringify({ userId, provider:'fcm', platform: Platform.OS, token })
  });
  return token;
}

// foreground event
messaging().onMessage(async remoteMessage => {
  // show in-app UI or local notification
});

// background & quit handling: use Headless JS or native handlers (see docs)

iOS notes

  • For iOS you must set up APNs or use Firebase to set up APNs credentials in Firebase console (for FCM to forward to APNs).
  • For local display in foreground, use react-native-push-notification or notifee to display local notifications, because iOS doesn’t show notifications while app is in foreground automatically.

8 — OneSignal client integration (Web & React Native)

OneSignal (Web)

  • Add OneSignal SDK to your web page and initialize with appId. It handles subscription and returns player_id.
  • After initialization, call OneSignal.getUserId() to get player_id and register it to your API.

OneSignal (React Native)

yarn add react-native-onesignal

Follow native setup docs. After SDK init:

import OneSignal from 'react-native-onesignal';
OneSignal.setAppId('ONESIGNAL_APP_ID');

OneSignal.setNotificationOpenedHandler((openedEvent) => {
  // handle open
});

OneSignal.getDeviceState().then(state => {
  const playerId = state.userId;
  // register to your API: provider: 'onesignal', token: playerId
});

OneSignal SDK handles permission prompts and token refresh.


9 — Sending notifications from your Node API

Minimal send endpoint (uses Device table)

// src/controllers/notify.controller.js
const Device = require('../models/Device');
const fcm = require('../services/fcm.provider');
const onesignal = require('../services/onesignal.provider');

exports.send = async (req, res) => {
  const { userId, title, body, data } = req.body;

  // find devices for user
  const devices = await Device.find({ userId });

  // group by provider
  const fcmTokens = devices.filter(d => d.provider === 'fcm').map(d => d.token);
  const onesignalIds = devices.filter(d => d.provider === 'onesignal').map(d => d.token);

  const results = {};
  if (fcmTokens.length) {
    // FCM allows multicast
    const resp = await fcm.sendToMultiple(fcmTokens, title, body, data);
    results.fcm = resp;
  }
  if (onesignalIds.length) {
    const resp = await onesignal.sendToPlayerIds(onesignalIds, title, body, data);
    results.onesignal = resp;
  }
  res.json({ success:true, results });
};

Important: In production you must not block on external provider calls — enqueue jobs (BullMQ/SQS) and process with workers.


10 — Client handling: foreground vs background

Web

  • Background — Service worker onBackgroundMessage triggers and you show notifications via self.registration.showNotification.
  • ForegroundonMessage callback triggers; show in-app toast or call new Notification(...).

React Native

  • Foregroundmessaging().onMessage fires. Use local notification library to show persistent notification.
  • Background / Quitmessaging().setBackgroundMessageHandler or native handlers run and system displays notification automatically if FCM payload contains notification key.

Payload differences:

  • For background display by FCM, include notification key. For data-only, handle in app code.

11 — Deep links & navigation

Include deep link data in payload data: { screen: 'chat', chatId: '123' }. Clients read data when opening the notification and navigate accordingly.

  • Web: data.url and clients.openWindow(data.url) in service worker.
  • RN: on notification open, call navigation navigation.navigate(screen, params).

12 — Token refresh & housekeeping

  • FCM tokens change periodically. Clients should send tokens to /devices/register on every app launch (or on onTokenRefresh).
  • Remove stale tokens: when send fails with "NotRegistered" or "InvalidRegistration", remove from DB.
  • Monitor provider responses and update Device collection accordingly.

13 — Testing & debugging

Tools

  • Firebase Console → Send test messages to tokens.
  • OneSignal Dashboard → Send test notifications & view delivery logs.
  • adb logcat (Android) and Xcode console (iOS) for device logs.
  • Browser DevTools Application > Notifications for web.

Test flow

  1. Register device from client (web or RN) → confirm DB entry.
  2. Use your /send endpoint to target the user.
  3. Check provider response; if failures, inspect error codes and remove tokens if necessary.

14 — Security & best practices

  • Protect device registration endpoints (authenticated users).
  • Validate token format and prevent duplicates.
  • Rate-limit sends per user and per tenant to avoid spam.
  • Store provider secrets in secrets manager (not git). Use KMS/Secrets Manager or CI secrets.
  • Use idempotency keys for bulk sends to prevent duplicates.
  • Queue all sends; avoid synchronous provider calls on HTTP request path.

15 — Scaling and reliability

  • Use a job queue (BullMQ, SQS) with scaled workers for fan-out and retries.
  • Batch sends when provider supports it (FCM multicast).
  • Implement exponential backoff and DLQ.
  • Track metrics: sent, delivered, failed, token invalidations.

16 — Example payloads

FCM data+notification payload (for background system display):

{
  "notification": { "title": "New message", "body": "You have a new message" },
  "data": { "screen":"inbox", "conversationId":"abc123" }
}

FCM data-only payload (handled by app in foreground/background):

{ "data": { "type":"sync", "payload":"..." } }

OneSignal payload (server):

{
  "app_id":"ONESIGNAL_APP_ID",
  "include_player_ids":["playerid1"],
  "headings":{"en":"Hello"},
  "contents":{"en":"World"},
  "data":{"screen":"home"}
}

17 — Troubleshooting common issues

  • No notification on iOS: Check APNs setup in Firebase, request permissions, ensure token registration, check that provider uses APNs for iOS (FCM forwards to APNs).
  • No background messages on web: Ensure firebase-messaging-sw.js present at site root, VAPID key set, service worker scope correct.
  • Token not updating: Listen to onTokenRefresh (web/ RN) and re-register.
  • High failure rate: Inspect provider responses, remove invalid tokens, throttle sends.

18 — Example quickcheck list before going live

  • Tokens are stored and de-duped.
  • Background service worker registered and working (web).
  • RN has correct native setup and notification permissions requested.
  • Server uses queue for sending, not direct blocking calls.
  • You have retry and DLQ for failed sends.
  • Monitoring/alerting for error spikes (Sentry/Prometheus/Grafana).

Appendix — Useful client snippets

Web: show local notification (foreground)

if (Notification.permission === 'granted') {
  new Notification(title, { body, data });
}

RN: display local notification in foreground (example with notifee)

import notifee from '@notifee/react-native';
await notifee.displayNotification({
  title, body, data
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment