Building Real-Time Push Notifications: A Complete Guide to Integrating Firebase with PocketBase and Express.js
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
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]- PocketBase stores data and triggers hooks on changes
- Hooks detect important events (like order status changes)
- Express Service securely processes notification requests
- 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.comPro 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
- Batch Processing: Always send notifications in batches of 500 (Firebase’s limit)
- Token Deduplication: Remove duplicate tokens before sending
- Async Processing: Use PocketBase hooks for async notification sending
- Caching: Cache frequently accessed user tokens
- Error Handling: Implement comprehensive error logging and recovery
Conclusion: Lessons Learned
Building this notification system taught me several valuable lessons:
- Security First: Always implement proper authentication and rate limiting
- Firebase SDK v13: The new version requires careful initialization but offers better performance
- PocketBase Hooks: Version 0.27.2 hooks are incredibly powerful for automation
- Vercel Deployment: Environment variable management is crucial for success
- 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!
