TL;DR

1. The problem: why are notifications hard?

"Maya liked your photo." It seems trivial: when a like happens, send a push. But consider what Instagram has to get right:

🧠 Analogy Think of a great personal assistant handling your mail. They don't run into your meeting every time a letter arrives. They know which senders you've asked to ignore (preferences, mutes), they bundle twenty fan letters into one note ("you got 20 fan letters, including one from Maya"), they write it in your language, they don't call you at 3 a.m. unless the house is on fire (quiet hours, priority), they use the right channel (a text for urgent things, a summary email for the weekly digest), they keep an up-to-date list of your phone numbers (device tokens), and they notice which messages you actually read so they can do better next week (tracking and experiments).

2. Types of notifications and requirements

2.1 The four channels

ChannelHow it reaches the userLatencyCostTypical Instagram use
PushVia Apple Push Notification service (APNs) or Firebase Cloud Messaging (FCM) to the OS, which shows a banner even when the app is closedSeconds (best-effort)Free per message, but costly in user attention — users can disable it with one tapLikes, comments, mentions, follows, DMs, live videos, posts from accounts with post notifications on
In-app activity feedStored server-side; shown in the Activity (♥) tab with an unread badgeVisible next time the user looksStorage onlyEverything, including low-priority items that never get a push
EmailVia an email delivery service (SMTP/API)Seconds to minutesFractions of a cent eachSecurity alerts, password resets, digests of missed activity, re-engagement
SMSVia SMS gateways / carriersSecondsReal money per message (varies by country), fraud riskLogin verification codes, account recovery — rarely anything else

A single event may produce output on several channels: a new comment creates an in-app activity item and (if allowed) a push. A login from a new device produces an in-app security alert, a push and an email.

2.2 Functional requirements

2.3 Non-functional requirements

2.4 Back-of-the-envelope (assumptions)

Input events/day (assumptions): likes 4B, comments 500M, follows 300M,
  mentions/tags 200M, DMs 12B (handled mostly in-chat), lives/new-post subscriptions ~1B fan-out
Notification-worthy social events ≈ 5-6B/day → ~65,000/s average, ~200,000/s peak

After preferences, mutes, aggregation, caps:
  In-app activity writes ≈ 3B/day  (~35k/s; aggregated items are UPSERTS, not new rows)
  Push sends          ≈ 2-3B/day (~30k/s average, ~100k/s+ at peak and during spikes)
  Email               ≈ 100M/day; SMS ≈ 10-20M/day (mostly verification codes)

Push payload ≈ 1-2 KB → 3B × 1.5 KB ≈ 4.5 TB/day of outbound API traffic to APNs/FCM
Tracking events (created, sent, opened, dismissed…) ≈ 5× pushes ≈ 15B/day → data warehouse

The headline: aggregation and filtering cut the event stream roughly in half or more before anything is sent, and tracking data is several times larger than the notifications themselves.

3. Architecture overview

User prefsKV store + cache Activity feedCassandra (in-app) Kafkalike, comment, follow… Notification Svcresolve recipients Prefs & mutesdrop if disabled Aggregator"X and 20 others" Renderertemplate + i18n Rate limitsquiet hours, caps Priority queueshigh / normal / low Delivery workerspush · email · SMS APNs / FCMemail & SMS gateways 📱 Devices Device tokensregistry Tracking events → Kafkasent, invalid token, opened → warehouse lookup tokens in-app item
The notification pipeline: filter early, aggregate, render late, rate-limit, queue by priority, deliver through providers, and feed results back.

Each box is a stage that can be scaled independently, and every stage boundary is a queue or topic (see Message queues & Kafka), so a slow provider never backs up into the services that produce events. Let's walk through the stages.

4. The pipeline, stage by stage

4.1 Ingest: events and recipient resolution

Producer services don't call the Notification Service directly; they publish domain events they would publish anyway — the Like & Comment Service emits like.created, the Follow Service emits follow.created, and so on. The Notification Service is just another consumer group on those Kafka topics.

