Design problems
AI-era systemsMediumSDE-2 30 min

Design ML-personalised notifications

Asked at
Dream11FlipkartLinkedInMeeshoSwiggyZomato

The interviewer asks

“Our food and grocery app sends the same promotional pushes to everyone, and users are disabling notifications. Design a system that uses ML to decide which notification each user gets, on which channel, at what time and how often, for 100M users, while still delivering transactional messages instantly.”

What a strong answer looks like

A strong answer separates transactional notifications (order updates: always sent, never ranked) from promotional ones, which compete for a small per-user daily budget. Candidates come from two sources: batch campaigns created by marketers and real-time triggers (cart left, price drop). A ranking model predicts, for each user and candidate, the chance of an open, of a conversion, and of a negative outcome (mute, disable, uninstall); a utility score combines them, and a per-user budget and frequency caps pick the best one or two per day. A send-time model picks each user's best hour, and the content is re-validated just before sending (no 'complete your cart' after the user already ordered). Features come from a feature store with the same definitions online and offline. Strong candidates measure incrementality with holdout groups instead of counting attributed clicks, explore with bandits and log propensities, use LLMs offline to write copy templates in many languages with facts filled from data, and respect channel cost and Indian rules (DLT-registered SMS templates, WhatsApp per-message pricing, quiet hours).

Concepts to mention

transactional vs promotional separationutility = value − fatigue/opt-out cost − channel costper-user notification budget and frequency capssend-time optimisationre-validation at send timefeature store with point-in-time joins (no training-serving skew)holdout groups for incrementality, uplift modellingexploration with logged propensitiesLLM copy offline with reviewed templatespacing and idempotent sends

1 Requirements

Functional

  • Marketers create campaigns (audience rules, content variants, channels, value per conversion, start/end)
  • Real-time triggers: cart abandoned, price drop on wishlisted item, restaurant back online
  • For each user choose which promotional notifications to send, when, and on which channel (push, in-app, WhatsApp, SMS, email)
  • Frequency caps per user per day/week and per category; quiet hours by user's local time
  • Transactional notifications (OTP, order status) bypass ranking and caps and are delivered immediately
  • Copy variants in 10+ Indian languages; the system picks the variant per user
  • Measure impact: opens, conversions, opt-outs and incremental orders versus a holdout

Non-functional

  • 100M users, 40M daily active; ~500M candidate (user, notification) pairs scored per day
  • Real-time trigger decision p99 < 100 ms; trigger-to-send < 2 minutes (respecting the chosen time)
  • Batch campaign scoring for all users completes within 2 hours
  • No duplicate promotional sends to a user for the same notification key
  • Send peak ~30k/s through the delivery system; smooth bursts so we don't hit FCM/APNs or SMS vendor limits
  • Opt-out (disable push) rate should not increase; target a 20% drop

Out of scope (say it out loud)

  • The delivery pipeline itself (FCM/APNs/SMS/WhatsApp senders, retries, receipts) — assume a notification service exists
  • Home-feed recommendations
  • Campaign budget approval workflows for marketing

2 Estimates

Candidates scored~500M/day~20 active campaigns, each targeting on average ~20M users ≈ 400M pairs, plus ~100M real-time trigger candidates/day ≈ 500M
Batch scoring time~30-60 min on a Spark clusterA gradient-boosted model scores ~50-100k rows/s per executor core; 400M ÷ (200 cores × 50k/s) ≈ 40 s of compute — feature joins over 100M users dominate, so ~30-60 min end to end
Real-time trigger rate~1,200/s avg, ~5k/s peak100M trigger candidates/day ÷ 86,400 ≈ 1,160/s; dinner-time peaks ≈ 4×. Each needs a feature lookup (~5 ms from Redis) and a model call (~5 ms)
Promotional sends~60-80M/day40M DAU + some lapsed users; average budget ~1.5 promos/day for engaged users and ~0.3/day for low-engagement users ≈ 70M/day
Send peak~25-30k/s at popular hoursSend-time optimisation concentrates sends around 12-1 pm and 7-9 pm: if ~40% of 70M go in two 1-hour windows → 14M/h ≈ 4k/s average, but campaign starts create bursts, so the scheduler must pace to ≤ 30k/s
Online feature store~100-200 GB in Redis100M users × ~1-2 KB of features (recent orders, category affinities, open-hour histogram, fatigue counters) ≈ 100-200 GB, sharded across a Redis cluster
Channel costpush ≈ free; WhatsApp/SMS cost per messagePush via FCM/APNs has no per-message fee; WhatsApp marketing messages and SMS have per-message charges, so 10M WhatsApp messages a day becomes a large line item. The utility function includes channel cost

