Push notifications are the most powerful re-engagement tool in mobile development -- and also the most abused. A well-timed, relevant notification brings users back to your app and drives meaningful actions. A poorly executed notification strategy drives users to the settings screen to disable notifications entirely, or worse, to uninstall your app. Industry data consistently shows that apps sending between two and five notifications per week see the highest retention rates, while apps that send more than one notification per day see opt-out rates above 50 percent.
The technical infrastructure for push notifications has matured significantly. Firebase Cloud Messaging (FCM) provides a unified layer for both Android and iOS, Apple Push Notification service (APNs) continues to add capabilities, and modern mobile frameworks offer rich notification features including images, action buttons, and inline replies. But the technology is the easy part. The hard part is building a notification strategy that respects your users' attention while achieving your engagement goals.
This guide covers the full stack: setting up FCM and APNs, implementing notification channels and categories, building rich notifications with media and actions, segmentation and targeting, timing strategies, and measuring what actually works.
Setting Up Firebase Cloud Messaging and APNs
Firebase Cloud Messaging serves as the standard abstraction layer for push notifications across platforms. On Android, FCM is native -- it is built into Google Play Services. On iOS, FCM wraps APNs, handling token management and message delivery through Apple's infrastructure.
Start by configuring your Firebase project and registering your app. On the server side, you will use the Firebase Admin SDK to send messages. Here is a Node.js example for sending a notification to a specific device:
import { initializeApp, cert } from 'firebase-admin/app';
import { getMessaging } from 'firebase-admin/messaging';
const app = initializeApp({
credential: cert('./service-account-key.json'),
});
const messaging = getMessaging(app);
async function sendNotification(
token: string,
title: string,
body: string,
data?: Record<string, string>
): Promise<string> {
const message = {
notification: {
title,
body,
},
data: data ?? {},
token,
android: {
priority: 'high' as const,
notification: {
channelId: 'default',
sound: 'default',
clickAction: 'OPEN_APP',
},
},
apns: {
headers: {
'apns-priority': '10',
},
payload: {
aps: {
sound: 'default',
badge: 1,
'content-available': 1,
},
},
},
};
return messaging.send(message);
}
On the client side, you need to handle token registration and refresh. FCM tokens can change at any time -- when the app is restored on a new device, when the user clears app data, or when FCM rotates tokens for security. Your app must listen for token changes and update the server accordingly.
In a React Native app using @react-native-firebase/messaging:
import messaging from '@react-native-firebase/messaging';
async function setupNotifications(): Promise<void> {
// Request permission (required on iOS, no-op on Android 12 and below)
const authStatus = await messaging().requestPermission();
const enabled =
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (!enabled) {
console.log('Notification permission denied');
return;
}
// Get the current token
const token = await messaging().getToken();
await registerTokenWithServer(token);
// Listen for token refreshes
messaging().onTokenRefresh(async (newToken) => {
await registerTokenWithServer(newToken);
});
// Handle foreground messages
messaging().onMessage(async (remoteMessage) => {
console.log('Foreground notification:', remoteMessage);
displayInAppNotification(remoteMessage);
});
// Handle background messages
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
console.log('Background notification:', remoteMessage);
// Process silently -- the system will display the notification
});
}
async function registerTokenWithServer(token: string): Promise<void> {
await fetch('https://api.example.com/notifications/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token,
platform: Platform.OS,
appVersion: DeviceInfo.getVersion(),
}),
});
}
For direct APNs integration on native iOS (useful when you need maximum control over the notification pipeline):
import UserNotifications
import UIKit
class NotificationManager: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationManager()
func requestAuthorization() {
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge, .provisional]
) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
}
// Called when notification arrives while app is in foreground
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler:
@escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound, .badge])
}
// Called when user taps on notification
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
handleNotificationTap(userInfo: userInfo, actionId: response.actionIdentifier)
completionHandler()
}
}
Note the use of .provisional in the authorization options. Provisional authorization, introduced in iOS 12, allows your app to send notifications to the Notification Center without prompting the user first. Notifications arrive silently, and the user decides whether to keep or turn off notifications after seeing them. This eliminates the high-stakes permission dialog on first launch and typically results in higher long-term opt-in rates.
Notification Channels, Categories, and Rich Content
Android notification channels (introduced in Android 8.0) and iOS notification categories give users granular control over which types of notifications they receive. This is not just a platform requirement -- it is a retention strategy. Users who can silence marketing notifications while keeping transactional alerts are far less likely to disable notifications entirely.
On Android, define channels during app initialization:
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
class NotificationChannelManager(private val context: Context) {
fun createChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
val channels = listOf(
NotificationChannel(
"orders",
"Order Updates",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Updates about your order status"
enableVibration(true)
},
NotificationChannel(
"messages",
"Messages",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "New messages from other users"
},
NotificationChannel(
"promotions",
"Promotions",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Deals and special offers"
enableVibration(false)
},
)
channels.forEach { manager.createNotificationChannel(it) }
}
}
Rich notifications transform a simple text alert into an interactive experience. Both platforms support images, action buttons, and expandable content. On the server side, you include additional payload fields:
async function sendRichNotification(
token: string,
orderId: string,
productName: string,
imageUrl: string
): Promise<string> {
const message = {
token,
notification: {
title: 'Your order has shipped!',
body: `${productName} is on its way. Tap to track your delivery.`,
imageUrl,
},
data: {
type: 'order_update',
orderId,
deepLink: `/orders/${orderId}/tracking`,
},
android: {
notification: {
channelId: 'orders',
imageUrl,
clickAction: 'OPEN_ORDER_TRACKING',
},
},
apns: {
payload: {
aps: {
'mutable-content': 1,
category: 'ORDER_UPDATE',
},
},
fcmOptions: {
imageUrl,
},
},
};
return messaging.send(message);
}
On iOS, implementing action buttons requires defining a notification category:
func registerNotificationCategories() {
let trackAction = UNNotificationAction(
identifier: "TRACK_ORDER",
title: "Track Delivery",
options: [.foreground]
)
let contactAction = UNNotificationAction(
identifier: "CONTACT_SUPPORT",
title: "Contact Support",
options: [.foreground]
)
let orderCategory = UNNotificationCategory(
identifier: "ORDER_UPDATE",
actions: [trackAction, contactAction],
intentIdentifiers: [],
options: [.customDismissAction]
)
UNUserNotificationCenter.current().setNotificationCategories([orderCategory])
}
For images on iOS, you need a Notification Service Extension -- a separate target in your Xcode project that downloads and attaches media before the notification is displayed. The mutable-content flag in the payload triggers this extension.
Segmentation, Targeting, and Personalization
Sending the same notification to all users is the fastest path to high opt-out rates. Effective notification strategies segment users based on behavior, preferences, and context.
FCM topic messaging provides a lightweight pub/sub model for broad segments:
// Subscribe user to topics based on their preferences
async function updateUserTopics(
token: string,
preferences: UserPreferences
): Promise<void> {
const topicActions: Promise<void>[] = [];
if (preferences.orderUpdates) {
topicActions.push(
messaging.subscribeToTopic(token, 'order-updates').then(() => {})
);
}
if (preferences.weeklyDigest) {
topicActions.push(
messaging.subscribeToTopic(token, 'weekly-digest').then(() => {})
);
}
if (preferences.productCategory) {
topicActions.push(
messaging.subscribeToTopic(
token,
`category-${preferences.productCategory}`
).then(() => {})
);
}
await Promise.all(topicActions);
}
// Send to a topic
async function sendWeeklyDigest(): Promise<string> {
return messaging.send({
topic: 'weekly-digest',
notification: {
title: 'Your Weekly Digest',
body: 'Here is what happened this week.',
},
});
}
For more granular targeting, maintain a server-side notification preferences table and query it when sending. This allows complex targeting like "users who added items to their cart in the last 24 hours but did not complete checkout" or "users who have not opened the app in 7 days but were active daily before that."
interface NotificationSegment {
name: string;
query: () => Promise<string[]>; // Returns array of FCM tokens
}
const segments: NotificationSegment[] = [
{
name: 'abandoned-cart-24h',
query: async () => {
const users = await db.query(`
SELECT fcm_token FROM users u
JOIN carts c ON u.id = c.user_id
WHERE c.updated_at > NOW() - INTERVAL '24 hours'
AND c.item_count > 0
AND c.completed_at IS NULL
AND u.notification_opt_in = true
`);
return users.map(u => u.fcm_token);
},
},
{
name: 'win-back-7d',
query: async () => {
const users = await db.query(`
SELECT fcm_token FROM users
WHERE last_active_at < NOW() - INTERVAL '7 days'
AND last_active_at > NOW() - INTERVAL '30 days'
AND notification_opt_in = true
`);
return users.map(u => u.fcm_token);
},
},
];
async function sendSegmentedNotification(
segmentName: string,
notification: { title: string; body: string }
): Promise<void> {
const segment = segments.find(s => s.name === segmentName);
if (!segment) throw new Error(`Unknown segment: ${segmentName}`);
const tokens = await segment.query();
// FCM supports up to 500 tokens per multicast
const batches = chunk(tokens, 500);
for (const batch of batches) {
await messaging.sendEachForMulticast({
tokens: batch,
notification,
});
}
}
Personalization goes beyond segmentation. Use the user's name, reference their specific actions, and tailor the content to their context. "Your order #4521 has shipped" outperforms "An order has shipped" every time. "Sarah, the running shoes you viewed are now 20% off" outperforms "Sale: 20% off shoes" by a wide margin in click-through rates.
Timing, Frequency, and A/B Testing
When you send a notification matters as much as what you send. The same message delivered at 2 AM will be dismissed or will actively annoy the user, while the same message at 10 AM might drive a conversion.
Implement timezone-aware delivery by storing the user's timezone and scheduling notifications relative to their local time:
import { DateTime } from 'luxon';
interface ScheduledNotification {
userId: string;
token: string;
timezone: string;
notification: {
title: string;
body: string;
};
targetLocalHour: number; // e.g., 10 for 10:00 AM local time
}
async function scheduleTimezoneAwareNotification(
notification: ScheduledNotification
): Promise<void> {
const userNow = DateTime.now().setZone(notification.timezone);
let targetTime = userNow.set({
hour: notification.targetLocalHour,
minute: 0,
second: 0,
});
// If the target time has passed today, schedule for tomorrow
if (targetTime < userNow) {
targetTime = targetTime.plus({ days: 1 });
}
const delayMs = targetTime.toMillis() - DateTime.now().toMillis();
await notificationQueue.add('send', {
token: notification.token,
notification: notification.notification,
}, {
delay: delayMs,
attempts: 3,
backoff: { type: 'exponential', delay: 60000 },
});
}
Frequency capping prevents notification fatigue. Implement a rate limiter on the server side that tracks how many notifications each user has received and enforces maximum thresholds:
async function canSendNotification(
userId: string,
category: string
): Promise<boolean> {
const key = `notifications:${userId}:${category}`;
const dailyKey = `${key}:daily`;
const weeklyKey = `${key}:weekly`;
const [dailyCount, weeklyCount] = await Promise.all([
redis.get(dailyKey),
redis.get(weeklyKey),
]);
const limits: Record<string, { daily: number; weekly: number }> = {
transactional: { daily: 20, weekly: 100 },
engagement: { daily: 2, weekly: 5 },
marketing: { daily: 1, weekly: 3 },
};
const limit = limits[category] ?? limits.engagement;
if (Number(dailyCount ?? 0) >= limit.daily) return false;
if (Number(weeklyCount ?? 0) >= limit.weekly) return false;
// Increment counters
await redis.multi()
.incr(dailyKey)
.expire(dailyKey, 86400)
.incr(weeklyKey)
.expire(weeklyKey, 604800)
.exec();
return true;
}
A/B testing notification content is essential for optimization. Test one variable at a time: title copy, body length, inclusion of emojis, action buttons versus no action buttons, image versus text-only. Track not just open rates but downstream conversions -- a notification that gets opened but does not drive the intended action is not a success.
Measuring Notification Effectiveness
Metrics that matter for push notification performance include delivery rate (did the notification reach the device), display rate (was it shown to the user or suppressed by the OS), open rate (did the user tap it), conversion rate (did the user complete the intended action), and opt-out rate (did the notification cause the user to disable notifications).
Firebase provides delivery and open tracking out of the box. For conversion tracking, you need to pass through attribution data in the notification payload and track it in your analytics pipeline:
// When sending
const message = {
token,
notification: { title, body },
data: {
notificationId: generateUUID(),
campaign: 'abandoned_cart_q2',
segment: 'high_value_users',
sentAt: new Date().toISOString(),
},
};
// When handling notification tap in the app
function handleNotificationOpen(data: Record<string, string>): void {
analytics.track('notification_opened', {
notificationId: data.notificationId,
campaign: data.campaign,
segment: data.segment,
sentAt: data.sentAt,
openedAt: new Date().toISOString(),
timeToOpen: Date.now() - new Date(data.sentAt).getTime(),
});
}
Monitor your opt-out rate closely. If it is climbing, your notification strategy needs adjustment -- either in frequency, timing, relevance, or all three. A healthy opt-out rate for a well-managed notification strategy is below 5 percent per month.
Building a Notification Strategy That Retains Users
Push notifications are a direct line to your users' attention. That access is a privilege, and it can be revoked with a single tap. The apps that succeed with notifications are the ones that treat every notification as a value exchange: the user gives you their attention, and you give them something genuinely useful in return.
At Maranatha Technologies, we help teams build mobile applications with notification systems that drive real engagement without burning user trust. From FCM and APNs integration to segmentation strategy to analytics pipelines, we implement the full notification stack with a focus on long-term retention. If you are building a mobile app and want to get your notification strategy right, explore our mobile app development services or get in touch to discuss your project.