{ "event_id": "evt_01HF3K9...",          // unique, used for idempotency
  "type": "like.created",
  "actor_id": 4821,                       // maya
  "object": { "type": "post", "id": 3170538912345678, "owner_id": 17 },
  "ts": 1730000000123 }

The first job is recipient resolution — who should hear about this event?

EventRecipients
Like on a postThe post owner (not the liker; never notify yourself)
CommentPost owner + users @mentioned in the comment + (for replies) the parent comment's author
Follow / follow requestThe followed account
Mention / tag in a post or storyEach mentioned/tagged user
New post / live videoFollowers who turned on post/live notifications for that account — a fan-out (section 7)
DMConversation members who are offline (see Direct messaging)
Security (new login, password change)The account owner, on all channels

The output is a stream of (recipient, notification candidate) pairs, partitioned by recipient_id so that all of one user's notifications are processed by the same worker — which makes per-user aggregation, rate limiting and ordering simple and lock-free.

4.2 Preferences and mute checks: filter early

The cheapest notification is the one you drop first. Before any expensive work, check:

Filtering happens per channel: a like might be suppressed for push but still recorded in the activity feed.

4.3 Aggregation and batching

Aggregation turns many similar events into one notification. The aggregation key identifies "the same kind of thing about the same object for the same person":

agg_key = (recipient_id, type, object_id)      e.g. (17, "like", post 3170538912345678)

A common strategy is "first one fast, then batch":

  1. The first like on a post in a window → create the activity item and send a push quickly (users like immediate feedback on the first reaction).
  2. Further likes within the window (say 15–60 minutes) → update the aggregate (count, latest actors) and the in-app item, but don't push yet.
  3. When the window closes — or when the count crosses a milestone (10, 50, 100, 1,000…) — send one aggregated push: "maya, arjun and 48 others liked your post".
  4. The aggregated push uses the same collapse ID as the earlier one, so on the device it replaces the previous banner instead of stacking a new one.
# Aggregation state in Redis (one hash per agg_key), processed by the recipient's worker
HINCRBY agg:17:like:3170538912345678  count 1
LPUSH   agg:17:like:3170538912345678:actors 4821
LTRIM   agg:17:like:3170538912345678:actors 0 2        # keep the 3 most recent actors for display
EXPIRE  agg:17:like:3170538912345678  3600

def on_like(recipient, post, actor):
    key = f"agg:{recipient}:like:{post}"
    count = redis.hincrby(key, "count", 1)
    activity_store.upsert(recipient, key, actor, count)          # in-app item, always fresh
    if count == 1:
        enqueue_push(recipient, key, priority="normal")          # first like: send soon
        schedule_flush(key, delay_s=window_for(recipient))       # delay queue / timer
    elif count in MILESTONES:                                    # 10, 50, 100, 1000 ...
        enqueue_push(recipient, key, priority="low")

def on_flush(key):                                               # window closed
    if redis.hget(key, "count") > redis.hget(key, "count_at_last_push"):
        enqueue_push(recipient_of(key), key, priority="low")     # one summary push

4.4 Template rendering and localization

Notifications are never stored as final text. Each is a structured record (type, actors, count, object, deep link) plus a template ID, rendered as late as possible — at send time for pushes, at read time for the activity feed — so that names, counts and language are current.

# template "like_post_agg" in ICU MessageFormat (handles plurals per language)
en: "{actor1}{count, plural,
        =1 { liked your post.}
        =2 { and {actor2} liked your post.}
        other { and {others, number} others liked your post.}}"
hi: "{actor1}{count, plural, =1 { ने आपकी पोस्ट पसंद की।} other { और {others, number} अन्य लोगों ने आपकी पोस्ट पसंद की।}}"

render(template="like_post_agg", locale="en-IN",
       args={actor1:"maya", actor2:"arjun", count:50, others:48})
→ "maya and 48 others liked your post."

4.5 Rate limits, fatigue and quiet hours

Even after aggregation, an active user can receive a lot. Over-notifying has a real cost: users turn off push or uninstall, and a disabled channel is very hard to win back. Guards:

