Design a Push Notification System — System Design Guide
In this tutorial, you'll learn about Design a Push Notification System. We cover key concepts, practical examples, and best practices.
A push notification system delivers timely alerts to mobile and web users through platform-specific services like FCM for Android and APNs for iOS, handling billions of daily deliveries with low latency.
What You'll Learn
You will design a scalable push notification system -- device registration, notification queues, FCM/APNs integration, batching, retry logic with exponential backoff, and user preference filtering. You'll implement a notification service in Python.
Why This Problem Matters
Push notifications drive engagement. WhatsApp sends 100 billion daily. Instagram sends billions of notification digests. A 1-second delay in delivery reduces click-through rates by 10%. Doda Browser uses push notifications for extension updates, sync alerts, and security warnings.
Real-World Use
Durga Antivirus Pro sends real-time threat alerts to 50 million devices. When a new malware strain is detected, the notification system delivers warnings within seconds. DodaZIP notifies users when cloud sync completes or encounters conflicts.
System Design Learning Path
flowchart LR
A[Job Scheduler] --> B[Push Notification System]
B --> C{You Are Here}
C --> D[Distributed File Storage]
C --> E[Real-Time Analytics]
style C fill:#f90,color:#fff
Requirements
Functional:
- Register and unregister device tokens
- Send notifications to individual users, segments, or all users
- Support mobile (FCM, APNs) and web push (W3C Push API)
- Template-based notification content
- User opt-in/opt-out per notification type
- Delivery status tracking and analytics
Non-functional:
- P99 delivery latency < 5 seconds for critical notifications
- Support 100M+ registered devices
- 1M+ notifications sent per minute
- At-least-once delivery
- Graceful degradation if FCM/APNs is unreachable
System Architecture
flowchart TB App[Application Server] --> API[Notification API] API --> MQ[(Message Queue - Kafka)] MQ --> Router[Notification Router] Router --> Filter[Preference Filter] Filter --> Batcher[Batching Engine] Batcher --> FCM[FCM Gateway] Batcher --> APNs[APNs Gateway] Batcher --> WebPush[Web Push Gateway] FCM --> Device[Android Device] APNs --> Device2[iOS Device] WebPush --> Device3[Browser] Router --> DB[(User Preferences)] Router --> Cache[(Redis Device Tokens)]
Device Token Registration
Every device registers its push token when the app opens. Tokens expire and must be refreshed.
from dataclasses import dataclass
from enum import Enum
import uuid
class Platform(Enum):
ANDROID = "fcm"
IOS = "apns"
WEB = "web_push"
@dataclass
class Device:
user_id: str
device_id: str
platform: Platform
push_token: str
locale: str = "en"
active: bool = True
class DeviceRegistry:
def __init__(self, db_pool, redis_client):
self.db = db_pool
self.redis = redis_client
async def register(self, device: Device):
await self.db.execute(
"""INSERT INTO devices (user_id, device_id, platform, push_token, locale)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (device_id) DO UPDATE
SET push_token = $4, active = true""",
device.user_id, device.device_id,
device.platform.value, device.push_token, device.locale)
await self.redis.setex(
f"device:{device.device_id}", 86400,
device.push_token)
async def unregister(self, device_id: str):
await self.db.execute(
"UPDATE devices SET active = false WHERE device_id = $1",
device_id)
await self.redis.delete(f"device:{device.device_id}")
Notification Queue and Processing
Notifications flow through a message queue. The router reads from the queue, filters by user preferences, batches by platform, and sends to the appropriate gateway.
import asyncio
import json
import aiohttp
class NotificationRouter:
def __init__(self, kafka_consumer, redis_client, db_pool):
self.consumer = kafka_consumer
self.redis = redis_client
self.db = db_pool
self.gateways = {
"fcm": FCMGateway(),
"apns": APNsGateway(),
"web_push": WebPushGateway(),
}
async def process_notification(self, notification):
user_id = notification["user_id"]
preferences = await self._get_preferences(user_id)
if not preferences.get(notification["type"], True):
return
devices = await self._get_devices(user_id)
for device in devices:
gateway = self.gateways[device["platform"]]
await gateway.send(
device["push_token"],
notification["payload"],
)
async def _get_preferences(self, user_id: str):
prefs = await self.redis.hgetall(f"prefs:{user_id}")
if not prefs:
prefs = await self.db.fetchrow(
"SELECT preferences FROM users WHERE id = $1", user_id)
prefs = prefs["preferences"]
await self.redis.hset(f"prefs:{user_id}", mapping=prefs)
return prefs
async def _get_devices(self, user_id: str):
rows = await self.db.fetch(
"SELECT * FROM devices WHERE user_id = $1 AND active = true",
user_id)
return [dict(r) for r in rows]
FCM Gateway with Batching
Sending individual HTTP requests for each notification is slow. Batch multiple notifications to the same FCM endpoint.
class FCMGateway:
def __init__(self):
self.session = aiohttp.ClientSession()
self.fcm_url = "https://fcm.googleapis.com/fcm/send"
self.batch_size = 100
async def send(self, token: str, payload: dict, retries: int = 3):
message = {
"to": token,
"notification": {
"title": payload["title"],
"body": payload["body"],
},
"data": payload.get("data", {}),
}
for attempt in range(retries):
try:
async with self.session.post(
self.fcm_url,
json=message,
headers={"Authorization": f"key={FCM_SERVER_KEY}"},
) as resp:
result = await resp.json()
if result.get("success") == 1:
return True
if result.get("failure") == 1:
error = result["results"][0].get("error")
if error == "Unavailable":
await asyncio.sleep(2 ** attempt)
continue
if error == "NotRegistered":
return "unregister"
return False
except aiohttp.ClientError:
await asyncio.sleep(2 ** attempt)
continue
return False
# Expected output from FCM response:
# {"success": 1, "failure": 0, "results": [{"message_id": "msg_123"}]}
# {"success": 0, "failure": 1, "results": [{"error": "NotRegistered"}]}
Rate Limiting and Throttling
FCM and APNs enforce rate limits. Respect them to avoid blacklisting.
class RateLimiter:
def __init__(self, max_per_second: int = 600):
self.max_per_second = max_per_second
self.tokens = max_per_second
self.last_refill = time.time()
async def acquire(self):
now = time.time()
elapsed = now - self.last_refill
self.tokens = min(self.max_per_second,
self.tokens + elapsed * self.max_per_second)
self.last_refill = now
if self.tokens < 1:
wait_time = (1 - self.tokens) / self.max_per_second
await asyncio.sleep(wait_time)
self.tokens = 0
self.tokens -= 1
Common Mistakes
1. No Token Expiry Handling
Push tokens expire. Without refreshing them, you accumulate dead tokens. Track the NotRegistered error from FCM/APNs and mark those devices inactive.
2. Sending Notifications Without User Preference Check
Users who opted out still receive notifications because the sender didn't check preferences. Always filter against user preferences stored in Redis cache before sending.
3. No Batching for Bulk Notifications
Sending one HTTP request per notification creates massive overhead. Batch up to 100 notifications per request when using FCM's multicast endpoint. This reduces latency and HTTP connection overhead.
4. Blocking the Sender Thread
Synchronous HTTP calls to FCM block the entire notification pipeline. Use asynchronous I/O with aiohttp or similar. Process notifications concurrently with a semaphore limiting concurrency.
5. No Exponential Backoff on Gateway Failures
When FCM returns 503, retrying immediately floods the gateway. Use exponential backoff with jitter. Start at 1 second, double each attempt, cap at 60 seconds.
6. Storing Notification Payloads in the Database
Notification payloads are transient. Store them only in the message queue. If you need history, log to a separate analytics system. Don't bloat your transactional database.
Practice Questions
1. How do you handle silent push notifications?
Silent notifications carry data without alerting the user. Set content_available: true on iOS and priority: high on FCM. The app processes the data in the background and can trigger local notifications if needed.
2. How do you deliver notifications to users across multiple time zones?
Store the user's timezone in their profile. For scheduled notifications (like daily digests), convert the user's preferred delivery time to UTC before scheduling. The scheduler runs in UTC but targets the correct local time per user.
3. What happens when FCM is down?
Queue notifications for retry with exponential backoff. After the max retry period, store the notification in a dead letter queue. Consider using a secondary push provider as fallback. Inform the user through in-app messaging if possible.
4. Challenge: Design a notification priority system.
Critical notifications (security alerts, password resets) must bypass silent mode and reach the user immediately. Low-priority notifications (promotions, reminders) can be batched. Design a priority queue with three tiers and different delivery guarantees.
Mini Project: Push Notification Service
import asyncio
import json
import aiohttp
import redis.asyncio as redis
r = redis.Redis(host="localhost", port=6379, db=0)
FCM_KEY = "your-fcm-server-key"
async def send_push(user_id: str, title: str, body: str):
token = await r.get(f"device:{user_id}")
if not token:
print(f"No device for {user_id}")
return
async with aiohttp.ClientSession() as session:
payload = {
"to": token.decode(),
"notification": {"title": title, "body": body},
}
async with session.post(
"https://fcm.googleapis.com/fcm/send",
json=payload,
headers={"Authorization": f"key={FCM_KEY}"},
) as resp:
result = await resp.json()
print(f"Sent: {result}")
async def notification_consumer():
while True:
_, msg = await r.brpop("notifications", timeout=30)
if msg:
data = json.loads(msg)
await send_push(data["user_id"], data["title"], data["body"])
asyncio.run(notification_consumer())
To test:
# Terminal 1: Start the notification service
$ python push_service.py
# Terminal 2: Simulate a notification
$ redis-cli lpush notifications \
'{"user_id": "user_123", "title": "Threat Detected", "body": "Malware blocked"}'
FAQ
What's Next
Congratulations on designing a push notification system! Here's where to go from here:
- Practice daily -- Integrate FCM with a test app
- Build a project -- Create a notification dashboard with delivery analytics
- Explore related topics -- WebSocket for real-time push, Web Push API, notification scheduling
- Join the community -- Share your push notification designs and get feedback
Remember: every expert was once a beginner. Keep pushing!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro