Sitemap

Building Real-Time Push Notifications: A Complete Guide to Integrating Firebase with PocketBase and Express.js

10 min readJun 17, 2025

--

How I built a scalable notification system that handles thousands of daily push notifications using PocketBase v0.27.2, Express.js, and Firebase Admin SDK v13

Press enter or click to view image in full size
Firebase push notification (pocketbase + express+ vercel+ firebase api v1)

Introduction: Why This Guide Matters

When I started building an e-commerce app that needed to notify users about order updates in real-time, I quickly realized that setting up push notifications is more complex than it initially appears. After weeks of trial and error, I’ve created a robust system that handles thousands of notifications daily.

In this comprehensive guide, I’ll walk you through exactly how I built a production-ready push notification system using:

  • PocketBase v0.27.2 as the backend database and API
  • Firebase Admin SDK v13 for push notifications
  • Express.js as a secure notification service
  • Vercel for reliable deployment

The Challenge: Why Standard Solutions Weren’t Enough

Most tutorials show you how to send a single push notification, but real applications need:

  • Automatic notifications triggered by database changes
  • Role-based notifications (customers, vendors, admins get different messages)
  • Secure API endpoints that can’t be abused
  • Batch processing for sending to multiple users
  • Error handling for failed notifications
  • Rate limiting to prevent spam

Let me show you how I solved each of these challenges.

Architecture Overview: The Big Picture

Before diving into code, here’s how all the pieces fit together:

[PocketBase Database] → [Hooks] → [Express Service] → [Firebase] → [User Devices]
  1. PocketBase stores data and triggers hooks on changes
  2. Hooks detect important events (like order status changes)
  3. Express Service securely processes notification requests
  4. Firebase delivers notifications to user devices

Part 1: Setting Up PocketBase Collections

First, let’s set up the essential collections. Here are the key structures you’ll need:

Device Token Collectionjavascript

// Collection: device_tokens
{
"user_id": "relation to users",
"fcm_token": "text - FCM token from device",
"device_type": "text - ios/android/web",
"last_active": "date - when token was last used"
}

Notification History Collection

// Collection: notification_log  
{
"title": "text",
"message": "text",
"notification_type": "select - order_update|promotion|system",
"target_user": "relation to users",
"read_status": "select - unread|read",
"created_date": "date"
}

Email Backup Collection (for fallback notifications)

// Collection: email_recipients
{
"email_address": "email",
"user_role": "select - customer|admin|vendor",
"is_active": "bool - whether to send notifications"
}

Part 2: Building the Express.js Firebase Service

Here’s where the magic happens. I created a secure Express service that handles all Firebase communication:

Setting Up the Express App

const express = require('express');
const { initializeApp, getApps, getApp } = require('firebase-admin/app');
const { getMessaging } = require('firebase-admin/messaging');
const { credential } = require('firebase-admin');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const crypto = require('crypto');

const app = express();
// Security headers
app.use(helmet({
crossOriginEmbedderPolicy: false
}));
// Rate limiting to prevent abuse
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: {
error: 'Too many requests from this IP, please try again later.',
code: 'RATE_LIMIT_EXCEEDED'
}
});
const notificationLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 10, // limit each IP to 10 notification requests per minute
message: {
error: 'Too many notification requests, please try again later.',
code: 'NOTIFICATION_RATE_LIMIT_EXCEEDED'
}
});
app.use(globalLimiter);

Firebase Admin SDK v13 Initialization

One of the trickiest parts was getting Firebase Admin SDK v13 working properly. Here’s the solution:

function initializeFirebaseV13() {
try {
// Check if already initialized
const existingApps = getApps();
if (existingApps.length > 0) {
console.log('✅ Firebase already initialized');
firebaseApp = getApp();
isFirebaseReady = true;
return true;
}

// Process private key (this was the tricky part!)
let privateKey = process.env.FIREBASE_PRIVATE_KEY;

// Handle different formats
if (privateKey.startsWith('"') && privateKey.endsWith('"')) {
privateKey = privateKey.slice(1, -1);
}

privateKey = privateKey
.replace(/\\n/g, '\n')
.replace(/\n\s+/g, '\n')
.trim();
// Create service account credential
const serviceAccount = {
type: "service_account",
project_id: process.env.FIREBASE_PROJECT_ID,
private_key_id: process.env.FIREBASE_PRIVATE_KEY_ID,
private_key: privateKey,
client_email: process.env.FIREBASE_CLIENT_EMAIL,
client_id: process.env.FIREBASE_CLIENT_ID,
auth_uri: "https://accounts.google.com/o/oauth2/auth",
token_uri: "https://oauth2.googleapis.com/token"
};
// Initialize Firebase Admin using v13 syntax
firebaseApp = initializeApp({
credential: credential.cert(serviceAccount),
projectId: process.env.FIREBASE_PROJECT_ID,
});
console.log('✅ Firebase Admin SDK v13 initialized successfully');
isFirebaseReady = true;
return true;
} catch (error) {
console.error('❌ Firebase v13 initialization failed:', error.message);
firebaseInitError = error.message;
isFirebaseReady = false;
return false;
}
}