⚠️ Expiry matters A deferred notification can become wrong: "maya is live now" is useless after the live video has ended, and a comment notification is embarrassing if the comment was deleted. Every notification carries an expiry, and the delivery worker re-validates the object state just before sending. Providers support this too (APNs apns-expiration, FCM ttl): if the device is unreachable until after expiry, the provider drops it.

4.6 Priority queues

Not all notifications are equal, and they must not share a single queue. If a celebrity's post generates 5 million low-value pushes, a login code queued behind them would arrive 10 minutes late. So the pipeline uses separate queues (Kafka topics or queue partitions) per priority, each with dedicated worker capacity — a bulkhead (see Resilience patterns).

PriorityExamplesTarget latencyTreatment
Critical / highLogin/2FA codes, security alerts, DMs, incoming call< 1–2 sNever aggregated or capped; bypass quiet hours; reserved worker capacity; retried aggressively; may fall back to SMS/email
NormalComments, mentions, tags, follows, first likeSecondsAggregated, capped, quiet hours respected
LowLike summaries, milestones, "X posted for the first time in a while", suggestionsMinutes to hoursHeavily aggregated, deferrable, first to be shed under load
Bulk / scheduledCelebrity fan-out, digests, re-engagement emailsMinutes; spread over timeThrottled, staggered, run on separate infrastructure

Workers pull from the high-priority queue first (strict priority) or use weighted scheduling so that low-priority work still progresses. A delay queue (e.g. a sorted set keyed by send-time, or a scheduler service) holds deferred items for quiet hours and aggregation windows.

4.7 Delivery workers and the push providers

Delivery workers take a rendered notification, look up the recipient's device tokens, call the right provider for each device, and report outcomes. You can't push to a phone directly — the OS vendors run the only connection that can wake a sleeping device:

Delivery workerpayload + token
→HTTP/2 + JWT
APNsApple
→OS-held connection
📱 iPhonebanner
Delivery workerpayload + token
→HTTPS, FCM v1 API
FCMGoogle
→OS-held connection
📱 Androidbanner
# APNs request (HTTP/2) — one stream per notification, many streams per connection
POST /3/device/<device_token>
authorization: bearer <JWT signed with the app's .p8 key>
apns-topic: com.example.instagram
apns-push-type: alert
apns-priority: 10                       # 10 = immediate, 5 = power-friendly
apns-expiration: 1730003600             # drop if not deliverable by then
apns-collapse-id: like:3170538912345678 # newer push with same id replaces older
apns-id: 6f1c2d4e-...                   # our notification id (UUID)
{ "aps": { "alert": { "title": "Instagram", "body": "maya and 48 others liked your post." },
           "badge": 7, "sound": "default", "thread-id": "post-3170538912345678",
           "mutable-content": 1 },
  "deeplink": "instagram://media?id=3170538912345678", "nid": "n_8e1f..." }

# Responses the worker must handle
200                      → accepted by APNs (NOT the same as displayed!)
400 BadDeviceToken       → token malformed / wrong environment → delete token
410 Unregistered         → app uninstalled / token invalid      → delete token
429 TooManyRequests      → back off and retry
500 / 503                → provider trouble → retry with exponential backoff

5. The device token registry

A device token is an opaque string issued by APNs/FCM to one installation of the app on one device; it's the "address" for push. Users have several devices, tokens change, and apps get uninstalled — so tokens need a well-maintained registry.

device_tokens  (partition key: user_id)
  user_id      bigint
  device_id    text        -- stable per installation (generated by the app)
  platform     text        -- ios | android | web
  token        text        -- APNs / FCM token (unique index token → user for reassignment)
  environment  text        -- production | sandbox (APNs)
  app_version  text
  locale       text        -- e.g. "hi-IN"   (used for rendering)
  timezone     text        -- e.g. "Asia/Kolkata" (used for quiet hours)
  push_enabled boolean     -- OS permission as last reported by the app
  last_seen_at timestamp
  PRIMARY KEY (user_id, device_id)