3 API

EndpointWhat it does
POST/v1/campaignsCreate campaign: audience rules, variants (template ids per language), channels allowed, value_per_conversion, priority, schedule, expiry.
POST/v1/triggersInternal: services emit trigger candidates {user_id, type, entity_id, dedupe_key, expires_at}; returns accepted/suppressed.
POST/v1/transactionalBypass path: order and OTP messages go straight to the delivery system, never ranked or capped.
gRPCDecision.Score(user_id, candidates[]) → ranked decisionsOnline ranking for triggers: returns send/hold/drop, chosen channel, variant and send time.
POST/v1/eventsApp reports delivered, opened, dismissed, converted, notifications disabled; joined to the decision id for training.
GET/v1/campaigns/{id}/reportSends, opens, conversions, opt-outs, incremental lift versus holdout with confidence interval.

4 Data model

campaigns / variantsPostgres
campaign_idaudience_rulechannels[]value_per_conversionprioritystarts_atends_atstatus; variant_idcampaign_idlanguagetemplateapproved_by

Small, edited by marketers, needs approvals and audit.

user_featuresFeature store: Redis (online) + Iceberg/Hive (offline)
user_id → orders_30daovcategory_affinity[]last_open_tsopen_hour_histogram[24]promo_sent_7dpromo_opened_7dlanguagepush_enabledapp_version

One feature definition produces both stores, and training uses point-in-time joins so the model never sees future data (avoids training-serving skew).

decisionsCassandra (partition = user_id, clustering = decided_at)
decision_iduser_idcandidate_keycampaign_id/triggerscorepropensitychosen_variantchannelscheduled_forstatus (scheduled|sent|suppressed|expired)model_versionholdout_group

Per-user history for caps and dedupe; high write volume. Unique candidate_key per user prevents duplicate sends.

send_scheduleRedis sorted sets (per minute bucket) or Kafka delayed topics
bucket minute → decision_ids

Scheduler drains each minute's bucket at a paced rate. Postgres/Cassandra decisions are the source of truth for recovery.

fatigue_countersRedis
user_id → promo_count_todayper_category_countslast_promo_tsTTL 7 days

Checked at decision time and again at send time; atomic INCR with cap check in a Lua script.

notification_eventsKafka → data lake
decision_iduser_idevent (delivered|opened|dismissed|converted|disabled)ts

Labels for training and the metrics pipeline; conversion attributed within a window (e.g. 24 h).

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALcreate campaignaudience + variantsscheduled decisionsreal-time triggersfeatures, capsdecision + propensitydue decisionsre-check at sendpaced sendsopens, conversionseventspoint-in-time datanew modelnew modelMobile app (events)Marketer consoleAPI gatewayDecision service(ranker)Send-time schedulerCampaign serviceKafka: triggers,eventsTraining + evalpipelineBatch scoring (Spark)Feature store (Redis)Data lake (Iceberg)Decisions (Cassandra)Campaigns (Postgres)Notification delivery
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Daily campaign: 'Weekend biryani fest'

  1. 1A marketer creates the campaign with an audience rule (ordered biryani in 90 days, in 12 cities), 3 copy variants in 8 languages, push and in-app allowed, value per conversion ₹60, valid Saturday 11 am-10 pm.
  2. 2At 6 am the batch job joins the audience with offline features and scores each (user, campaign) pair with the ranker: p_open, p_convert and p_negative.
  3. 3Utility = p_convert × value − λ × p_negative × lifetime-value-at-risk − channel cost; candidates from all active campaigns are merged per user and the best ones are picked within that user's daily budget (e.g. 2 for engaged users, 0-1 for users showing fatigue).
  4. 45% of users are in a long-term holdout and get no promos; within the rest, ~5% of picks are exploratory (a random eligible candidate) with the propensity logged.
  5. 5The send-time model picks each user's best hour from their open-hour histogram, within quiet-hour rules; decisions are written to Cassandra as scheduled.
  6. 6Each minute the scheduler loads due decisions and re-checks them: user still has push enabled, cap not exceeded by a trigger sent earlier, campaign still active, restaurant still open. Stale ones are suppressed.
  7. 7Valid sends are paced into the notification service; open and order events flow back via Kafka and are joined to decision ids.

