SYSTEM DESIGN:Lesson 17: When Your App Needs to Ping Users
Mastering lesson 17: when your app needs to ping users concepts and implementation.
Your Swiggy order arrived — but how did your phone know?
The delivery partner marks "delivered" at 8:14 PM. Four seconds later your phone buzzes: "Enjoy your biryani! Rate your order." You did not refresh the app. You did not poll a server every two seconds. Something pushed that message to you.
That is a notification system — take an event ("order delivered"), pick a channel (push, SMS, email), respect user preferences, and deliver through a third-party provider (FCM, Twilio, SES). Every app with "order updates" or "payment received" builds some version of this.
This chapter covers the core design: push vs pull, channels, event pipeline, templates, preferences, idempotency, and API shape. Fan-out to ten million users is the next lesson.

Swiggy delivery push notification arrives
You did not refresh. Something pushed.
Notification system architecture from event to device
Requirements — functional and non-functional
Functional
- Send notification — given user + event type + payload, deliver on appropriate channel(s)
- Multi-channel — push (mobile), SMS, email (at minimum)
- Templates — "Your order #{order_id} is {status}" with variable substitution
- User preferences — opt out of marketing; allow transactional SMS only
- Delivery status — track sent, delivered, failed per notification
- Idempotency — same event must not send duplicate notifications
Non-functional
- Low latency for transactional — "payment failed" should arrive in seconds, not minutes
- Reliability — at-least-once delivery with dedup on the consumer side
- Provider rate limits — FCM and SMS gateways cap requests/sec
- Scale hint — one user, one notification is easy; IPL score alert to 10M users is not
Clarify: are we building Swiggy transactional alerts or a marketing blast platform? I commit to transactional + templates + preferences + push/SMS/email. Skip in-app websocket unless asked.
Clarifying with the interviewer (say this out loud)
You: "Example flow — order delivered event, user gets push notification?"
-> Interviewer: "Yes, like Swiggy or Zomato."
You: "Which channels? Push, SMS, email — all three?"
-> Interviewer: "Push primary, SMS fallback if push fails."
You: "Do users control preferences — marketing off, order updates on?"
-> Interviewer: "Yes."
You: "What latency for transactional — seconds or minutes?"
-> Interviewer: "Under 10 seconds."
You: "I will cover event queue, worker, template engine,
idempotency keys, and provider integration. Scale fan-out next."
Push vs pull — why your phone does not poll Swiggy every second
Pull model
App calls GET /notifications every N seconds. Simple, wasteful, battery killer. Works for email clients; bad for "your UPI payment succeeded."
Push model
Server initiates delivery when event happens. Mobile uses FCM (Firebase Cloud Messaging) on Android and APNs on iOS. Server sends to FCM; FCM delivers to device.
Swiggy backend -> FCM -> your phone (even if app is killed)
Every modern notification system is push-first for real-time, pull as supplement (notification inbox API).
Channels — push, SMS, email
| Channel | Provider examples | Latency | Cost | Best for |
|---|---|---|---|---|
| **Push** | FCM, APNs | 1–5 sec | Free | Order updates, chat |
| **SMS** | Twilio, MSG91, AWS SNS | 5–30 sec | ₹0.15–0.25/msg | OTP, fallback |
| **Email** | AWS SES, SendGrid | 10–60 sec | ~₹0.01/email | Receipts, digests |
Channel selection logic:
def pick_channels(user, event_type) -> list[str]:
prefs = get_preferences(user.id)
if event_type == "order_delivered":
channels = []
if prefs.push_enabled:
channels.append("push")
if prefs.sms_enabled and "push" not in channels:
channels.append("sms") # fallback
return channels
if event_type == "marketing_offer" and not prefs.marketing_opt_in:
return []
return ["email"]
Transactional notifications (order, payment) bypass marketing opt-out. Legal and product teams care about this distinction — mention it.
High-level pipeline — event to device
Event source -> Notification API -> Queue -> Worker -> Provider (FCM/SES/SMS) -> User device
| |
+-> Idempotency check +-> Delivery status DB
+-> Template render
+-> Preference filter
Never call FCM synchronously from the order service. Order marking "delivered" should return in 50 ms. Notification is async — fire event, return.
# In order service — do NOT block on FCM
def mark_delivered(order_id: str, user_id: str):
db.update_order_status(order_id, "delivered")
event_bus.publish("order.delivered", {
"order_id": order_id,
"user_id": user_id,
"idempotency_key": f"order-delivered-{order_id}",
})
return {"status": "delivered"}
Event queue — decouple producers from delivery
Use SQS, RabbitMQ, or Redis Streams for the MVP:
{
"event_type": "order.delivered",
"user_id": "usr_abc123",
"payload": {
"order_id": "ord_xyz789",
"restaurant": "Meghana Biryani",
"total": 450
},
"idempotency_key": "order-delivered-ord_xyz789",
"created_at": "2026-08-28T20:14:00Z"
}
Queue absorbs spikes — dinner rush at 8 PM sends 50K events/minute; workers drain at their pace without crashing the order API.
Same decoupling pattern as click analytics in URL Shortener Core and crawl events in Web Crawler at Scale.
Worker — template, preferences, send
def process_notification_event(event: dict):
if already_sent(event["idempotency_key"]):
return # duplicate event — skip
user = get_user(event["user_id"])
channels = pick_channels(user, event["event_type"])
if not channels:
log_skipped(event, reason="preferences")
return
body = render_template(event["event_type"], event["payload"])
for channel in channels:
provider = get_provider(channel)
result = provider.send(user, body, metadata=event["payload"])
record_delivery(event["idempotency_key"], channel, result)
One worker process handles hundreds of notifications per minute on a single server. Scale workers horizontally in Lesson 18.
Templates — one definition, many messages
Store templates separately from code:
{
"event_type": "order.delivered",
"push": {
"title": "Order delivered!",
"body": "Enjoy your meal from {{restaurant}}. Tap to rate."
},
"sms": {
"body": "Swiggy: Your order from {{restaurant}} (Rs {{total}}) was delivered."
},
"email": {
"subject": "Your Swiggy order is here",
"body_html": "<p>Order {{order_id}} delivered...</p>"
}
}
def render_template(event_type: str, payload: dict) -> dict:
tmpl = template_store.get(event_type)
return {
channel: render_string(tmpl[channel], payload)
for channel in tmpl
}
Product teams edit copy without deploys. Version templates (order.delivered.v2) for A/B tests.
User preferences
CREATE TABLE notification_preferences (
user_id TEXT PRIMARY KEY,
push_enabled BOOLEAN DEFAULT true,
sms_enabled BOOLEAN DEFAULT true,
email_enabled BOOLEAN DEFAULT true,
marketing_opt_in BOOLEAN DEFAULT false,
quiet_hours_start TIME, -- no push between 11 PM and 7 AM
quiet_hours_end TIME,
updated_at TIMESTAMPTZ
);
Cache preferences in Redis (prefs:{user_id}) — every notification read would crush Postgres.
Device tokens for push live separately:
CREATE TABLE device_tokens (
user_id TEXT,
device_token TEXT,
platform TEXT, -- ios | android
PRIMARY KEY (user_id, device_token)
);
Users have multiple devices (phone + tablet). Send push to all active tokens; prune invalid tokens when FCM returns NotRegistered.
Idempotency — never double-charge attention
Network retries duplicate events. Order service retries publish on timeout; worker must not send two "delivered" pushes.
Idempotency key = unique per logical notification:
order-delivered-{order_id}
payment-failed-{payment_id}
otp-{request_id}
CREATE TABLE sent_notifications (
idempotency_key TEXT PRIMARY KEY,
user_id TEXT,
event_type TEXT,
channels_sent TEXT[],
sent_at TIMESTAMPTZ DEFAULT now()
);
def already_sent(key: str) -> bool:
return db.exists("sent_notifications", idempotency_key=key)
def mark_sent(key: str, user_id: str, channels: list):
db.insert("sent_notifications", ...) # unique constraint catches races
Insert-before-send, not after. If two workers race, one wins the unique constraint; the other skips.
Provider integration — FCM example
def send_push(user_id: str, title: str, body: str, data: dict):
tokens = get_device_tokens(user_id)
for token in tokens:
response = fcm.send(
message={
"token": token,
"notification": {"title": title, "body": body},
"data": data,
}
)
if response.error == "NotRegistered":
delete_device_token(user_id, token)
FCM handles APNs routing for iOS. You talk to one API. Rate limits apply — batch sends where possible.
Protect FCM calls with a rate limiter — Rate Limiter Algorithms and Rate Limiter Architecture cover token bucket and sliding window patterns you apply per provider.
API design
Send notification (internal — service-to-service)
POST /api/v1/notifications
Authorization: Bearer <service_token>
Idempotency-Key: order-delivered-ord_xyz789
{
"user_id": "usr_abc123",
"event_type": "order.delivered",
"payload": {
"order_id": "ord_xyz789",
"restaurant": "Meghana Biryani",
"total": 450
}
}
202 Accepted
{ "notification_id": "ntf_001", "status": "queued" }
Return 202 immediately — delivery is async.
Get delivery status
GET /api/v1/notifications/ntf_001
200 OK
{
"notification_id": "ntf_001",
"status": "delivered",
"channels": [
{ "channel": "push", "status": "delivered", "delivered_at": "..." }
]
}
User preferences (client-facing)
PUT /api/v1/users/me/notification-preferences
{ "marketing_opt_in": false, "push_enabled": true }
Register device token (mobile app)
POST /api/v1/users/me/device-tokens
{ "token": "fcm_token_...", "platform": "android" }
Delivery status lifecycle
queued -> processing -> sent -> delivered
\-> failed -> retry (max 3) -> dead_letter
Store status per channel per notification. "Sent" means provider accepted; "delivered" means provider callback confirmed (FCM delivery receipt where available).
Napkin math — single server MVP
Swiggy-scale is not your MVP. Start with:
10,000 notifications/day
10,000 / 86,400 ~ 0.12/sec average
Peak dinner rush (10x): ~1.2/sec
One worker + one SQS queue handles this easily
FCM free tier covers millions of messages. SMS cost dominates if you lean on SMS:
10,000 SMS/day x Rs 0.20 = Rs 2,000/day if every notification is SMS
Push-first saves real money
What a single-server MVP looks like
- Order service publishes to SQS/RabbitMQ
- One Python worker: dequeue → template → FCM/SES
- Postgres for preferences, device tokens, sent_notifications
- Redis cache for preferences
- FastAPI for internal send API + preference endpoints
- Deploy on Railway or single EC2 (~₹2,500/month)
Ship this for your startup before you design a fan-out engine for ten million IPL alerts.
Security and compliance (mention briefly)
- PII in payloads — do not put full card numbers in notification body
- OTP SMS — separate high-priority queue; shorter retry window
- Marketing — require explicit opt-in (TRAI DND rules in India)
- Service auth — only internal services call POST /notifications
Interview traps on the core design
| Question | Answer |
|---|---|
| "Why not call FCM from order service?" | Blocks order API; no retry; no template layer |
| "User has 3 devices?" | Send to all tokens; prune dead ones |
| "Push failed?" | Fallback to SMS if preferences allow |
| "Duplicate event?" | Idempotency key + unique constraint |
| "Quiet hours?" | Queue until window opens, or skip non-urgent |
Preview: why the next chapter exists
One Swiggy order → one user → one push. Fine.
IPL final: Cricbuzz sends "CSK wins!" to 10 million users in 60 seconds. That is fan-out — queue partitioning, thousands of workers, provider rate limits, dead-letter queues, and dedup at scale.
Continue here: Notification System at Scale.