Firebase Cloud Messaging (FCM)
Firebase Cloud Messaging (FCM) is Google's cross-platform messaging solution. BookWish uses FCM to send push notifications to mobile apps (iOS and Android).
Overview
BookWish uses Firebase for:
- Push Notifications: Send notifications to user devices
- Cross-Platform: Unified API for iOS, Android, and web
- Token Management: Register and manage device tokens
- Batch Messaging: Send to multiple devices simultaneously
- Badge Counts: Update app badge numbers (iOS)
- Rich Notifications: Titles, bodies, and custom data
Implementation
Backend Location: /backend/src/integrations/firebase.ts
Service Location: /backend/src/services/push.service.ts
Configuration
Required environment variables:
FIREBASE_PROJECT_ID=your-project-id
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..."
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@your-project.iam.gserviceaccount.com
The backend uses Firebase Admin SDK with service account credentials.
Features
1. Send to Single Device
Send a notification to a specific device token.
Send Notification
import { sendToDevice } from '../integrations/firebase';
await sendToDevice('device_token_...', {
notification: {
title: 'New Wishlist Match!',
body: 'The Great Gatsby is now available at Main Street Books'
},
data: {
type: 'wishlist_match',
bookId: 'book_123',
storeId: 'store_456'
},
apns: {
payload: {
aps: {
badge: 5 // iOS badge count
}
}
}
});
Notification Payload
interface FirebasePayload {
notification: {
title: string; // Notification title
body: string; // Notification body
};
data?: Record<string, string>; // Custom key-value pairs
apns?: {
payload: {
aps: {
badge?: number; // iOS badge count
}
}
};
}
2. Send to Multiple Devices (Batch)
Send the same notification to multiple device tokens.
Batch Send
import { sendToDevices } from '../integrations/firebase';
const tokens = [
'device_token_1',
'device_token_2',
'device_token_3',
// ... more tokens
];
const result = await sendToDevices(tokens, {
notification: {
title: 'Daily Reading Challenge',
body: 'Complete your reading challenge today!'
},
data: {
type: 'challenge_reminder',
challengeId: 'challenge_789'
}
});
console.log('Success:', result.successCount);
console.log('Failures:', result.failureCount);
console.log('Invalid tokens:', result.invalidTokens);
3. Handle Invalid Tokens
FCM automatically detects invalid or expired tokens.
Invalid Token Handling
try {
await sendToDevice(token, payload);
} catch (error) {
if (error instanceof InvalidTokenError) {
// Token is invalid or unregistered
// Remove from database
await unregisterToken(error.token);
}
}
The integration automatically collects invalid tokens in batch sends:
const result = await sendToDevices(tokens, payload);
if (result.invalidTokens.length > 0) {
// These tokens should be removed from database
await removeInvalidTokens(result.invalidTokens);
}
Push Service Layer
The push service (/backend/src/services/push.service.ts) provides higher-level functions.
Register Token
Register a device token for a user:
import { registerToken } from '../services/push.service';
await registerToken(
'user_123',
'device_token_...',
'ios' // 'ios' | 'android' | 'web'
);
Send to User
Send notification to all devices registered for a user:
import { sendPush } from '../services/push.service';
await sendPush('user_123', {
title: 'Order Shipped',
body: 'Your order #1234 has been shipped!',
data: {
type: 'order_update',
orderId: 'order_1234'
},
badge: 3
});
The service automatically:
- Fetches all tokens for the user
- Sends to all devices
- Removes invalid tokens
Send to Multiple Users
Send notification to multiple users:
import { sendPushBatch } from '../services/push.service';
const userIds = ['user_1', 'user_2', 'user_3'];
await sendPushBatch(userIds, {
title: 'New Feature Available',
body: 'Check out the new reading challenges!'
});
Unregister Token
Remove a device token (e.g., on logout):
import { unregisterToken } from '../services/push.service';
await unregisterToken('device_token_...');
Remove All User Tokens
Remove all tokens for a user (e.g., account deletion):
import { removeAllUserTokens } from '../services/push.service';
await removeAllUserTokens('user_123');
Database Schema
PushToken Table
CREATE TABLE push_tokens (
token VARCHAR PRIMARY KEY, -- Device token
user_id UUID NOT NULL, -- User who owns the device
platform VARCHAR NOT NULL, -- 'ios', 'android', 'web'
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE INDEX idx_push_tokens_user_id ON push_tokens(user_id);
Mobile Client Setup
iOS Setup
File: /app/ios/Runner/GoogleService-Info.plist
- Download
GoogleService-Info.plistfrom Firebase Console - Add to Xcode project
- Configure FCM in AppDelegate
import Firebase
import FirebaseMessaging
@main
class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Initialize Firebase
FirebaseApp.configure()
// Request notification permissions
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
if granted {
DispatchQueue.main.async {
application.registerForRemoteNotifications()
}
}
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// Handle token registration
override func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
}
Android Setup
File: /app/android/app/google-services.json
- Download
google-services.jsonfrom Firebase Console - Place in
android/app/directory - Firebase auto-initializes on Android
// No additional setup needed for basic FCM
// Token handling in Flutter code
Flutter Integration
Get FCM token in Flutter app:
import 'package:firebase_messaging/firebase_messaging.dart';
Future<void> setupPushNotifications() async {
final messaging = FirebaseMessaging.instance;
// Request permissions (iOS)
await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
// Get FCM token
final token = await messaging.getToken();
if (token != null) {
// Register with backend
await api.registerPushToken(token, Platform.isIOS ? 'ios' : 'android');
}
// Listen for token refresh
messaging.onTokenRefresh.listen((newToken) {
api.registerPushToken(newToken, Platform.isIOS ? 'ios' : 'android');
});
// Handle foreground messages
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
// Show in-app notification
});
// Handle background/terminated messages
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
// Navigate to relevant screen
});
}
Notification Types
Common notification types in BookWish:
Wishlist Matches
{
title: 'Wishlist Match Found!',
body: '{book_title} is now available',
data: {
type: 'wishlist_match',
bookId: '...',
storeId: '...'
}
}
Order Updates
{
title: 'Order Update',
body: 'Your order has shipped',
data: {
type: 'order_shipped',
orderId: '...'
}
}
Social Interactions
{
title: 'New Follower',
body: '{username} started following you',
data: {
type: 'new_follower',
userId: '...'
}
}
Reading Challenges
{
title: 'Challenge Update',
body: 'Complete your reading goal today!',
data: {
type: 'challenge_reminder',
challengeId: '...'
}
}
Error Handling
Common Errors
// Invalid token
if (error.code === 'messaging/invalid-registration-token') {
// Token is invalid, remove from database
}
// Token not registered
if (error.code === 'messaging/registration-token-not-registered') {
// Device uninstalled app or token expired
}
// Message too large
if (error.code === 'messaging/invalid-payload') {
// Payload exceeds 4KB limit
}
Error Codes
messaging/invalid-registration-token: Invalid token formatmessaging/registration-token-not-registered: Token expired/uninstalledmessaging/invalid-payload: Message exceeds size limitsmessaging/server-unavailable: FCM service temporarily downmessaging/internal-error: Internal FCM error
Best Practices
- Token Lifecycle: Update tokens when they refresh
- Graceful Degradation: Handle FCM unavailability gracefully
- Invalid Token Cleanup: Remove invalid tokens promptly
- Payload Size: Keep messages under 4KB
- Data-Only Messages: Use data messages for silent updates
- Badge Management: Always update badge counts on iOS
- Testing: Test notifications on both iOS and Android
- Personalization: Include user-specific data in messages
Payload Limits
FCM enforces payload size limits:
- Maximum payload size: 4KB (4096 bytes)
- Notification fields count against limit
- Data fields count against limit
- Keep messages concise
Testing
Test Notifications
Send test notification from Firebase Console:
- Go to Cloud Messaging
- Click "Send test message"
- Enter device token
- Send
Test with FCM API
# Send test notification via curl
curl -X POST https://fcm.googleapis.com/v1/projects/YOUR_PROJECT/messages:send \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"message": {
"token": "DEVICE_TOKEN",
"notification": {
"title": "Test",
"body": "This is a test notification"
}
}
}'
Debug Logging
Enable Firebase debug logging:
// Enable verbose logging
logger.level = 'debug';
// Logs include:
// - firebase.push_sent
// - firebase.batch_sent
// - firebase.push_failed
Rate Limits
FCM has generous rate limits:
- Fanout: 1,000,000 messages per second
- Burst: 10,000 messages per second to single device
- No daily quota
iOS-Specific Considerations
APNs Configuration
FCM uses Apple Push Notification service (APNs) for iOS. Configure in Firebase Console:
- Upload APNs authentication key or certificate
- Configure team ID and bundle ID
- Enable production/development environments
Badge Management
Update badge count with every notification:
apns: {
payload: {
aps: {
badge: badgeCount // New badge number
}
}
}
Silent Notifications
Send silent notifications (no alert):
{
data: {
type: 'silent_sync',
action: 'refresh_feed'
},
apns: {
headers: {
'apns-priority': '5' // Low priority
},
payload: {
aps: {
contentAvailable: true
}
}
}
}
Android-Specific Considerations
Notification Channels
Android requires notification channels (handled by Flutter plugin automatically):
const AndroidNotificationChannel channel = AndroidNotificationChannel(
'bookwish_notifications',
'BookWish Notifications',
description: 'Notifications from BookWish',
importance: Importance.high,
);
Data-Only Messages
Android handles data-only messages in background:
{
data: {
type: 'sync',
action: 'update_inventory'
}
// No notification field = silent
}
Monitoring
Track notification metrics:
// Log successful sends
logger.info('push.sent', {
userId,
notificationType: payload.data?.type,
deviceCount: tokens.length
});
// Log failures
logger.error('push.failed', {
userId,
error: error.message,
invalidTokenCount: result.invalidTokens.length
});
Important metrics:
- Success rate
- Invalid token rate
- Delivery latency
- Error types