Real-time trigger: cart left with ₹420 of groceries

  1. 1The cart service emits cart_abandoned with dedupe_key user:cart_id and expiry 3 hours.
  2. 2The decision service waits a configured delay (e.g. 30 minutes) using a delayed queue, then reads online features and fatigue counters.
  3. 3If the user already ordered, or got 2 promos today, it suppresses. Otherwise it scores the trigger against the user's already-scheduled promos; if it wins, it replaces the lower-utility one.
  4. 4It chooses push (free) over WhatsApp unless push is disabled and the predicted value exceeds WhatsApp's per-message cost.
  5. 5The send is scheduled, re-checked at send time (cart still not ordered), and delivered.

7 Deep dives

The objective: utility, not click probability

If you rank by predicted open rate alone, the model learns to send more clickbait and more notifications, which raises opens this week and opt-outs next month. The decision must weigh value against harm. Train several heads (or models) on logged outcomes: p_open, p_convert within 24 hours, and p_negative (disable notifications, mute category, uninstall within 7 days). Score each candidate as expected value minus expected cost: p_convert × value_per_conversion − λ × p_negative × value of keeping the channel − channel cost (WhatsApp and SMS cost money; push does not). λ is tuned with experiments, not guessed: run arms with different λ and pick the one that maximises long-term orders, not 24-hour clicks. Then apply a per-user budget: a light user who rarely opens should get fewer notifications, and the budget itself can be learned (users with high p_negative get a lower cap). Choosing items across many campaigns under a budget is a small knapsack problem per user; greedy by utility with diversity rules (no two food-discount pushes in a row) is good enough. Hard caps and quiet hours sit outside the model as rules, because they are product promises, not predictions.

Measuring incrementality, not attribution

Attribution says 'the user opened the push and ordered, so the push caused ₹400 of revenue'. Often the user was going to order dinner anyway; the push only claimed credit. To know whether notifications add value, keep a randomised holdout: e.g. 5% of users get no promotional notifications for a quarter (transactional ones still flow). Comparing orders per user between holdout and treated users gives the true incremental lift, with a confidence interval. Per campaign, use a smaller campaign-level holdout. The model should also optimise for incremental conversions, which is an uplift-modelling problem: predict p_convert if sent minus p_convert if not sent, trained using the randomised exploration data. Exploration matters for another reason: the model only learns from what it chose to send, so without randomness it never learns about candidates it always ranks low (feedback loop). Send ~5% of decisions using an exploration policy (epsilon-greedy or Thompson sampling across variants) and log the propensity of every decision; that enables off-policy evaluation (inverse propensity scoring) of a new model on old logs before an A/B test. Strong candidates bring up holdouts without being asked.

Features, freshness and training-serving skew

Notification models depend on fresh behaviour: a user who ordered 10 minutes ago must not get 'hungry?'. Features come in three speeds. Batch features (30-day order counts, category affinity, open-hour histogram) are computed nightly in Spark and loaded into the online store. Streaming features (last order time, last app open, promos sent today) are updated by a Flink or Kafka Streams job within seconds. Request-time features (current cart value, time of day) come with the trigger. The classic bug is training-serving skew: the training pipeline computes 'orders in last 30 days' slightly differently from the online path, or uses values as of today for an event that happened last week (leakage), and the model looks great offline and fails online. A feature store (Feast-style, or in-house) solves this by defining each feature once, materialising it to both offline (Iceberg) and online (Redis) stores, and generating training data with point-in-time-correct joins: for each logged decision, features as they were at decision time. Log the exact feature vector used at serving time as well; it is the cheapest way to debug skew.