5.1 Token lifecycle

  1. Register: on every app start (and when the OS reports a new token), the app calls PUT /devices/{device_id} with its current token, permission state, locale and time zone. Upsert is idempotent.
  2. Rotate: tokens can change after OS updates, app reinstalls or restores; the newest registration for a device_id wins.
  3. Reassign: if user A logs out and user B logs in on the same phone, the same token now belongs to B. The unique token index lets registration remove it from A — otherwise A's notifications would appear on B's phone, a privacy bug.
  4. Logout: delete the device's token row (and tell the app to stop showing cached notifications).
  5. Prune on feedback: when APNs answers 410 Unregistered/BadDeviceToken or FCM answers UNREGISTERED, the delivery worker emits a token.invalid event and the registry deletes that token.
  6. Prune stale tokens: a periodic job removes tokens not seen for, say, 60–90 days — they almost certainly belong to abandoned installs and waste send capacity.

Why pruning matters: without it, a growing fraction of every send goes to dead tokens. If 30% of tokens are dead, 30% of provider calls and worker capacity are wasted, and providers may view high invalid-token rates as abusive. Reads of the registry are a single-partition lookup per recipient (cached), so it scales simply by sharding on user_id.

6. Reliability: retries, dedup and ordering

6.1 Retries with backoff

Failures happen at every step: a worker crashes mid-batch, APNs returns 503, a network blip times out a request. The rules (see Resilience patterns):

6.2 Deduplication with idempotency keys

Kafka consumers are at-least-once: after a crash, a worker reprocesses events whose offsets weren't committed, and a retry after a lost provider response may resend something that was actually delivered. Users must not get the same push twice. Layers of dedup:

  1. Event-level: the Notification Service records processed event_ids (or makes processing naturally idempotent — e.g. adding actor 4821 to an aggregate set twice has no effect).
  2. Notification-level idempotency key: each outgoing notification gets a deterministic ID, e.g. nid = hash(recipient, type, object_id, aggregation_window, version). Before sending, the worker does SET sent:{nid} 1 NX EX 86400 in Redis; if the key already exists, the send is skipped. Deterministic means a reprocessed event produces the same nid, so the check catches it.
  3. Device-level: the same apns-collapse-id / FCM collapse_key makes a duplicate replace rather than stack; the app can also ignore an nid it has already displayed.
💡 Mark before or after sending? If you set the "sent" marker before calling APNs and then crash, the notification is never sent (at-most-once). If you set it after and crash in between, it may be sent twice (at-least-once). For social notifications, prefer a short "in-progress" lease before sending and "sent" after — a crash causes at most a rare duplicate that collapse IDs then hide. For critical codes, prefer at-least-once: a duplicate login code is harmless; a missing one locks the user out.

6.3 Ordering

