Building Push Notification Infrastructure
How to build reliable push notification infrastructure with APNs and FCM integration, device token management, delivery tracking, and retry strategies — with patterns from Whisper.
Push notifications are the only way to reach users when they are not actively using your app. For a banking app, this capability is critical — transaction alerts, security warnings, and payment reminders must reach users promptly regardless of whether the app is open. But push notification infrastructure is deceptively complex. Each platform (iOS and Android) has its own push service, token format, payload structure, and error handling requirements.
This article covers how Whisper manages push notification delivery across platforms, from device token management to delivery guarantees.
Device Token Management
Every device that wants to receive push notifications must register a device token with the push service (APNs for iOS, FCM for Android). These tokens are opaque strings that identify a specific app installation on a specific device. Tokens can change — when the app is reinstalled, when the OS is updated, or when the push service rotates them.
interface DeviceToken {
token: string;
platform: "ios" | "android";
userId: string;
appVersion: string;
registeredAt: Date;
lastUsedAt: Date;
isActive: boolean;
}
class DeviceTokenStore {
private db: Database;
async register(
userId: string,
token: string,
platform: "ios" | "android",
appVersion: string
): Promise<void> {
// Deactivate old tokens for the same device
await this.db.query(
`UPDATE device_tokens SET is_active = false
WHERE token = $1 AND user_id != $2`,
[token, userId]
);
// Upsert the token
await this.db.query(
`INSERT INTO device_tokens (token, platform, user_id, app_version, registered_at, last_used_at, is_active)
VALUES ($1, $2, $3, $4, NOW(), NOW(), true)
ON CONFLICT (token)
DO UPDATE SET user_id = $3, app_version = $4, last_used_at = NOW(), is_active = true`,
[token, platform, userId, appVersion]
);
}
async getActiveTokens(userId: string): Promise<DeviceToken[]> {
const result = await this.db.query(
`SELECT * FROM device_tokens WHERE user_id = $1 AND is_active = true`,
[userId]
);
return result.rows;
}
async deactivateToken(token: string): Promise<void> {
await this.db.query(
`UPDATE device_tokens SET is_active = false WHERE token = $1`,
[token]
);
}
async cleanupStaleTokens(daysInactive: number = 90): Promise<number> {
const result = await this.db.query(
`DELETE FROM device_tokens
WHERE last_used_at < NOW() - INTERVAL '1 day' * $1
AND is_active = false`,
[daysInactive]
);
return result.rowCount ?? 0;
}
}Unified Push Service
Whisper abstracts platform differences behind a unified push service that handles APNs and FCM through a common interface.
interface PushPayload {
title: string;
body: string;
data?: Record<string, string>;
badge?: number;
sound?: string;
category?: string;
threadId?: string;
priority: "high" | "normal";
ttl?: number; // Time to live in seconds
}
interface PushResult {
token: string;
success: boolean;
error?: string;
errorCode?: string;
shouldRemoveToken: boolean;
}
class UnifiedPushService {
private apnsClient: APNsClient;
private fcmClient: FCMClient;
async send(
token: DeviceToken,
payload: PushPayload
): Promise<PushResult> {
switch (token.platform) {
case "ios":
return this.sendAPNs(token.token, payload);
case "android":
return this.sendFCM(token.token, payload);
}
}
async sendToUser(
userId: string,
payload: PushPayload
): Promise<PushResult[]> {
const tokens = await tokenStore.getActiveTokens(userId);
const results = await Promise.all(
tokens.map((token) => this.send(token, payload))
);
// Handle invalid tokens
for (const result of results) {
if (result.shouldRemoveToken) {
await tokenStore.deactivateToken(result.token);
}
}
return results;
}
private async sendAPNs(
token: string,
payload: PushPayload
): Promise<PushResult> {
try {
const notification = {
aps: {
alert: { title: payload.title, body: payload.body },
badge: payload.badge,
sound: payload.sound ?? "default",
"thread-id": payload.threadId,
"mutable-content": 1,
},
...payload.data,
};
await this.apnsClient.send(token, notification, {
priority: payload.priority === "high" ? 10 : 5,
expiry: payload.ttl
? Math.floor(Date.now() / 1000) + payload.ttl
: 0,
topic: "com.klivvr.app",
});
return { token, success: true, shouldRemoveToken: false };
} catch (error: any) {
const shouldRemove = [
"BadDeviceToken",
"Unregistered",
"ExpiredToken",
].includes(error.reason);
return {
token,
success: false,
error: error.message,
errorCode: error.reason,
shouldRemoveToken: shouldRemove,
};
}
}
private async sendFCM(
token: string,
payload: PushPayload
): Promise<PushResult> {
try {
await this.fcmClient.send({
token,
notification: {
title: payload.title,
body: payload.body,
},
data: payload.data,
android: {
priority: payload.priority,
ttl: payload.ttl ? `${payload.ttl}s` : undefined,
notification: {
sound: payload.sound ?? "default",
channelId: payload.category ?? "default",
},
},
});
return { token, success: true, shouldRemoveToken: false };
} catch (error: any) {
const shouldRemove = error.code === "messaging/registration-token-not-registered";
return {
token,
success: false,
error: error.message,
errorCode: error.code,
shouldRemoveToken: shouldRemove,
};
}
}
}Delivery Tracking
Knowing whether a notification was delivered is essential for reliability. Push services provide delivery confirmation at the platform level, but true delivery tracking requires client-side acknowledgment.
interface NotificationRecord {
id: string;
userId: string;
type: string;
payload: PushPayload;
sentAt: Date;
deliveredAt?: Date;
readAt?: Date;
status: "queued" | "sent" | "delivered" | "read" | "failed";
attempts: number;
lastError?: string;
}
class DeliveryTracker {
private db: Database;
async recordSend(
notificationId: string,
results: PushResult[]
): Promise<void> {
const anySuccess = results.some((r) => r.success);
const errors = results
.filter((r) => !r.success)
.map((r) => r.error)
.join("; ");
await this.db.query(
`UPDATE notifications
SET status = $1, sent_at = NOW(), attempts = attempts + 1, last_error = $2
WHERE id = $3`,
[anySuccess ? "sent" : "failed", errors || null, notificationId]
);
}
async recordDelivery(notificationId: string): Promise<void> {
await this.db.query(
`UPDATE notifications SET status = 'delivered', delivered_at = NOW()
WHERE id = $1 AND status = 'sent'`,
[notificationId]
);
}
async recordRead(notificationId: string): Promise<void> {
await this.db.query(
`UPDATE notifications SET status = 'read', read_at = NOW()
WHERE id = $1`,
[notificationId]
);
}
async getDeliveryStats(
startDate: Date,
endDate: Date
): Promise<DeliveryStats> {
const result = await this.db.query(
`SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'delivered' OR status = 'read') as delivered,
COUNT(*) FILTER (WHERE status = 'read') as read,
COUNT(*) FILTER (WHERE status = 'failed') as failed,
AVG(EXTRACT(EPOCH FROM (delivered_at - sent_at))) as avg_delivery_seconds
FROM notifications
WHERE sent_at BETWEEN $1 AND $2`,
[startDate, endDate]
);
return result.rows[0];
}
}Retry Strategy
Push notification delivery can fail transiently — network issues, service throttling, or temporary outages. A retry strategy with exponential backoff handles these cases.
class PushRetryQueue {
private queue: PriorityQueue<RetryEntry>;
async enqueue(
notification: NotificationRecord,
delay: number
): Promise<void> {
await this.queue.add({
notificationId: notification.id,
userId: notification.userId,
payload: notification.payload,
attempt: notification.attempts + 1,
maxAttempts: 5,
nextAttemptAt: new Date(Date.now() + delay),
});
}
async processRetries(): Promise<void> {
const due = await this.queue.getDue(new Date());
for (const entry of due) {
if (entry.attempt >= entry.maxAttempts) {
await this.markFinalFailure(entry.notificationId);
continue;
}
const results = await pushService.sendToUser(
entry.userId,
entry.payload
);
if (results.some((r) => r.success)) {
await deliveryTracker.recordSend(entry.notificationId, results);
} else {
// Exponential backoff: 1m, 5m, 25m, 2h
const delay = Math.min(
60000 * Math.pow(5, entry.attempt),
7200000
);
await this.enqueue(
{ ...entry, attempts: entry.attempt } as any,
delay
);
}
}
}
private async markFinalFailure(notificationId: string): Promise<void> {
await deliveryTracker.recordSend(notificationId, [
{
token: "",
success: false,
error: "Max retry attempts exceeded",
shouldRemoveToken: false,
},
]);
}
}Conclusion
Push notification infrastructure is a critical component of any mobile-first service. Whisper handles the complexity of multi-platform delivery (APNs and FCM), device token lifecycle management, delivery tracking with client acknowledgment, and retry strategies for transient failures. The unified push service abstracts platform differences so that the rest of the system works with a single notification API regardless of whether the user is on iOS or Android. Reliability is the paramount concern — a missed transaction alert in a banking app is not just a poor experience, it is a potential compliance issue.
Related Articles
Omnichannel Messaging Strategy for Fintech
How to build a unified omnichannel messaging strategy across push, SMS, email, and in-app channels — covering channel selection, message consistency, and unified customer experience.
Real-Time Notifications in Banking
How real-time notifications transform the banking experience, covering transaction alerts, security notifications, compliance requirements, and the business impact of instant communication.
Scaling Notification Systems
Strategies for scaling notification systems to millions of users, covering horizontal scaling, fan-out patterns, rate limiting, and infrastructure design — with patterns from Whisper.