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

Swiggy delivery push notification arrives

You did not refresh. Something pushed.

Notification system architecture from event to device

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

ChannelProvider examplesLatencyCostBest for
**Push**FCM, APNs1–5 secFreeOrder updates, chat
**SMS**Twilio, MSG91, AWS SNS5–30 sec₹0.15–0.25/msgOTP, fallback
**Email**AWS SES, SendGrid10–60 sec~₹0.01/emailReceipts, 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

QuestionAnswer
"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.