event-driven processing pipeline.
Step 1: Define Provider Abstraction
Create a unified interface that abstracts FCM, APNs, and web push. This prevents vendor lock-in and enables runtime routing.
export interface PushProvider {
id: 'fcm' | 'apns' | 'webpush';
send(payload: PushPayload): Promise<DeliveryResult>;
validate(payload: PushPayload): boolean;
getQuotaStatus(): Promise<QuotaStatus>;
}
export interface DeliveryResult {
provider: string;
messageId: string;
status: 'sent' | 'throttled' | 'failed' | 'invalid_token';
latencyMs: number;
}
APNs and FCM enforce strict payload limits (~4KB total, but actual usable space is smaller due to routing metadata). Build a validator that enforces platform constraints before API calls.
export class PayloadBuilder {
private static MAX_PAYLOAD_BYTES = 3800; // Conservative limit
static build(target: PushTarget, content: PushContent, platform: 'fcm' | 'apns'): PushPayload {
const base = {
tokens: target.tokens,
title: content.title,
body: content.body,
data: content.customData || {},
priority: content.priority || 'normal',
ttl: content.ttl || 86400
};
const serialized = JSON.stringify(base);
if (Buffer.byteLength(serialized, 'utf8') > this.MAX_PAYLOAD_BYTES) {
throw new Error(`Payload exceeds ${this.MAX_PAYLOAD_BYTES} bytes`);
}
return platform === 'apns' ? this.formatForAPNs(base) : this.formatForFCM(base);
}
private static formatForAPNs(payload: any): any {
return {
aps: { alert: { title: payload.title, body: payload.body }, sound: 'default' },
custom: payload.data
};
}
private static formatForFCM(payload: any): any {
return {
notification: { title: payload.title, body: payload.body },
data: payload.data,
android: { priority: payload.priority },
apns: { payload: { aps: { sound: 'default' } } }
};
}
}
Step 3: Build Async Routing & Retry Engine
Synchronous delivery blocks event loops and cascades timeouts. Use a message queue with exponential backoff, idempotency keys, and token lifecycle management.
import { Redis } from 'ioredis';
export class DeliveryEngine {
private redis: Redis;
private providers: Map<string, PushProvider>;
private readonly IDEMPOTENCY_TTL = 3600; // 1 hour
constructor(redis: Redis, providers: PushProvider[]) {
this.redis = redis;
this.providers = new Map(providers.map(p => [p.id, p]));
}
async deliver(eventId: string, payload: PushPayload): Promise<DeliveryResult> {
const idempotencyKey = `push:idem:${eventId}`;
const cached = await this.redis.get(idempotencyKey);
if (cached) return JSON.parse(cached) as DeliveryResult;
const provider = this.selectProvider(payload.platform);
if (!provider.validate(payload)) {
throw new Error('Payload validation failed');
}
const result = await this.executeWithRetry(provider, payload, 3);
await this.redis.set(idempotencyKey, JSON.stringify(result), 'EX', this.IDEMPOTENCY_TTL);
return result;
}
private async executeWithRetry(
provider: PushProvider,
payload: PushPayload,
attempts: number
): Promise<DeliveryResult> {
for (let i = 0; i < attempts; i++) {
const start = Date.now();
const result = await provider.send(payload);
result.latencyMs = Date.now() - start;
if (result.status === 'sent') return result;
if (result.status === 'throttled') {
await this.backoff(i);
continue;
}
if (result.status === 'invalid_token') {
await this.purgeToken(payload.tokens[0]);
return result;
}
}
throw new Error('Delivery failed after max retries');
}
private async backoff(attempt: number): Promise<void> {
const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
await new Promise(res => setTimeout(res, delay));
}
}
Architecture Decisions & Rationale
- Facade Pattern: Decouples business logic from provider SDKs. Enables runtime provider switching and A/B testing of delivery channels.
- Async Queue Processing: Prevents thread blocking, absorbs traffic spikes, and enables batch optimization.
- Idempotency via Redis: Guarantees exactly-once delivery semantics for critical events (e.g., transaction alerts).
- Conservative Payload Limits: 3800 bytes accounts for routing headers, platform metadata, and UTF-8 encoding overhead. Prevents silent truncation.
- Token Lifecycle Management: Invalid tokens are purged immediately to reduce API waste and improve delivery accuracy.
Pitfall Guide
-
Ignoring Platform Payload Limits
APNs and FCM both cap payloads at ~4KB, but developers frequently exceed usable space by embedding large custom objects. Providers silently drop oversized notifications or truncate keys. Always serialize, measure byte length, and strip non-essential fields before routing.
-
Treating Delivery as Synchronous
Blocking request threads on provider APIs causes timeout cascades during peak hours. Push delivery must be async. Use a queue (Redis, Kafka, or SQS) to decouple ingestion from execution.
-
Missing Idempotency & Deduplication
Network retries, SDK reinitializations, and user actions generate duplicate events. Without idempotency keys, users receive identical notifications. Implement request hashing or event ID tracking with TTL-based cache.
-
Over-Relying on Provider Retry Defaults
FCM and APNs implement their own retry logic, but it is optimized for volume, not latency. Blindly trusting provider retries leads to exponential backoff misalignment and delayed delivery. Implement client-side retry with jitter and circuit breakers.
-
Skipping Quiet Hours & Timezone Awareness
Sending notifications at 2 AM local time triggers immediate opt-outs. Always attach timezone metadata to tokens and enforce quiet-hour windows at the routing layer, not the client.
-
No Delivery Analytics or Failure Segregation
Logging 200 OK as success is insufficient. Track invalid_token, throttled, payload_truncated, and delivery_timeout separately. Without granular metrics, optimization is guesswork.
-
Hardcoding Provider Credentials in Runtime
Embedding API keys in environment variables without rotation or secret management leads to credential drift and quota exhaustion. Use a centralized secret store with automatic rotation and quota monitoring.
Best Practices from Production:
- Centralize notification config in a versioned registry.
- Implement A/B testing for payload structure and send windows.
- Gracefully degrade to in-app messages or email when push fails consistently.
- Monitor provider health endpoints and implement fallback routing.
- Enforce strict schema validation at ingestion, not at delivery.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Marketing blast to 1M users | Segmented batch routing with provider-specific batching | Reduces API calls, optimizes payload size, respects rate limits | High initial compute, lower per-delivery cost |
| Transactional alerts (payments, security) | Behavioral/event-driven with idempotency & synchronous fallback | Guarantees delivery, prevents duplicates, meets SLA | Moderate infra cost, high retention ROI |
| Cross-platform app (iOS, Android, Web) | Facade routing with platform validation & quiet hours | Handles fragmentation, prevents payload truncation, reduces opt-outs | Slightly higher dev overhead, lower churn |
| Legacy app with expired tokens | Token purge pipeline + re-engagement campaign | Cleans delivery list, restores accuracy, stops quota waste | One-time cleanup cost, long-term delivery improvement |
Configuration Template
push:
providers:
fcm:
enabled: true
endpoint: https://fcm.googleapis.com/v1/projects/${PROJECT_ID}/messages:send
max_payload_bytes: 3800
retry:
max_attempts: 3
base_delay_ms: 500
max_delay_ms: 30000
jitter: true
apns:
enabled: true
endpoint: https://api.push.apple.com/3/device/${TOKEN}
max_payload_bytes: 3800
retry:
max_attempts: 2
base_delay_ms: 1000
max_delay_ms: 15000
jitter: true
routing:
strategy: behavioral
quiet_hours:
enabled: true
start: "22:00"
end: "07:00"
timezone: user_local
idempotency:
ttl_seconds: 3600
store: redis
analytics:
track: [sent, throttled, invalid_token, payload_truncated, delivery_timeout]
retention_days: 90
Quick Start Guide
- Install dependencies:
npm install ioredis axios zod
- Initialize provider clients: Configure FCM service account and APNs AuthKey in your environment. Instantiate
PushProvider implementations with the configuration template.
- Spin up the delivery queue: Deploy a Redis instance or managed queue. Configure the
DeliveryEngine with idempotency TTL and retry parameters.
- Integrate ingestion endpoint: Expose a POST route that validates incoming events, generates idempotency keys, and pushes payloads to the queue.
- Monitor delivery health: Instrument the analytics pipeline with provider status codes. Set alerts for
invalid_token spikes and throttled rates exceeding 5%.