Global ordering of notifications is neither possible (providers don't guarantee order) nor needed. What matters:

7. Celebrity events: millions of notifications at once

Two different celebrity problems hit the notification system, one on each side.

7.1 Outbound: a celebrity posts or goes live

Followers can turn on "post notifications" or "live notifications" for an account. For an account with 400M followers, assume 2% did → 8 million pushes for one event. Doing this naively has three failure modes: it floods the shared queues (delaying everyone's DMs), it can exceed provider throughput, and — most dangerously — it makes millions of people open the app in the same minute, all requesting the same post, the feed, the profile and the media: a self-inflicted thundering herd.

  1. Maintain a subscriber list per account (users who enabled post/live notifications) instead of scanning all 400M followers and checking each one's settings.
  2. Create a fan-out job on the bulk queue, never on the normal/high queues. The job pages through subscribers (e.g. 10,000 per page) and emits per-recipient tasks.
  3. Render once per locale, not once per recipient: group recipients by locale and reuse the payload ("taylor just went live" in ~50 languages).
  4. Throttle with a token bucket per job (e.g. 50,000 sends/s) and globally across concurrent jobs.
  5. Stagger and prioritise: send first to the most engaged subscribers (most likely to open; for live video, to start the audience quickly), and spread the rest over several minutes; respect quiet hours per time zone.
  6. Prepare the backend: pre-warm the CDN with the post's media and cache the post object before the first push goes out.
  7. Re-check before each page: if the post was deleted or the live ended mid-fan-out, stop the job.
8M recipients at a throttle of 50,000/s  → 160 s to send everything
If 10% open within ~2 minutes of receiving:
   unstaggered → 800k app opens in ~2 min ≈ 6,700 opens/s extra, each = feed + post + media requests
   staggered over 10 min → ~1,300 opens/s extra — absorbed by normal headroom

FCM also offers topic messaging (devices subscribe to a topic; one API call fans out inside Google), which offloads fan-out for broadcast-style messages, at the cost of less per-user control (no per-user caps, personalisation or tracking granularity). APNs has no equivalent for regular alerts, so each device is a separate (cheap, multiplexed) HTTP/2 request.

7.2 Inbound: a celebrity receives a million likes

When a celebrity's post gets 1M likes in 10 minutes, all those events target one recipient. Because the pipeline partitions by recipient_id, they all land on one Kafka partition and one worker — a hot partition (see hot keys). Fixes:

8. Delivery and open tracking

You can't improve what you don't measure, and providers tell you little. The system emits a tracking event at each step, keyed by nid:

EventEmitted byMeaning
createdNotification ServiceA candidate notification exists for this recipient
suppressed (+ reason)Prefs / aggregator / rate limiterDropped: setting off, muted, capped, aggregated into another, expired, quiet hours
sent / provider_acceptedDelivery workerAPNs/FCM returned success
failed (+ error)Delivery workerInvalid token, throttled, provider error
deliveredThe app (notification service extension / message handler), reported back asynchronouslyThe device actually received it
openedThe app, when launched via a notification tap (the payload carries nid)User engaged
dismissed, disabledThe app (where the OS exposes it) / settings changeNegative feedback — the most important guardrail

All events go to Kafka and then to the data warehouse and real-time dashboards (see Analytics and Observability). Key metrics: delivery rate (delivered/sent — exposes dead tokens and provider issues), open rate (opened/delivered, per type), end-to-end latency (event ts → provider accepted, per priority), suppression breakdown, and opt-out rate. These same events are training data for the relevance/fatigue models in section 4.5.

9. The in-app activity feed

The Activity tab is a per-user, reverse-chronological list of aggregated items with an unread badge — structurally a tiny news feed with only one writer per item (the notification pipeline) and one reader (the user).

-- Item details: one row per aggregation key; UPSERTed as new actors arrive
CREATE TABLE activity_items (
  user_id     bigint,
  agg_key     text,          -- "like:3170538912345678", "follow:2024-10-23", "comment:...:c991"
  type        text,
  actors      list<bigint>,  -- latest 3 actors for display
  actor_count int,
  object_ref  text,          -- post / comment / user being referenced
  created_at  timestamp,
  updated_at  timestamp,
  PRIMARY KEY (user_id, agg_key)
) WITH default_time_to_live = 7776000;      -- 90 days

-- Time index: append a row each time an item is updated; read newest-first
CREATE TABLE activity_timeline (
  user_id    bigint,
  updated_at timestamp,
  agg_key    text,
  PRIMARY KEY (user_id, updated_at, agg_key)
) WITH CLUSTERING ORDER BY (updated_at DESC) AND default_time_to_live = 7776000;

10. Experimentation

Almost every parameter in this pipeline is a product decision with uncertain effects: How long should the like-aggregation window be? Which copy gets more opens: "maya liked your post" or "maya liked your photo from Goa"? Should we push "suggested account" notifications at all? Notification systems are therefore built for A/B testing:

11. End-to-end sequence: a like becomes a push

 Maya app   Like Svc   Kafka      Notif Svc    Prefs    Aggregator   Activity   Renderer +   Priority   Delivery   Token     APNs     Raj's iPhone
    |  like   |          |        (consumer)   cache    (Redis)       store      rate limit   queue      worker     registry
    |-------->|-- write like, publish like.created ------->|            |            |            |          |          |         |           |
    |<-- 200 -|          |-- event (partition = recipient 17 = Raj) -> |            |            |          |          |         |           |
    |         |          |          |-- likes push? muted maya? ------->|            |            |          |          |         |           |
    |         |          |          |<-- allowed --|          |          |            |            |          |          |         |           |
    |         |          |          |-- add actor to agg:17:like:P ---->|            |            |          |          |         |           |
    |         |          |          |<-- count = 1 (first like) --------|            |            |          |          |         |           |
    |         |          |          |-- upsert in-app item --------------------------->|          |          |          |         |           |
    |         |          |          |-- render "maya liked your post." (Raj's locale) ----------->|          |          |         |           |
    |         |          |          |   check per-user cap, quiet hours (Raj's timezone) → OK     |          |          |         |           |
    |         |          |          |-- enqueue {nid, priority: normal} --------------------------->|          |          |         |           |
    |         |          |          |   schedule flush of agg:17:like:P in 30 min                  |-- pull -->|          |         |           |
    |         |          |          |          |              |             |            |          |  SETNX sent:nid → OK |         |           |
    |         |          |          |          |              |             |            |          |-- tokens(17) ------>|         |           |
    |         |          |          |          |              |             |            |          |<-- 2 devices --------|         |           |
    |         |          |          |          |              |             |            |          |-- POST /3/device/… (HTTP/2) ---->|        |
    |         |          |          |          |              |             |            |          |<-- 200 (device 1) / 410 (device 2) |        |
    |         |          |          |          |              |             |            |          |-- token.invalid(device 2) → prune  |        |
    |         |          |          |          |              |             |            |          |-- tracking: sent ──► Kafka         |--push->|
    |         |          |          |          |              |             |            |          |          |          |  Raj taps banner → app opens
    |         |          |          |          |              |             |            |          |          |          |  deep link → post; tracking: opened(nid)
    |   ... 30 min later, 48 more likes arrived: flush → ONE push "maya and 48 others liked your post." (same collapse id → replaces banner)

12. How Instagram uses the notification system

In the main architecture diagram, the Notification Service is a consumer of the Kafka event bus, alongside the Search Indexer, Feed Fan-out Workers, Media Processing Workers and Analytics. It sends through Push Providers (APNs/FCM) to phones. Concretely:

  1. The Like & Comment, Follow/Graph, Upload, Stories and Direct Messaging services publish events; none of them know notifications exist — adding a new notification type means adding a consumer rule, not changing producers.
  2. The Notification Service resolves recipients, checks preferences and relationships, aggregates, writes the in-app Activity item, renders in the recipient's language, applies caps and quiet hours, and queues by priority.
  3. Delivery workers fetch tokens from the device registry and call APNs/FCM; invalid tokens are pruned; outcomes go back to Kafka.
  4. When a user taps a push, the app deep-links to the post/comment/profile, loading data through the normal API path and media through the CDN.
  5. Tracking data feeds Analytics & ML, which trains models deciding which notifications are worth sending.
📸 In Instagram Instagram's public settings expose exactly the controls this design needs: per-category push settings (likes, comments, follows, DMs, live, reminders) with options such as "Off / From people I follow / From everyone", per-account post/story/reel/live notification toggles, "Pause all", and muting. The Activity tab shows aggregated items like "maya and 48 others liked your post". Instagram's early engineering talks described sending push notifications through asynchronous task queues rather than in the request path; today Meta runs shared notification infrastructure across its apps. Internal details (thresholds, models, exact volumes) are not public — all numbers here are illustrative assumptions.
📸 Rough numbers for an Instagram-scale notification system (assumptions)

13. Trade-offs & common pitfalls

✅ Asynchronous, staged pipeline: benefits

  • Producers are fully decoupled; notification outages never block likes or posts
  • Early filtering and aggregation cut volume dramatically
  • Priority queues guarantee critical messages aren't stuck behind bulk work
  • Each stage scales and fails independently
  • Late rendering gives correct names, counts and language
  • Full funnel tracking enables tuning and experiments

⚠️ Costs

  • Aggregation and quiet hours deliberately add delay
  • At-least-once processing requires careful dedup at several layers
  • Dependence on third-party providers with limited delivery feedback
  • Many interacting knobs (caps, windows, models) are hard to reason about
  • Hot recipients and celebrity fan-outs need special paths
  • Tracking data volume exceeds notification volume

Common pitfalls

14. Real-world technologies

TechnologyRoleWhen to use it
Apple Push Notification service (APNs)The only way to push to iOS/macOS devicesAny iOS app; HTTP/2 + token (JWT) auth
Firebase Cloud Messaging (FCM)Push to Android (and web/iOS via APNs bridging); topicsAny Android app; topic messaging for broadcasts
Web Push (VAPID)Push to browsers via their push servicesWeb clients
Amazon SNS, Azure Notification HubsManaged multi-platform push fan-outWhen you don't want to manage provider connections yourself
OneSignal, Braze, Airship, IterableHosted engagement platforms: segmentation, templates, A/B tests, analyticsMarketing/lifecycle messaging; smaller teams
Amazon SES, SendGrid, MailgunEmail delivery with bounce/complaint feedbackTransactional email and digests
Twilio, Vonage, MessageBirdSMS (and WhatsApp/voice) gatewaysVerification codes, critical alerts
KafkaEvent ingestion, priority topics, tracking eventsThe backbone of the pipeline
RedisAggregation state, idempotency keys, per-user rate limits, delay queuesFast, TTL-based soft state
Cassandra / DynamoDBIn-app activity feed, token registry, preferencesLarge, per-user partitioned data
ICU MessageFormat / FormatJS / FluentLocalization with plurals and genderRendering text in dozens of languages

15. Interview corner

Design the notification system for Instagram.

Event-driven pipeline consuming Kafka domain events (like, comment, follow, mention, live, DM, security). Stages: recipient resolution → preference/mute/block checks (cached per-user settings) → aggregation keyed by (recipient, type, object) with "first fast, then batch" windows and milestones → write/update the in-app activity item (Cassandra, partitioned by user_id, TTL 90 days) → late template rendering with ICU plurals in the recipient's locale → per-user caps and quiet hours (Redis token buckets, delay queue) → priority queues (critical / normal / low / bulk) → delivery workers that look up device tokens and call APNs (HTTP/2) and FCM, with collapse IDs and expiry → feedback: prune invalid tokens, emit sent/delivered/opened events for metrics, ML and experiments. Partition by recipient_id for per-user consistency; idempotency keys for dedup; special paths for celebrity fan-out and hot recipients.

How do you avoid sending duplicate notifications?

Assume at-least-once everywhere. Make processing idempotent (adding an actor to an aggregate set twice is a no-op), give every notification a deterministic ID derived from (recipient, type, object, window, version) and check-and-set it in Redis before sending, use provider collapse IDs so any duplicate that slips through replaces the earlier banner, and let the client ignore an nid it already displayed. Decide per priority whether a crash should risk duplicates (critical codes) or loss (low-value likes).

How would you implement "maya and 20 others liked your post"?

Aggregate by key (recipient, "like", post_id): store count and the latest few actor IDs in Redis (or the activity item). The first like triggers a quick push and schedules a flush; subsequent likes update the count and the in-app item without pushing; at window end or milestones, send one summary push with the same collapse ID so it replaces the earlier banner. Render with a plural-aware template at send time so names and counts are current; handle unlikes by decrementing and cancelling if zero.

A celebrity with 300M followers goes live. How do you notify subscribers without breaking anything?

Keep a separate subscriber list (users who opted into live notifications) — say 2–3% → ~6–9M recipients. Run a fan-out job on an isolated bulk queue, paging subscribers, rendering once per locale, throttled by a token bucket, ordered by engagement so the likeliest viewers get it first, staggered over a few minutes and respecting quiet hours. Pre-warm caches/CDN for the live stream; stop the job if the live ends; set short expiry so late pushes are dropped. Critical queues keep their reserved capacity throughout.

How do you manage device tokens?

A registry partitioned by user_id with rows per device: token, platform, environment, app version, locale, time zone, push permission, last_seen. Apps upsert on every start and on token refresh; a unique index on token handles reassignment between accounts on the same device; logout deletes. Delivery workers delete tokens when APNs returns 410/BadDeviceToken or FCM returns UNREGISTERED, and a periodic job prunes tokens not seen for 60–90 days.

How do you make sure a 2FA code isn't delayed by a flood of like notifications?

Priority isolation: critical notifications go on a dedicated topic/queue with reserved worker capacity and separate provider connections, skip aggregation, caps and quiet hours, use high APNs priority, retry aggressively with short backoff, and fall back to SMS/email if not confirmed. Bulk and low-priority work runs on separate queues and is the first to be shed under load.

How do you know if notifications are working well?

Track a per-nid funnel: created → suppressed (with reason) → sent → provider accepted → delivered (reported by the app) → opened → dismissed/disabled. Monitor delivery rate, end-to-end latency per priority, provider error rates, open rate per type, and opt-out/uninstall rates as guardrails. Run A/B tests on windows, caps, copy and timing with long-term holdouts, because short-term open-rate gains can hide long-term fatigue.

16. Check your understanding

Why is the Notification Service a Kafka consumer instead of being called directly by the Like Service?

Decoupling: likes must succeed fast even if notifications (or APNs) are slow or down. Kafka buffers events durably, lets the Notification Service scale and retry independently, and lets other consumers (analytics, feed, search) use the same events. Producers don't need to know notification rules.

A post gets 2,000 likes in an hour. With a "first like immediately, then a summary at milestones 10, 100, 1,000 and at window end" rule, how many pushes does the owner receive?

At most 5: first like, milestones 10, 100 and 1,000, plus the window-end summary (2,000) — and with a shared collapse ID, only the latest is visible on the lock screen. Compare with 2,000 pushes without aggregation.

APNs returns HTTP 200 for a push. Can you conclude the user saw it?

No. 200 only means APNs accepted it. The device may be offline (APNs keeps just the latest notification per app and drops it after expiry), notifications may be disabled or silenced, or a newer push may have collapsed it. Delivery and opens must be reported by the app itself.

Scenario: users report receiving another person's notifications after that person logged out on a shared phone. What's the bug and the fix?

The device token is still registered to the old account. Fix: delete the device's token row on logout, and on registration enforce token uniqueness — if the token already belongs to another user, move it to the new user. Also clear displayed notifications on logout.

8M subscribers must be notified at a 40,000/s throttle. How long does it take, and why might you deliberately go slower?

8,000,000 / 40,000 = 200 s. Going slower (staggering over ~10 minutes) spreads the resulting app opens — each one a burst of feed, post and media requests — so the backend and CDN aren't hit by a self-inflicted thundering herd, and it leaves provider and worker capacity for other notifications.

Why render notification text at send time instead of when the event is first processed?

Aggregates keep changing (count, latest actors), users may rename themselves, the recipient may change language, and deferred notifications (quiet hours) can be sent hours later. Storing a structured record and rendering late ensures correct, current, localized text — and lets experiments change copy without reprocessing.

All like events for a mega-celebrity land on one Kafka partition. Why, and what do you do?

The pipeline partitions by recipient_id to keep per-user processing consistent, so one recipient = one partition = one worker — a hot partition. Pre-aggregate upstream (stream processor counting likes per post per minute), use special rules for very large accounts (summaries only), and use sharded counters for the aggregate.

Which errors should a delivery worker retry: (a) APNs 503, (b) APNs 410 Unregistered, (c) FCM 429, (d) payload too large?

Retry (a) and (c) with exponential backoff and jitter (honouring Retry-After). (b) is permanent → prune the token. (d) is a bug in rendering → drop, log and alert; retrying will never succeed.

🎯 Key takeaways

Related lessons