Secure API Key Validation

Security is crucial. Here’s how I implemented API key validation:

const validateApiKey = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
const expectedApiKey = process.env.API_SECRET_KEY;

if (!apiKey) {
return res.status(401).json({
success: false,
error: 'API key required',
code: 'MISSING_API_KEY'
});
}

// Use timing-safe comparison to prevent timing attacks
const expectedBuffer = Buffer.from(expectedApiKey);
const providedBuffer = Buffer.from(apiKey);

if (expectedBuffer.length !== providedBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, providedBuffer)) {
return res.status(401).json({
success: false,
error: 'Invalid API key',
code: 'INVALID_API_KEY'
});
}

next();
};

The Core Notification Endpoint

Here’s the heart of the system — the endpoint that sends notifications:

app.post('/send-notification', 
validateApiKey,
notificationLimiter,
validateNotificationInput,
async (req, res) => {

if (!isFirebaseReady || !firebaseApp) {
return res.status(500).json({
success: false,
error: 'Firebase service unavailable',
code: 'FIREBASE_ERROR'
});
}

const { tokens, title, body: messageBody, data = {}, options = {} } = req.body;
const results = [];
let successCount = 0;
try {
const messaging = getMessaging(firebaseApp);

// Process tokens in batches (Firebase allows up to 500 per batch)
const batchSize = 500;

for (let i = 0; i < tokens.length; i += batchSize) {
const batchTokens = tokens.slice(i, i + batchSize);

const message = {
tokens: batchTokens,
notification: {
title: title,
body: messageBody
},
data: Object.keys(data).reduce((acc, key) => {
acc[key] = String(data[key]);
return acc;
}, {}),
android: {
priority: 'high',
notification: {
channelId: options.channelId || 'default',
sound: options.sound || 'default'
}
},
apns: {
payload: {
aps: {
alert: { title, body: messageBody },
sound: options.sound || 'default',
badge: options.badge || 1
}
}
}
};
// Use sendEachForMulticast for batch sending
const response = await messaging.sendEachForMulticast(message);
successCount += response.successCount;

// Track results for debugging
response.responses.forEach((result, index) => {
results.push({
token: batchTokens[index].substring(0, 20) + "...",
success: result.success,
messageId: result.messageId || null,
error: result.error ? {
code: result.error.code,
message: result.error.message
} : null
});
});
}
res.json({
success: true,
results: results,
totalSent: successCount,
totalFailed: tokens.length - successCount,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ Critical error in send-notification:', error);
res.status(500).json({
success: false,
error: 'Internal server error',
code: 'SEND_NOTIFICATION_ERROR'
});
}
});

Part 3: PocketBase Hooks — The Automation Engine

Now let’s set up PocketBase hooks that automatically trigger notifications. This is where PocketBase v0.27.2 really shines:

Database Change Hook

// pb_hooks/notification-triggers.pb.js
onRecordAfterUpdateSuccess((e) => {
const collectionName = e.record?.collection()?.name;

if (collectionName === "your_main_collection") {
try {
const notificationHelper = require(`${__hooks}/notification-helper.js`);

const originalRecord = e.record.original();
const originalStatus = originalRecord?.get("status");
const newStatus = e.record.get("status");

// Only process if status actually changed
if (originalStatus !== newStatus) {
console.log(`Status changed from ${originalStatus} to ${newStatus}`);

const userId = e.record.get("user_id");
const recordId = e.record.id;

// Create notification record
notificationHelper.createNotificationRecord(e.record, userId, newStatus);

// Send Firebase push notification
const tokens = notificationHelper.getUserTokens(userId);

if (tokens.length > 0) {
const title = "Status Update";
const body = notificationHelper.getUpdateMessage(newStatus, e.record);
const data = {
recordId: recordId,
status: newStatus,
type: "status_update",
timestamp: Date.now().toString()
};

const result = notificationHelper.sendPushNotification(tokens, title, body, data);
console.log("Firebase push notification result:", result);
}
}
} catch (error) {
console.error("Error in notification hook:", error);
}
}

e.next();
});

Notification Helper Module

I created a reusable module that handles all Firebase operations:

// pb_hooks/notification-helper.js
const FIREBASE_SERVICE_URL = "https://your-notification-service.vercel.app";
const API_SECRET_KEY = "your_secure_api_key_here";

function sendPushNotification(tokens, title, body, data = {}, options = {}) {
console.log(`🚀 Sending push notification to ${tokens.length} tokens`);

// Remove duplicate tokens
const uniqueTokens = [...new Set(tokens)];

try {
const response = $http.send({
url: `${FIREBASE_SERVICE_URL}/send-notification`,
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": API_SECRET_KEY
},
body: JSON.stringify({
tokens: uniqueTokens,
title: title,
body: body,
data: data,
options: options
}),
timeout: 30000
});
if (response.statusCode === 200) {
const result = JSON.parse(response.raw);
console.log(`✅ Successfully sent ${result.totalSent} notifications`);
return result;
} else {
console.error("❌ Service error:", response.statusCode);
return { success: false, error: `Service returned ${response.statusCode}` };
}
} catch (error) {
console.error("❌ Error calling Firebase service:", error.message);
return { success: false, error: error.message };
}
}
function getUserTokens(userId) {
try {
const tokenRecords = $app.findRecordsByFilter(
"device_tokens",
`user_id = "${userId}"`
);

const tokens = tokenRecords.map(record => record.get("fcm_token"));
return [...new Set(tokens)]; // Remove duplicates
} catch (error) {
console.error("Error getting user tokens:", error);
return [];
}
}
function createNotificationRecord(record, userId, newStatus) {
try {
const notificationData = {
title: "Status Update",
message: getUpdateMessage(newStatus, record),
notification_type: "status_update",
target_user: userId,
read_status: "unread"
};
const notificationsCollection = $app.findCollectionByNameOrId("notification_log");
const notificationRecord = new Record(notificationsCollection);

Object.keys(notificationData).forEach((key) => {
notificationRecord.set(key, notificationData[key]);
});
$app.save(notificationRecord);
console.log("✅ Notification record created");
} catch (error) {
console.error("Error creating notification record:", error);
}
}
function getUpdateMessage(status, record) {
const recordType = record.collection().name;
const recordId = record.id.substring(0, 8);

switch (status) {
case "pending":
return `Your ${recordType} #${recordId} is being processed.`;
case "in_progress":
return `Your ${recordType} #${recordId} is now in progress.`;
case "completed":
return `Your ${recordType} #${recordId} has been completed successfully.`;
case "cancelled":
return `Your ${recordType} #${recordId} has been cancelled.`;
default:
return `Your ${recordType} #${recordId} status has been updated to ${status}.`;
}
}
module.exports = {
sendPushNotification,
getUserTokens,
createNotificationRecord,
getUpdateMessage
};

Part 4: Security and Rate Limiting

Security was a major concern. Here’s how I handled it:

1. API Key Authentication

// Use a strong, randomly generated API key
const API_SECRET_KEY = "your_very_secure_random_string_here_minimum_32_chars";

// Always use timing-safe comparison
if (!crypto.timingSafeEqual(expectedBuffer, providedBuffer)) {
return res.status(401).json({ error: 'Invalid API key' });
}

2. Rate Limiting

// Global rate limit
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100 // limit each IP to 100 requests per windowMs
});