Send-time re-validation and pacing

Decisions made at 6 am are executed hours later, and the world changes. The scheduler re-checks each decision just before sending: is the user still opted in, did a higher-priority trigger already use the budget (atomic check-and-increment on the fatigue counter in Redis), did the user already order, is the campaign still active, is the restaurant open, is it inside quiet hours for the user's time zone? This prevents the embarrassing 'complete your order' after an order. Idempotency: the decision key (user_id, candidate_key) is unique in Cassandra, and the notification service dedupes on decision_id, so a scheduler crash and retry cannot send twice. Pacing: send-time optimisation pulls many sends into the same popular minutes (7:30 pm), and a campaign launch can schedule millions at once. The scheduler spreads sends within a few-minute window and drains per-minute buckets at a controlled rate so FCM/APNs quotas, WhatsApp/SMS vendor throughput and our own backend (the app's API gets hit when millions open at once) are not overwhelmed. For SMS in India, only DLT-registered templates can be sent, so LLM-generated copy is only usable for SMS after template registration.

8 Trade-offs

Batch scoring vs fully real-time

Scoring everything online gives the freshest decisions but means 500M model calls through a low-latency path. Batch scoring for scheduled campaigns is far cheaper and enough because decisions are re-validated at send time; real-time scoring is reserved for triggers where minutes matter.

LLM-generated copy: offline templates vs per-user generation

Per-user live generation could personalise wording, but it is costly at 70M sends/day, slow, hard to review for brand and legal compliance, and can hallucinate discounts. Generate a few variants per campaign, segment and language offline, have them reviewed, use placeholders for facts (₹{discount}, {restaurant}), and let a bandit pick the variant per user.

One model vs separate models per channel

A single multi-task model with channel as a feature shares learning and is easier to run; per-channel models capture very different behaviour (WhatsApp vs push) but fragment data. Start with one multi-task model and split only when evaluation shows a clear gain.

9 Bottlenecks

Thundering herd after a big send

Sending 10M pushes in 5 minutes makes millions of users open the app at once, overloading menus and search. Pace sends, coordinate with backend capacity, and stagger by city and cohort.

Feature store hot during peaks

Trigger bursts at dinner time hit Redis hard. Batch feature reads per user, colocate a user's features in one hash, and keep the trigger path's feature set small.

10 Failure handling

Ranker or feature store unavailable

Transactional messages are unaffected (separate path). For promos, fall back to a simple rule-based policy (campaign priority + caps) or delay sending; never fall back to 'send everything', which spikes opt-outs.

Bad model ships and doubles sends

Hard caps outside the model bound the damage; guardrail metrics (opt-out rate, sends per user) are monitored per model version with automatic rollback to the previous version.

Scheduler crashes mid-bucket

Decisions in Cassandra have status scheduled; on restart the scheduler reprocesses due buckets. Unique decision ids and the delivery system's dedupe make the retry safe.

11 What impresses

  • Optimises for long-term value with an explicit opt-out penalty instead of click-through
  • Brings up a global holdout to measure incremental lift
  • Logs propensities and explains the feedback-loop problem
  • Re-validates decisions at send time and makes sends idempotent
  • Includes channel cost and Indian rules (DLT SMS templates, WhatsApp pricing, quiet hours)
  • Keeps transactional messages on a separate, unranked path

12 Mistakes

  • Ranking only by predicted open rate
  • Mixing OTP/order updates into the ranked, capped promotional flow
  • Measuring success by attributed revenue with no holdout
  • Deciding in the morning and sending blindly hours later
  • Different feature code for training and serving
  • Letting an LLM write each message live with facts it might invent

13 Follow-ups

Q1How would you handle a brand-new user with no history (cold start)?
Q2How do you decide between push and WhatsApp for a user who disabled push?
Q3How would you run an A/B test of a new ranker without hurting users?
Q4How do you prevent several teams' campaigns from flooding the same users?
Q5What changes for a fantasy sports app where notifications are tied to live matches?
Q6How would you evaluate a new model offline before any live traffic?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free