Scalable Push Notifications Architecture: A Language-Agnostic Approach
Introduction
Push notifications are essential for user engagement, but most implementations only scratch the surface. They focus on sending messages without addressing token lifecycle management, delivery tracking, notification history, or scalability concerns.
This post presents a comprehensive architecture for push notifications that works regardless of your tech stack. Whether you're using Flutter/React Native/Swift on mobile, or Node.js/Python/Java/Go on the backend, these concepts apply universally.
What makes this architecture different:
- Automatic token synchronization between client and server
- Complete notification history with CRUD operations
- Delivery tracking and error handling
- Multi-device support per user
- Built-in retry mechanisms
- Scalable from day one
The Challenge
Implementing a robust push notification system involves several interconnected challenges:
1. Token Lifecycle Management
- Tokens expire and need refreshing
- Users have multiple devices (phone, tablet, web)
- Tokens become invalid (app uninstall, permissions revoked)
- Need to sync token state between client and server
2. Delivery Reliability
- Network failures during send
- Push service rate limits (FCM/APNs)
- Invalid tokens need identification
- Failed deliveries require retry logic
3. User Experience
- View notification history
- Mark notifications as read/unread
- Delete unwanted notifications
- Receive notifications even when app is closed
4. Scalability
- Send to thousands of users simultaneously
- Handle burst traffic during campaigns
- Manage growing notification history
- Optimize database queries
5. Observability
- Track delivery success/failure rates
- Monitor push service errors
- Measure user engagement (open rates)
- Debug delivery issues
Architecture Overview
The solution follows a client-server pattern with asynchronous processing:
┌──────────────────────────────────────────────────────────┐
│ MOBILE CLIENT │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Push Service (FCM/APNs) │ │
│ │ • Request permissions │ │
│ │ • Obtain device token │ │
│ │ • Listen for token refresh │ │
│ │ • Handle incoming notifications │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ │ 1. Register/Update Token │
└────────────────────────┼─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ API SERVER │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ REST API Endpoints │ │
│ │ • POST /api/tokens │ │
│ │ • DELETE /api/tokens/:id │ │
│ │ • POST /api/notifications │ │
│ │ • GET /api/notifications │ │
│ │ • PATCH /api/notifications/:id/read │ │
│ │ • DELETE /api/notifications/:id │ │
│ └──────────────────────────────────────────┘ │
│ │ │
└────────────────────────┼─────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ DATABASE │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ device_tokens │ │ notifications │ │
│ │ • user_id │◄───│ • user_id │ │
│ │ • token │ │ • title │ │
│ │ • platform │ │ • body │ │
│ │ • is_active │ │ • data (JSON) │ │
│ │ • last_used_at │ │ • status │ │
│ └─────────────────────┘ │ • sent_at │ │
│ │ • read_at │ │
│ └─────────────────────┘ │
│ │ │
└───────────────────────────────────────┼──────────────────┘
│
│ 2. Trigger Async Send
▼
┌──────────────────────────────────────────────────────────┐
│ BACKGROUND WORKER │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ Push Delivery Service │ │
│ │ • Fetch notification │ │
│ │ • Get active tokens for user │ │
│ │ • Send via FCM/APNs │ │
│ │ • Handle responses │ │
│ │ • Invalidate bad tokens │ │
│ │ • Update notification status │ │
│ │ • Log delivery results │ │
│ └───────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ Retry Worker │ │
│ │ • Find failed/pending notifications │ │
│ │ • Retry with exponential backoff │ │
│ │ • Max retry limit (e.g., 3 attempts) │ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
│
│ 3. Send Push
▼
┌──────────────────────────────────────────────────────────┐
│ PUSH SERVICE (FCM/APNs) │
└──────────────────────────────────────────────────────────┘
Core Components
- Client Token Manager: Monitors token lifecycle and syncs with server
- Token Registry API: Receives and stores device tokens
- Notifications CRUD API: Manages notification records
- Push Delivery Service: Sends notifications via FCM/APNs
- Background Worker: Handles retries and batch processing
- History UI: Shows notification history to users
Database Schema Design
Device Tokens Table
CREATE TABLE device_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL,
platform VARCHAR(20) NOT NULL, -- 'ios', 'android', 'web'
device_info JSONB, -- model, OS version, etc.
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_used_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- One user can have multiple devices
UNIQUE(user_id, token)
);
-- Indexes for performance
CREATE INDEX idx_device_tokens_user_id ON device_tokens(user_id);
CREATE INDEX idx_device_tokens_active ON device_tokens(is_active);
CREATE INDEX idx_device_tokens_last_used ON device_tokens(last_used_at);
Key Design Decisions:
UNIQUE(user_id, token): Prevents duplicate tokens per useris_active: Soft delete for audit traillast_used_at: Identify stale tokens for cleanup- Support multiple devices per user
Notifications Table
CREATE TABLE notifications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
data JSONB DEFAULT '{}', -- Custom payload for app navigation
type VARCHAR(50), -- 'general', 'alert', 'promotion', etc.
priority VARCHAR(20) DEFAULT 'normal', -- 'low', 'normal', 'high'
status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'sent', 'failed', 'cancelled'
sent_at TIMESTAMP WITH TIME ZONE,
read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Delivery metadata
fcm_message_id TEXT, -- ID from FCM/APNs response
error_message TEXT,
retry_count INTEGER DEFAULT 0
);
-- Critical indexes
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_status ON notifications(status);
CREATE INDEX idx_notifications_created_at ON notifications(created_at DESC);
CREATE INDEX idx_notifications_user_status ON notifications(user_id, status);
CREATE INDEX idx_notifications_unread ON notifications(user_id, read_at)
WHERE read_at IS NULL;
Status Lifecycle:
pending → sent → (optionally) delivered
↓
failed → retry (up to max attempts)
Delivery Logs Table (Optional but Recommended)
CREATE TABLE notification_deliveries (
id UUID PRIMARY KEY,
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
token_id UUID NOT NULL REFERENCES device_tokens(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL, -- 'success', 'failed'
error_code VARCHAR(50),
error_message TEXT,
sent_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_deliveries_notification ON notification_deliveries(notification_id);
CREATE INDEX idx_deliveries_token ON notification_deliveries(token_id);
Purpose: Track delivery per device for debugging and analytics
API Design
Token Management Endpoints
POST /api/tokens - Register or Update Token
Purpose: Upsert device token for authenticated user
Auth: Required (JWT/Session)
Request Body:
{
"token": "device_fcm_or_apns_token",
"platform": "ios" | "android" | "web",
"device_info": {
"model": "iPhone 15 Pro",
"os_version": "iOS 17.1",
"app_version": "1.2.3"
}
}
Response: 201 Created
{
"success": true,
"data": {
"id": "uuid",
"token": "...",
"platform": "ios"
}
}
Logic:
1. Extract user_id from auth token
2. Validate input (token, platform required)
3. UPSERT:
- If exists: Update metadata, set is_active=true
- If new: Insert record
4. Update last_used_at timestamp
DELETE /api/tokens/:token - Deactivate Token
Purpose: Mark token as inactive (user logout)
Auth: Required
Logic:
- Set is_active = false
- Keep record for audit (soft delete)
- Don't physically delete
Notifications CRUD Endpoints
POST /api/notifications - Create Notification
Purpose: Create notification + trigger async push delivery
Auth: Required (typically admin role)
Request Body:
{
"user_id": "uuid",
"title": "New message",
"body": "You have a new message from John",
"data": {
"type": "message",
"message_id": "123",
"sender_id": "456"
},
"type": "message",
"priority": "high"
}
Response: 201 Created
{
"success": true,
"data": {
"id": "uuid",
"status": "pending",
"created_at": "2025-10-22T10:30:00Z"
}
}
Flow:
1. Validate authorization (admin check)
2. Validate required fields
3. Insert notification record (status: 'pending')
4. Commit transaction
5. Return 201 immediately
6. Trigger async worker to send push (don't block!)
GET /api/notifications - List User Notifications
Purpose: Fetch notification history
Auth: Required
Query Params:
- page: number (default: 1)
- limit: number (default: 20, max: 100)
- status: 'sent' | 'failed' (optional filter)
- type: string (optional filter)
Response: 200 OK
{
"success": true,
"data": [...notifications],
"pagination": {
"page": 1,
"limit": 20,
"total": 150,
"has_more": true
}
}
Logic:
- Filter by authenticated user_id
- Apply status/type filters if provided
- Order by created_at DESC (newest first)
- Paginate results
GET /api/notifications/:id - Get Single Notification
Purpose: Fetch notification details
Auth: Required
Security: Verify notification belongs to user
PATCH /api/notifications/:id/read - Mark as Read
Purpose: Update read_at timestamp
Auth: Required
Logic:
- Set read_at = NOW() WHERE id = :id AND user_id = :user_id
- Only update if currently null (idempotent)
- Return updated notification
DELETE /api/notifications/:id - Delete Notification
Purpose: Remove from user's history
Auth: Required
Logic:
- Physical delete (or soft delete based on requirements)
- Verify ownership before delete
Client-Side Implementation
Token Management Flow
App Lifecycle → Token Events → Server Sync
1. App Startup:
- Request notification permissions
- Get current token
- Send to server
2. Token Refresh (automatic):
- Listen for refresh event
- Get new token
- Send to server
3. User Login:
- Re-send token with user context
- Associate token with user_id
4. User Logout:
- Call DELETE /api/tokens/:token
- Mark token as inactive
Pseudo-code Pattern
class PushNotificationManager {
async initialize() {
// Request permissions
const hasPermission = await requestNotificationPermission();
if (!hasPermission) return;
// Get initial token
const token = await getDeviceToken();
await this.registerToken(token);
// Listen for token refresh
onTokenRefresh((newToken) => {
this.registerToken(newToken);
});
// Setup message handlers
this.setupMessageHandlers();
}
async registerToken(token) {
const maxRetries = 3;
let attempt = 0;
while (attempt < maxRetries) {
try {
await httpClient.post('/api/tokens', {
token: token,
platform: this.getPlatform(),
device_info: await this.getDeviceInfo()
});
return; // Success
} catch (error) {
attempt++;
if (attempt >= maxRetries) {
// Store locally and retry later
await this.saveTokenToLocalStorage(token);
return;
}
// Exponential backoff
await sleep(Math.pow(2, attempt) * 1000);
}
}
}
setupMessageHandlers() {
// When app is in foreground
onMessageReceived((message) => {
this.showLocalNotification(message);
this.updateNotificationsList();
});
// When user taps notification
onNotificationTapped((message) => {
this.navigateToScreen(message.data);
});
}
getPlatform() {
// Return 'ios', 'android', or 'web'
}
async getDeviceInfo() {
return {
model: await getDeviceModel(),
os_version: await getOSVersion(),
app_version: getAppVersion()
};
}
}
Notification History UI Pattern
class NotificationsScreen {
state = {
notifications: [],
page: 1,
hasMore: true,
isLoading: false
}
async loadNotifications() {
if (this.state.isLoading || !this.state.hasMore) return;
this.state.isLoading = true;
try {
const response = await httpClient.get(
`/api/notifications?page=${this.state.page}&limit=20`
);
this.state.notifications.push(...response.data);
this.state.page++;
this.state.hasMore = response.pagination.has_more;
} finally {
this.state.isLoading = false;
}
}
async markAsRead(notificationId) {
await httpClient.patch(`/api/notifications/${notificationId}/read`);
// Update local state
}
async deleteNotification(notificationId) {
await httpClient.delete(`/api/notifications/${notificationId}`);
// Remove from local list
}
// Infinite scroll trigger
onScrollNearBottom() {
this.loadNotifications();
}
}
Server-Side Push Delivery
Push Delivery Service Logic
Function: sendPushNotification(notificationId)
1. Fetch notification from database
- If not found → exit
2. Fetch all active tokens for user_id
- Query: WHERE user_id = X AND is_active = true
- If no tokens → mark notification as 'failed' with reason
3. Build push message payload:
{
notification: { title, body },
data: { custom fields from notification.data },
android: { priority, channel_id, sound },
ios: { sound, badge_count }
}
4. Send to FCM/APNs:
- Use multicast API for multiple tokens
- Handle rate limits (max 500 tokens per request)
5. Process each response:
FOR EACH token_response:
IF success:
- Log success in delivery_logs
- Update token.last_used_at
ELSE:
- Log failure with error_code
- IF error indicates invalid token:
- Set token.is_active = false
6. Update notification status:
IF any_success:
- status = 'sent'
- sent_at = NOW()
ELSE:
- status = 'failed'
- error_message = aggregated errors
Invalid Token Detection
Certain error codes indicate a token should be deactivated:
FCM Error Codes:
messaging/invalid-registration-tokenmessaging/registration-token-not-registeredmessaging/invalid-argument
APNs Error Codes:
BadDeviceTokenUnregisteredDeviceTokenNotForTopic
Action: Automatically set is_active = false for these tokens
Platform-Specific Payloads
Android (FCM):
{
"message": {
"token": "device_token",
"notification": {
"title": "Title",
"body": "Body text"
},
"data": {
"custom_key": "custom_value"
},
"android": {
"priority": "high",
"notification": {
"channel_id": "default",
"sound": "default",
"color": "#FF0000"
}
}
}
}
iOS (APNs):
{
"aps": {
"alert": {
"title": "Title",
"body": "Body text"
},
"sound": "default",
"badge": 5,
"content-available": 1
},
"custom_key": "custom_value"
}
Background Worker
Retry Worker Strategy
Schedule: Every 5 minutes (cron/scheduler)
Query for retryable notifications:
SELECT id FROM notifications
WHERE status IN ('pending', 'failed')
AND retry_count < 3
AND created_at > NOW() - INTERVAL '24 hours'
AND (sent_at IS NULL OR sent_at < NOW() - INTERVAL '5 minutes')
ORDER BY created_at ASC
LIMIT 100
Process:
1. Increment retry_count for selected notifications
2. FOR EACH notification:
- Call sendPushNotification(id)
- Catch and log errors
3. Sleep before next iteration
Exponential Backoff:
- Retry 1: Immediate
- Retry 2: After 5 minutes
- Retry 3: After 15 minutes
- After 3 failures: Mark as 'failed' permanently
Implementation Options by Language
Node.js:
- Libraries:
node-cron,bull,agenda - Example: BullMQ for robust job queue
Python:
- Libraries:
celery,apscheduler,rq - Example: Celery with Redis backend
Java:
- Libraries: Quartz Scheduler, Spring @Scheduled
- Example: Spring Boot scheduled tasks
Go:
- Libraries:
cron,gocron - Example:
robfig/cronpackage
Best Practices
1. Security
Authorization Checks:
- Only admins can create notifications for any user
- Regular users can only query their own notifications
- Always verify user_id matches authenticated user
- Use role-based access control (RBAC)
Rate Limiting:
Apply rate limits to prevent abuse:
- Per user: 100 notifications per 15 minutes
- Per IP: 1000 requests per hour
- Per endpoint: Adjust based on sensitivity
Input Validation:
- Sanitize title and body (max lengths)
- Validate JSON in data field
- Check platform enum values
- Prevent SQL injection (use parameterized queries)
2. Performance Optimization
Database Indexing:
-- Critical for query performance
CREATE INDEX idx_notifications_user_created
ON notifications(user_id, created_at DESC);
CREATE INDEX idx_notifications_status_created
ON notifications(status, created_at)
WHERE status IN ('pending', 'failed');
CREATE INDEX idx_tokens_user_active
ON device_tokens(user_id, is_active)
WHERE is_active = true;
Caching Strategy:
Cache with TTL:
- Unread count: 5-15 minutes
- User preferences: 30 minutes
- Active tokens: 10 minutes
Invalidate on:
- New notification created
- Notification marked as read
- Token registered/deactivated
Pagination:
- Default limit: 20 items
- Max limit: 100 items
- Use cursor-based pagination for large datasets
- Return total count in metadata
3. Scalability
Horizontal Scaling:
API Servers:
- Stateless design (store sessions in Redis/DB)
- Load balancer distributes requests
- Auto-scale based on CPU/memory
Workers:
- Multiple worker processes
- Use distributed job queue (RabbitMQ, SQS, Kafka)
- Prevent duplicate processing with locks
Database Partitioning:
-- Partition notifications by month
CREATE TABLE notifications_2025_10
PARTITION OF notifications
FOR VALUES FROM ('2025-10-01') TO ('2025-11-01');
-- Benefits:
-- - Faster queries on recent data
-- - Easy archival of old data
-- - Improved maintenance operations
Archival Strategy:
Move old notifications (> 90 days) to:
- Cold storage (AWS S3, Google Cloud Storage)
- Separate archive table
- Data warehouse for analytics
Keep recent data hot for fast access
4. Monitoring & Observability
Key Metrics to Track:
Delivery Metrics:
- Total notifications sent (by period)
- Success rate (sent / attempted)
- Failure rate + error distribution
- Average delivery time
Engagement Metrics:
- Read rate (read / sent)
- Open rate (tapped / sent)
- Conversion rate (action taken / sent)
System Metrics:
- API response times
- Worker processing rate
- Queue depth
- Database query performance
- Active tokens per user (avg, p95, p99)
Alerting Rules:
Critical:
- Delivery success rate < 90%
- Worker processing stopped
- Database connection pool exhausted
Warning:
- Delivery success rate < 95%
- Queue depth > 1000
- API response time > 500ms
- Retry queue growing
Logging:
Log with structured format (JSON):
{
"timestamp": "2025-10-22T10:30:00Z",
"level": "info",
"service": "push-worker",
"notification_id": "uuid",
"user_id": "uuid",
"tokens_count": 2,
"success_count": 2,
"failure_count": 0,
"duration_ms": 150
}
5. Error Handling
Client-Side:
Retry strategy:
- Max 3 attempts
- Exponential backoff (1s, 2s, 4s)
- Store failed tokens locally
- Retry on next app startup
- Show user-friendly errors
Server-Side:
Graceful degradation:
- If FCM/APNs down: Queue for later
- If database slow: Circuit breaker pattern
- If rate limited: Backoff and retry
- Log all errors with context
6. Token Cleanup
-- Scheduled job (daily)
DELETE FROM device_tokens
WHERE is_active = false
AND updated_at < NOW() - INTERVAL '90 days';
-- Mark stale tokens inactive (not used in 60 days)
UPDATE device_tokens
SET is_active = false
WHERE is_active = true
AND last_used_at < NOW() - INTERVAL '60 days';
Advanced Features
1. Topic-Based Notifications
Use Case: Broadcast to user segments
Implementation:
- Client subscribes tokens to topics
- Server sends single message to topic
- Push service handles fanout
Example Topics:
- "all_users"
- "premium_users"
- "sports_updates"
- "region_us_west"
Benefit: Efficient broadcasting without looping through tokens
2. Scheduled Notifications
-- Add scheduled_for field
ALTER TABLE notifications
ADD COLUMN scheduled_for TIMESTAMP WITH TIME ZONE;
-- Worker checks for due notifications
SELECT id FROM notifications
WHERE status = 'scheduled'
AND scheduled_for <= NOW()
ORDER BY scheduled_for ASC
LIMIT 100;
3. User Preferences
CREATE TABLE user_notification_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id),
enabled_types JSONB, -- ["message", "alert", "promotion"]
quiet_hours_start TIME,
quiet_hours_end TIME,
timezone VARCHAR(50),
frequency_limit INTEGER, -- max per day
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Check before sending
IF notification.type NOT IN user_preferences.enabled_types:
skip
IF current_time BETWEEN quiet_hours:
skip or schedule for later
4. Rich Notifications
Android:
- Custom layouts
- Action buttons
- Large images
- Progress bars
iOS:
- Media attachments
- Custom actions
- Rich content
Web:
- Icons and images
- Action buttons
- Require interaction
5. Analytics & A/B Testing
Track events:
- notification_sent
- notification_delivered
- notification_opened
- notification_action_taken
Use for:
- A/B test different titles/bodies
- Optimize send times
- Measure campaign effectiveness
- User segmentation
Common Pitfalls & Solutions
| Pitfall | Solution |
|---|---|
| Not handling token expiration | Always listen for refresh events and update server |
| Blocking API response waiting for FCM | Return immediately, send push asynchronously |
| No retry mechanism | Implement background worker with exponential backoff |
| Poor database performance | Add proper indexes, partition large tables |
| Not invalidating bad tokens | Check error codes and mark inactive automatically |
| Missing multi-device support | Allow multiple tokens per user |
| No rate limiting | Implement per-user and per-endpoint limits |
| Storing all history forever | Archive old data, set retention policies |
Testing Strategy
Unit Tests
Backend:
- Token registration logic
- Notification creation
- Status updates
- Error handling
- Token invalidation
Client:
- Token sync logic
- Message parsing
- UI state management
- Retry logic
Integration Tests
Full flow:
1. Client registers token → Server stores
2. Admin creates notification → DB record created
3. Worker picks up notification → Sends to FCM/APNs
4. Client receives push → Shows notification
5. User taps → Navigates to screen
6. User marks as read → Server updates
Load Tests
Scenarios:
- 1000 notifications/second
- 10,000 concurrent users
- Token refresh storm (all users at once)
- Database failover
- Push service throttling
Implementation Checklist
- [ ] Set up FCM/APNs in push service provider
- [ ] Create database tables with indexes
- [ ] Implement token registration API
- [ ] Build push delivery service
- [ ] Add background retry worker
- [ ] Create notifications CRUD API
- [ ] Implement client-side token manager
- [ ] Build notification history UI
- [ ] Add authentication & authorization
- [ ] Implement rate limiting
- [ ] Set up monitoring and alerts
- [ ] Add structured logging
- [ ] Implement token cleanup job
- [ ] Write unit tests
- [ ] Write integration tests
- [ ] Load test the system
- [ ] Document API for team
- [ ] Create runbook for operations
Conclusion
This architecture provides a solid foundation for push notifications that:
✅ Scales from day one - Handles growth without major refactoring
✅ Provides great UX - Users can view and manage notification history
✅ Ensures reliability - Automatic retries and error handling
✅ Maintains observability - Full tracking and monitoring
✅ Works anywhere - Language and framework agnostic
Next Phase Features
Once the core is solid, consider adding:
- Topic subscriptions for efficient broadcasts
- Scheduled notifications with timezone support
- User preference management for notification control
- Rich media notifications with images and actions
- Analytics dashboard for engagement insights
- A/B testing framework for optimization
- Multi-language support with localization
Resources
- Firebase Cloud Messaging Documentation
- Apple Push Notification Service (APNs)
- Web Push Protocol (RFC 8030)
- FCM HTTP v1 API Reference
Have questions or suggestions? Feel free to reach out!