// Notification-specific rate limit
const notificationLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 10 // limit each IP to 10 notification requests per minute
});

3. Input Validation

const validateNotificationInput = (req, res, next) => {
const { tokens, title, body } = req.body;

// Validate tokens array
if (!tokens || !Array.isArray(tokens) || tokens.length === 0) {
return res.status(400).json({
success: false,
error: 'tokens must be a non-empty array'
});
}

// Limit batch size
if (tokens.length > 1000) {
return res.status(400).json({
success: false,
error: 'tokens array too large (max 1000)'
});
}

// Validate each token format
for (const token of tokens) {
if (typeof token !== 'string' || token.length < 10 || token.length > 200) {
return res.status(400).json({
success: false,
error: 'Invalid token format'
});
}
}

// Validate title and body
if (!title || typeof title !== 'string' || title.length > 100) {
return res.status(400).json({
success: false,
error: 'title must be a string (max 100 chars)'
});
}

if (!body || typeof body !== 'string' || body.length > 500) {
return res.status(400).json({
success: false,
error: 'body must be a string (max 500 chars)'
});
}

next();
};

Part 5: Deploying to Vercel

Deploying to Vercel was straightforward once I got the configuration right:

package.json

{
"name": "firebase-notification-service",
"version": "1.0.0",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"firebase-admin": "^13.4.0",
"helmet": "^7.1.0"
},
"engines": {
"node": ">=18.0.0"
}
}

vercel.json

{
"version": 2,
"rewrites": [
{ "source": "/(.*)", "destination": "/api" }
]
}

Environment Variables in Vercel

Set these in your Vercel dashboard:

FIREBASE_PROJECT_ID=your-project-id
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...your key...\n-----END PRIVATE KEY-----\n"
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project.iam.gserviceaccount.com
API_SECRET_KEY=your_super_secure_random_string_at_least_32_characters
ALLOWED_ORIGINS=https://your-pocketbase-domain.com

Pro tip: The private key format is crucial. Make sure it includes the \n characters and is wrapped in quotes.

Part 6: Client-Side Integration

Here’s how to register devices and handle notifications on the client side:

Registering FCM Tokens

// In your React/Vue/vanilla JS app
import { getMessaging, getToken } from 'firebase/messaging';
async function registerDeviceToken(userId) {
try {
const messaging = getMessaging();
const token = await getToken(messaging, {
vapidKey: 'your-vapid-key'
});

if (token) {
// Send token to PocketBase
await fetch('https://your-pocketbase-url/api/collections/device_tokens/records', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify({
user_id: userId,
fcm_token: token,
device_type: 'web',
last_active: new Date().toISOString()
})
});

console.log('Device token registered successfully');
}
} catch (error) {
console.error('Error registering device token:', error);
}
}

Handling Incoming Notifications

// firebase-messaging-sw.js (Service Worker)
importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.0.0/firebase-messaging-compat.js');

firebase.initializeApp({
apiKey: "your-api-key",
authDomain: "your-project.firebaseapp.com",
projectId: "your-project-id",
storageBucket: "your-project.appspot.com",
messagingSenderId: "123456789",
appId: "your-app-id"
});
const messaging = firebase.messaging();
messaging.onBackgroundMessage((payload) => {
console.log('Received background message:', payload);

const notificationTitle = payload.notification.title;
const notificationOptions = {
body: payload.notification.body,
icon: '/firebase-logo.png',
data: payload.data
};

self.registration.showNotification(notificationTitle, notificationOptions);
});

Part 7: Testing and Monitoring

Health Check Endpoint

// Add this to your Express app
app.get('/health', (req, res) => {
const apps = getApps();
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
firebase: isFirebaseReady,
firebaseApps: apps.length,
firebaseError: firebaseInitError,
environment: process.env.NODE_ENV || 'development'
});
});

Monitoring Script

// Simple monitoring script you can run periodically
async function checkNotificationService() {
try {
const response = await fetch('https://your-service.vercel.app/health');
const data = await response.json();

if (data.status === 'healthy' && data.firebase) {
console.log('✅ Notification service is healthy');
} else {
console.error('❌ Service issues detected:', data);
}
} catch (error) {
console.error('❌ Service is down:', error.message);
}
}

Common Pitfalls and Solutions

1. Firebase Private Key Format Issues

Problem: The private key environment variable is the most common source of errors.

Solution: Always wrap your private key in quotes and ensure \n characters are properly escaped:

FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQI...............\n-----END PRIVATE KEY-----\n"

2. Token Validation Failures

Problem: FCM tokens become invalid over time.

Solution: Implement token cleanup in your PocketBase hooks:

// Clean up invalid tokens
function cleanupInvalidTokens(invalidTokens) {
invalidTokens.forEach(token => {
try {
const tokenRecord = $app.findFirstRecordByFilter(
"device_tokens",
`fcm_token = "${token}"`
);
if (tokenRecord) {
$app.delete(tokenRecord);
}
} catch (error) {
console.error('Error cleaning up token:', error);
}
});
}

3. Rate Limiting Issues

Problem: Hitting rate limits during high traffic.

Solution: Implement exponential backoff and queue management:

// Simple retry mechanism with exponential backoff
async function sendWithRetry(tokens, title, body, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const result = await sendPushNotification(tokens, title, body);
if (result.success) return result;
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
}

Performance Optimization Tips

  1. Batch Processing: Always send notifications in batches of 500 (Firebase’s limit)
  2. Token Deduplication: Remove duplicate tokens before sending
  3. Async Processing: Use PocketBase hooks for async notification sending
  4. Caching: Cache frequently accessed user tokens
  5. Error Handling: Implement comprehensive error logging and recovery

Conclusion: Lessons Learned

Building this notification system taught me several valuable lessons:

  1. Security First: Always implement proper authentication and rate limiting
  2. Firebase SDK v13: The new version requires careful initialization but offers better performance
  3. PocketBase Hooks: Version 0.27.2 hooks are incredibly powerful for automation
  4. Vercel Deployment: Environment variable management is crucial for success
  5. Error Handling: Comprehensive logging makes debugging much easier

The system now handles thousands of notifications daily with 99.9% reliability. Users receive instant updates about their orders, and the admin team can monitor everything through detailed logs.

If this guide helped you build your notification system, please give it a clap and share it with other developers!

--

--

Xettri Aleen
Xettri Aleen

Written by Xettri Aleen

Programming | AI | Reverse Engineer | Designing -> contact: aleenbhandari2@gmail.com