Skip to content

Design a Push Notification System — System Design Guide

DodaTech Updated 2026-06-24 8 min read

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 is the difference between FCM and APNs?

FCM (Firebase Cloud Messaging) handles Android push notifications. APNs (Apple Push Notification Service) handles iOS. FCM uses HTTP/2 or legacy HTTP. APNs uses HTTP/2 with TLS. FCM supports topics and conditions for multicast. APNs supports per-device notifications only.

How do you handle notification delivery receipts?

FCM and APNs return delivery acknowledgments. Store each notification's delivery status in a time-series database. Track delivery rate, open rate, and click-through rate per notification type. Use this data to optimize delivery timing and content.

What is the ideal batch size for FCM multicast?

FCM supports up to 500 devices per multicast request. The optimal batch size is 100-200. Larger batches increase the impact of a single failure. Each device in the batch gets an individual result, so you can retry only the failed ones.

What's Next

Design a Distributed File Storage System
Design a Metrics & Monitoring System
Content Delivery Network

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