{"id":"design-notification-system","title":"Design a notification system","sections":[{"id":"overview","title":"Overview","html":"<p>Almost every product sends notifications: a one-time login code, \"your order shipped\", \"your friend commented\", \"presale starts in an hour\". Each one is a small message, but the system behind them has to talk to several outside providers, respect what each user agreed to receive, survive provider outages and absorb sudden campaign spikes, all without delaying the login code behind a million marketing emails.</p>\n<p>Interviewers like this question because it's mostly about <a href=\"/learn/asynchronism#message-queues\">asynchronous processing</a> done carefully. Expect to cover queues, retries, deduplication, <a href=\"/learn/rate-limiting\">rate limiting</a>, scheduling and the messy details of real channels. There's no clever algorithm at the centre, so the marks go to clear separation of concerns and good answers to \"what happens when this fails?\"</p>","headings":[]},{"id":"requirements-and-estimates","title":"Requirements and estimates","html":"<h3 id=\"requirements-and-estimates\">Requirements and estimates</h3>\n<p>Pick a concrete product so the numbers mean something. Here, it's an event-ticketing app: people buy concert and sports tickets, follow artists and teams, and get reminders before events.</p>\n<p><strong>Functional</strong></p>\n<ul>\n<li>Internal services can send a notification to a user, or to an audience such as everyone following an artist.</li>\n<li>Four channels: mobile push (iOS and Android), email, SMS and an in-app inbox.</li>\n<li>Users control which categories they get on which channels. Security messages can't be turned off.</li>\n<li>Templates with per-channel content and localization.</li>\n<li>Scheduled sends, including \"10:00 in each user's local time\" and \"2 hours before the event\".</li>\n<li>Status tracking per notification: sent, delivered where the channel reports it, opened or clicked.</li>\n</ul>\n<p><strong>Non-functional</strong></p>\n<ul>\n<li>Transactional messages (login codes, ticket transfers, event time changes) go out within seconds, even during a campaign.</li>\n<li>Bulk campaigns finish within a target window, say 15 minutes for a presale.</li>\n<li>At-least-once delivery to the provider, with duplicates made rare. You can't fully rule them out, and the deep dive explains why.</li>\n<li>No message sent against a user's preferences or consent.</li>\n</ul>\n<p><strong>Estimates</strong> (assumed, round numbers)</p>\n<ul>\n<li>40 million monthly users, 12 million daily.</li>\n<li>Daily volume: 60 million push, 60 million in-app, 15 million email and 1.5 million SMS. That's 136.5 million a day in total.</li>\n<li>Average rate: 136,500,000 / 86,400 s ≈ 1,600 per second.</li>\n<li>Peak: a presale announcement to 8 million followers, sent by push and email within 15 minutes. That's 16,000,000 / 900 s ≈ 17,800 per second, on top of normal traffic. <strong>Campaigns, not the average, set the capacity.</strong></li>\n<li>Storage: about 1 KB per delivery record, so 136.5 million × 1 KB ≈ 137 GB a day. Keeping 90 days is about 137 GB × 90 ≈ 12.3 TB.</li>\n<li>Device tokens: 40 million users × about 1.5 devices = 60 million tokens. At roughly 200 bytes each, that's 12 GB, small enough to cache hot entries.</li>\n<li>SMS cost: at an assumed $0.01 per message, 1.5 million a day is $15,000 a day. Prices vary a lot by country, but the point stands: SMS mistakes cost real money, so dedup and rate limits matter most there.</li>\n</ul>","headings":[{"id":"requirements-and-estimates","title":"Requirements and estimates","depth":3}]},{"id":"api-and-data-model","title":"API and data model","html":"<h3 id=\"api-and-data-model\">API and data model</h3>\n<p>Producers call one internal API. They never talk to APNs or an email provider directly.</p>\n<pre class=\"shiki shiki-themes github-light github-dark\" style=\"background-color:#fff;--shiki-dark-bg:#24292e;color:#24292e;--shiki-dark:#e1e4e8\" tabindex=\"0\"><code><span class=\"line\"><span>POST /v1/notifications</span></span>\n<span class=\"line\"><span>{</span></span>\n<span class=\"line\"><span>  \"idempotency_key\": \"ticket-transfer-55120-accepted\",</span></span>\n<span class=\"line\"><span>  \"category\": \"transactional\",            // security | transactional | reminder | social | marketing</span></span>\n<span class=\"line\"><span>  \"template\": \"ticket_transfer_accepted\",</span></span>\n<span class=\"line\"><span>  \"recipients\": { \"user_ids\": [\"u_19\"] },</span></span>\n<span class=\"line\"><span>  \"data\": { \"event_name\": \"...\", \"seat\": \"...\" },</span></span>\n<span class=\"line\"><span>  \"priority\": \"high\",                     // high | normal | bulk</span></span>\n<span class=\"line\"><span>  \"send_at\": null,                        // or an absolute time</span></span>\n<span class=\"line\"><span>  \"expires_at\": \"2026-05-02T18:00:00Z\"    // don't deliver after this</span></span>\n<span class=\"line\"><span>}</span></span>\n<span class=\"line\"><span>-> 202 Accepted { \"notification_id\": \"n_7f3a...\" }</span></span>\n<span class=\"line\"><span></span></span>\n<span class=\"line\"><span>POST /v1/campaigns            { audience, template, send_at_local: \"10:00\", ... }</span></span>\n<span class=\"line\"><span>GET  /v1/notifications/{id}   per-channel status</span></span>\n<span class=\"line\"><span>POST /v1/users/{id}/devices   { platform: \"ios\", token: \"...\" }</span></span>\n<span class=\"line\"><span>PUT  /v1/users/{id}/preferences</span></span>\n<span class=\"line\"><span>GET  /v1/users/{id}/inbox?cursor=...</span></span></code></pre>\n<p>The API returns <code>202</code> because sending is asynchronous. The <code>idempotency_key</code> lets a producer retry safely after a timeout, since the service stores keys for a day and returns the original <code>notification_id</code> on a repeat. <code>expires_at</code> stops a stale message from going out late. A login code delivered after 20 minutes is worse than none.</p>\n<p>Main tables:</p>\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n<div class=\"table-wrap\"><table><thead><tr><th>Table</th><th>Key</th><th>Notable fields</th></tr></thead><tbody><tr><td><code>contacts</code></td><td>user_id</td><td>email, phone, verified flags, IANA time zone, locale</td></tr><tr><td><code>devices</code></td><td>device_id (indexed by user_id)</td><td>platform, push token, app version, last_seen, active</td></tr><tr><td><code>preferences</code></td><td>(user_id, category, channel)</td><td>enabled, consent source and timestamp</td></tr><tr><td><code>templates</code></td><td>(template_id, version, channel, locale)</td><td>subject, body, status (draft, approved)</td></tr><tr><td><code>notifications</code></td><td>notification_id</td><td>idempotency key, category, template version, data</td></tr><tr><td><code>deliveries</code></td><td>(notification_id, user_id, channel)</td><td>status, provider message id, attempts, timestamps</td></tr><tr><td><code>inbox</code></td><td>(user_id, created_at)</td><td>title, body, link, read_at</td></tr></tbody></table></div>\n<p>Preferences are stored as overrides on top of per-category defaults, so a new user needs no rows at all. Keep the consent source and time for marketing channels. Laws on marketing consent differ by country, and when a regulator asks, you need to show why a user got a message.</p>","headings":[{"id":"api-and-data-model","title":"API and data model","depth":3}]},{"id":"high-level-design","title":"High-level design","html":"<h3 id=\"high-level-design\">High-level design</h3>\n<figure class=\"diagram\" data-text=\"Producer services -> Notification service (send); Notification service -> Scheduled sends (later); Scheduled sends -> Router (due); Notification service -> Router; Router -> Prefs, tokens, templates; Router -> Push queue; Router -> In-app queue; Router -> Email queue; Router -> SMS queue; Push queue -> APNs / FCM; In-app queue -> Inbox + live socket; Email queue -> Email provider; SMS queue -> SMS provider\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 0 800 373\" width=\"800\" role=\"img\" aria-label=\"Producers hand off to one service. A router applies preferences and limits, then each channel has its own queue and workers\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"186\" x2=\"167\" y2=\"186\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"240\" y1=\"215\" x2=\"240\" y2=\"294\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge dg-dashed\" x1=\"277.8\" y1=\"296\" x2=\"367.8\" y2=\"215\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"311\" y1=\"186\" x2=\"327\" y2=\"186\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"400\" y1=\"159\" x2=\"400\" y2=\"78\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"430\" y1=\"159\" x2=\"527.8\" y2=\"71\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"164.7\" x2=\"487\" y2=\"159.9\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"207.3\" x2=\"487\" y2=\"212.1\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"430\" y1=\"213\" x2=\"527.8\" y2=\"301\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"631\" y1=\"42\" x2=\"647\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"631\" y1=\"138\" x2=\"647\" y2=\"138\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"631\" y1=\"234\" x2=\"647\" y2=\"234\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"631\" y1=\"330\" x2=\"647\" y2=\"330\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-client\" x=\"12\" y=\"160\" width=\"136\" height=\"52\" rx=\"26\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"182.5\">Producer</tspan><tspan x=\"80\" y=\"198.5\">services</tspan></text><rect class=\"dg-node dg-box\" x=\"172\" y=\"160\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"182.5\">Notification</tspan><tspan x=\"240\" y=\"198.5\">service</tspan></text><path class=\"dg-node dg-store\" d=\"M172,306 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M172,306 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"329.5\">Scheduled</tspan><tspan x=\"240\" y=\"345.5\">sends</tspan></text><path class=\"dg-node dg-store\" d=\"M332,18 a68,7 0 0,1 136,0 v48 a68,7 0 0,1 -136,0 z\"></path><path class=\"dg-line\" d=\"M332,18 a68,7 0 0,0 136,0\"></path><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"41.5\">Prefs, tokens,</tspan><tspan x=\"400\" y=\"57.5\">templates</tspan></text><rect class=\"dg-node dg-box\" x=\"332\" y=\"162\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"190.5\">Router</tspan></text><rect class=\"dg-node dg-queue\" x=\"492\" y=\"18\" width=\"136\" height=\"48\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"612\" y1=\"26\" x2=\"612\" y2=\"58\"></line><line class=\"dg-line\" x1=\"602\" y1=\"26\" x2=\"602\" y2=\"58\"></line><line class=\"dg-line\" x1=\"592\" y1=\"26\" x2=\"592\" y2=\"58\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"546\" y=\"46.5\">Push queue</tspan></text><rect class=\"dg-node dg-queue\" x=\"492\" y=\"114\" width=\"136\" height=\"48\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"612\" y1=\"122\" x2=\"612\" y2=\"154\"></line><line class=\"dg-line\" x1=\"602\" y1=\"122\" x2=\"602\" y2=\"154\"></line><line class=\"dg-line\" x1=\"592\" y1=\"122\" x2=\"592\" y2=\"154\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"546\" y=\"142.5\">In-app queue</tspan></text><rect class=\"dg-node dg-queue\" x=\"492\" y=\"210\" width=\"136\" height=\"48\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"612\" y1=\"218\" x2=\"612\" y2=\"250\"></line><line class=\"dg-line\" x1=\"602\" y1=\"218\" x2=\"602\" y2=\"250\"></line><line class=\"dg-line\" x1=\"592\" y1=\"218\" x2=\"592\" y2=\"250\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"546\" y=\"238.5\">Email queue</tspan></text><rect class=\"dg-node dg-queue\" x=\"492\" y=\"306\" width=\"136\" height=\"48\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"612\" y1=\"314\" x2=\"612\" y2=\"346\"></line><line class=\"dg-line\" x1=\"602\" y1=\"314\" x2=\"602\" y2=\"346\"></line><line class=\"dg-line\" x1=\"592\" y1=\"314\" x2=\"592\" y2=\"346\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"546\" y=\"334.5\">SMS queue</tspan></text><rect class=\"dg-node dg-box\" x=\"652\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"720\" y=\"46.5\">APNs / FCM</tspan></text><rect class=\"dg-node dg-box\" x=\"652\" y=\"112\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"720\" y=\"134.5\">Inbox +</tspan><tspan x=\"720\" y=\"150.5\">live socket</tspan></text><rect class=\"dg-node dg-box\" x=\"652\" y=\"208\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"720\" y=\"230.5\">Email</tspan><tspan x=\"720\" y=\"246.5\">provider</tspan></text><rect class=\"dg-node dg-box\" x=\"652\" y=\"304\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"720\" y=\"326.5\">SMS</tspan><tspan x=\"720\" y=\"342.5\">provider</tspan></text><rect class=\"dg-edge-bg\" x=\"141.6\" y=\"138\" width=\"34.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"159\" y=\"151\" text-anchor=\"middle\">send</text><rect class=\"dg-edge-bg\" x=\"219.5\" y=\"245.5\" width=\"41\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"240\" y=\"258.5\" text-anchor=\"middle\">later</text><rect class=\"dg-edge-bg\" x=\"308.5\" y=\"246.5\" width=\"28.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"322.8\" y=\"259.5\" text-anchor=\"middle\">due</text></svg></div><figcaption>Producers hand off to one service. A router applies preferences and limits, then each channel has its own queue and workers</figcaption></figure>\n<ol>\n<li><strong>Notification service.</strong> It validates the request, checks the idempotency key, writes the <code>notifications</code> row and returns <code>202</code>. Future sends go to the scheduler. Audience sends become a fan-out job that pages through the audience in batches (say 1,000 users at a time) and emits one task per user.</li>\n<li><strong>Router</strong>, per user. It loads preferences and contact details (from cache, backed by the tables), drops channels the user has opted out of, applies per-user rate limits and quiet hours, picks the template version and locale, and enqueues one message per channel. Anything dropped is recorded as <code>suppressed</code>, with the reason, so \"why didn't I get it?\" has an answer.</li>\n<li><strong>Channel queues and workers.</strong> Each channel has its own queue and worker pool. Workers render the template, call the provider, and record the result.</li>\n<li><strong>Tracking.</strong> Provider callbacks (email bounces, SMS delivery receipts) and app events (push opened, inbox read) go to a tracking service, which updates <code>deliveries</code> and feeds analytics.</li>\n</ol>\n<p>Why a separate queue per channel? <strong>Isolation.</strong> If the SMS provider slows down or goes out, SMS messages back up in their own queue while push and email carry on. Each pool also scales and retries according to its provider's rules.</p>\n<h4 id=\"channels-in-brief\">Channels in brief</h4>\n<ul>\n<li><strong>Push.</strong> You send to Apple Push Notification service (APNs) or Firebase Cloud Messaging (FCM), addressed by a device token the app registered with your backend. Payloads are small, about 4 KB. A success response means the provider <em>accepted</em> the message, not that the phone showed it. When a device is offline, APNs keeps only the most recent notification per app, and FCM holds a limited number. Use collapse keys (<code>apns-collapse-id</code>, FCM's <code>collapse_key</code>) for messages where only the latest matters, such as \"gate changed\". When a provider reports a token as unregistered or invalid, deactivate it right away. Stale tokens waste capacity on sends that can never arrive.</li>\n<li><strong>Email.</strong> You send through a provider or your own mail servers, with SPF, DKIM and DMARC set up so mail isn't marked as spam. Bounce and complaint webhooks feed a <strong>suppression list</strong>, so you never retry a hard-bounced address. Bulk mail needs a <code>List-Unsubscribe</code> header with one-click unsubscribe (<a href=\"https://www.rfc-editor.org/rfc/rfc8058\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8058</a>), which Gmail and Yahoo now require from bulk senders.</li>\n<li><strong>SMS.</strong> You send through an aggregator. It's expensive. A single message holds 160 GSM-7 characters (70 if it needs Unicode), and longer texts are split into several segments that are each billed. Delivery receipts depend on carriers and are patchy. Honour STOP replies. Sender rules and quiet-hour laws vary by country. Public endpoints that trigger SMS, like \"send me a login code\", attract fraud that pumps traffic to premium numbers, so they need strict limits.</li>\n<li><strong>In-app.</strong> Write a row to the user's inbox, increment their unread count, and if they're online, push it over their live connection (see <a href=\"/learn/realtime\">real-time updates</a>). This is the cheapest channel and the only one you fully control.</li>\n</ul>","headings":[{"id":"high-level-design","title":"High-level design","depth":3},{"id":"channels-in-brief","title":"Channels in brief","depth":4}]},{"id":"queues-retries-and-priority","title":"Queues, retries and priority","html":"<h3 id=\"queues-retries-and-priority\">Queues, retries and priority</h3>\n<h4 id=\"priority-versus-bulk\">Priority versus bulk</h4>\n<p>A presale campaign puts 16 million messages on the push and email queues within minutes. If a login code joins the back of that queue, the user waits 15 minutes and gives up. So split by priority as well as channel:</p>\n<ul>\n<li><strong>High</strong>: security codes, ticket transfers, event changes. Small volume, strict latency.</li>\n<li><strong>Normal</strong>: reminders and social notifications.</li>\n<li><strong>Bulk</strong>: campaigns and digests.</li>\n</ul>\n<p>Give each priority its own queue, or topic, per channel, with dedicated or weighted consumers, so bulk can never starve high. How you build this depends on the broker. With Kafka you'd use separate topics, since a partition is strictly ordered and has no notion of priority. RabbitMQ supports priority queues natively, but separate queues are still easier to reason about and to scale independently. Bulk consumers also run behind a token bucket set below the provider's quota, so a campaign never uses up the quota that high-priority traffic needs.</p>\n<h4 id=\"retries\">Retries</h4>\n<p>Classify every provider response:</p>\n<ul>\n<li><strong>Success</strong>: record <code>sent</code>, with the provider's message id.</li>\n<li><strong>Transient</strong> (timeouts, 5xx, 429 rate limited): retry with exponential backoff and jitter, and honour <code>Retry-After</code> when the provider sends it. Cap the attempts, and stop retrying once <code>expires_at</code> has passed.</li>\n<li><strong>Permanent</strong> (invalid token, hard bounce, invalid number): don't retry. Record <code>failed</code> and clean up the token or address.</li>\n</ul>\n<p>Messages that use up their retries go to a dead-letter queue for inspection, not a loop. <a href=\"/learn/reliability-patterns\">Reliability patterns</a> covers backoff, jitter and circuit breakers. A circuit breaker per provider stops thousands of workers hammering a provider that's already down.</p>\n<h4 id=\"deduplication\">Deduplication</h4>\n<p>Queues deliver at least once, so the same message can reach a worker twice. A worker can also crash after the provider accepted a message but before it recorded the fact. Defences, in layers:</p>\n<ol>\n<li><strong>At the API</strong>: the idempotency key turns producer retries into no-ops.</li>\n<li><strong>Per send</strong>: each message has a deterministic dedup key, <code>notification_id + user_id + channel</code>. Before calling the provider, the worker does a conditional write that moves the delivery row from <code>queued</code> to <code>sending</code>, and only one worker can win. A crashed <code>sending</code> row is retried only after a timeout.</li>\n<li><strong>At the provider</strong>: most providers won't deduplicate for you, though collapse keys on push at least replace duplicates on the device.</li>\n</ol>\n<p>There's still a small window between \"provider accepted\" and \"we recorded it\" where a crash causes a duplicate. True exactly-once delivery to a phone isn't possible across systems you don't control, so say so in the interview and keep the window small.</p>\n<h4 id=\"rate-limiting-per-user\">Rate limiting per user</h4>\n<p>These limits protect users, not just servers:</p>\n<ul>\n<li>Caps per category, for example at most 3 marketing pushes per user per day, and at most 1 SMS login code per number per 30 seconds and 10 per day.</li>\n<li><strong>Digests</strong>: instead of 12 \"a friend bought tickets\" pushes in an hour, hold social notifications briefly and send \"5 friends are going to Saturday's show\".</li>\n<li>Quiet hours in the user's local time. Non-urgent messages wait until the window ends, while security codes ignore it.</li>\n</ul>\n<p>A sliding-window counter per user and category in Redis is enough. See <a href=\"/learn/rate-limiting\">rate limiting</a> for the algorithms.</p>","headings":[{"id":"queues-retries-and-priority","title":"Queues, retries and priority","depth":3},{"id":"priority-versus-bulk","title":"Priority versus bulk","depth":4},{"id":"retries","title":"Retries","depth":4},{"id":"deduplication","title":"Deduplication","depth":4},{"id":"rate-limiting-per-user","title":"Rate limiting per user","depth":4}]},{"id":"scheduling-time-zones-and-tracking","title":"Scheduling, time zones and tracking","html":"<h3 id=\"scheduling-time-zones-and-tracking\">Scheduling, time zones and tracking</h3>\n<h4 id=\"scheduled-sends\">Scheduled sends</h4>\n<p>\"Send at 10:00 local time\" means a different UTC instant for each user. Store each user's IANA time zone (such as <code>Asia/Kolkata</code>, never a fixed offset, because daylight saving time changes offsets). Then compute each user's send time in UTC when you expand the campaign. Local times span from UTC−12 to UTC+14, so a \"10:00 local\" campaign naturally spreads over about 26 hours, which conveniently flattens the load.</p>\n<p>Store scheduled sends bucketed by minute. A scheduler claims due rows and hands them to the router. In PostgreSQL, <code>SELECT … FOR UPDATE SKIP LOCKED</code> lets several scheduler instances claim rows without blocking each other. A delay queue is another option, but check the limits: Amazon SQS, for example, can delay a message by at most 15 minutes, so it can't hold \"next Tuesday\" on its own.</p>\n<p>Watch for daylight-saving edge cases. 02:30 local may not exist on the spring-forward day, and in autumn an hour repeats (02:30 happens twice in central Europe, 01:30 in the US). Use a time zone library's rules for both cases and test them.</p>\n<p>Relative schedules (\"2 hours before the event\") must follow the event. If the concert moves, the reminder must move too, so key scheduled reminders by event id, not just by time.</p>\n<h4 id=\"tracking-delivery-and-opens\">Tracking delivery and opens</h4>\n<figure class=\"diagram\" data-text=\"Requested -> Queued; Requested -> Suppressed (prefs, limits); Queued -> Sent (provider ok) (accepted); Queued <-> Retry with backoff (transient); Queued -> Failed (permanent); Sent (provider ok) -> Delivered (receipt); Delivered -> Opened or clicked\"><div class=\"diagram-scroll\"><svg class=\"dg\" viewBox=\"0 -10 800 186\" width=\"800\" role=\"img\" aria-label=\"The life of one delivery. Each step is recorded so support and analytics can see where it stopped\"><defs><marker id=\"dg-arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path class=\"dg-arrowhead\" d=\"M0,1 L9,5 L0,9 z\"></path></marker></defs><line class=\"dg-edge\" x1=\"151\" y1=\"42\" x2=\"167\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"80\" y1=\"69\" x2=\"80\" y2=\"107\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"311\" y1=\"42\" x2=\"327\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"240\" y1=\"71\" x2=\"240\" y2=\"107\" marker-end=\"url(#dg-arrow)\" marker-start=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"285\" y1=\"69\" x2=\"348.3\" y2=\"107\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"471\" y1=\"42\" x2=\"487\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><line class=\"dg-edge\" x1=\"631\" y1=\"42\" x2=\"647\" y2=\"42\" marker-end=\"url(#dg-arrow)\"></line><rect class=\"dg-node dg-box\" x=\"12\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"80\" y=\"46.5\">Requested</tspan></text><rect class=\"dg-node dg-note\" x=\"12\" y=\"112\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"80\" y=\"134.5\">Suppressed</tspan><tspan x=\"80\" y=\"150.5\">(prefs, limits)</tspan></text><rect class=\"dg-node dg-box\" x=\"172\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"240\" y=\"46.5\">Queued</tspan></text><rect class=\"dg-node dg-queue\" x=\"172\" y=\"112\" width=\"136\" height=\"52\" rx=\"6\"></rect><line class=\"dg-line\" x1=\"292\" y1=\"120\" x2=\"292\" y2=\"156\"></line><line class=\"dg-line\" x1=\"282\" y1=\"120\" x2=\"282\" y2=\"156\"></line><line class=\"dg-line\" x1=\"272\" y1=\"120\" x2=\"272\" y2=\"156\"></line><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"226\" y=\"134.5\">Retry with</tspan><tspan x=\"226\" y=\"150.5\">backoff</tspan></text><rect class=\"dg-node dg-box\" x=\"332\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"400\" y=\"38.5\">Sent</tspan><tspan x=\"400\" y=\"54.5\">(provider ok)</tspan></text><rect class=\"dg-node dg-note\" x=\"332\" y=\"112\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label dg-muted\" text-anchor=\"middle\"><tspan x=\"400\" y=\"134.5\">Failed</tspan><tspan x=\"400\" y=\"150.5\">(permanent)</tspan></text><rect class=\"dg-node dg-box\" x=\"492\" y=\"18\" width=\"136\" height=\"48\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"560\" y=\"46.5\">Delivered</tspan></text><rect class=\"dg-node dg-box\" x=\"652\" y=\"16\" width=\"136\" height=\"52\" rx=\"8\"></rect><text class=\"dg-label\" text-anchor=\"middle\"><tspan x=\"720\" y=\"38.5\">Opened or</tspan><tspan x=\"720\" y=\"54.5\">clicked</tspan></text><rect class=\"dg-edge-bg\" x=\"289.2\" y=\"-6\" width=\"59.6\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"319\" y=\"7\" text-anchor=\"middle\">accepted</text><rect class=\"dg-edge-bg\" x=\"248\" y=\"80\" width=\"65.8\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"280.9\" y=\"93\" text-anchor=\"middle\">transient</text><rect class=\"dg-edge-bg\" x=\"452.3\" y=\"-6\" width=\"53.4\" height=\"18\" rx=\"4\"></rect><text class=\"dg-edge-label\" x=\"479\" y=\"7\" text-anchor=\"middle\">receipt</text></svg></div><figcaption>The life of one delivery. Each step is recorded so support and analytics can see where it stopped</figcaption></figure>\n<p>Each status change is an event appended to a log (Kafka works well here), which feeds two consumers: the <code>deliveries</code> row for per-message status, and a columnar analytics store for campaign metrics.</p>\n<p>How much you can trust each step varies by channel. \"Delivered\" is solid for in-app, available from some SMS carriers, and mostly absent for push. Your own app can report \"received\" and \"opened\" back with the notification id. Email opens are counted with a tracking pixel, and they're unreliable. Some mail clients, notably Apple Mail with Mail Privacy Protection, load images automatically, which inflates open counts. Clicks, tracked through redirect links, are a more honest engagement signal. Feed engagement back into sending: stop marketing email to addresses that haven't opened or clicked in months, which also protects your sender reputation.</p>","headings":[{"id":"scheduling-time-zones-and-tracking","title":"Scheduling, time zones and tracking","depth":3},{"id":"scheduled-sends","title":"Scheduled sends","depth":4},{"id":"tracking-delivery-and-opens","title":"Tracking delivery and opens","depth":4}]},{"id":"scaling-and-bottlenecks","title":"Scaling and bottlenecks","html":"<h3 id=\"scaling-and-bottlenecks\">Scaling and bottlenecks</h3>\n<ul>\n<li><strong>Campaign fan-out.</strong> Expanding 8 million followers is a batch job. Page through the follower index by cursor, emit batches onto the bulk lane, and checkpoint progress so a crash resumes instead of restarting and sending everything twice.</li>\n<li><strong>Router lookups.</strong> At 17,800 recipients per second, the router can't hit the database for every preference and token. Cache preferences and contacts per user with short TTLs, and invalidate on change. An opt-out must take effect quickly, so keep the TTL to minutes.</li>\n<li><strong>Provider throughput.</strong> Each provider enforces its own quotas and rates. Keep long-lived connections open (APNs uses HTTP/2, and many requests can share one connection), use the providers' batch APIs where they have them, and size worker pools to the quota, not to your queue depth.</li>\n<li><strong>Backlogs.</strong> When a provider slows down, its queue grows. Monitor queue depth and, better, the age of the oldest message per lane (see <a href=\"/learn/observability\">observability</a>), and alert when high-priority age goes past seconds. Apply <a href=\"/learn/asynchronism#back-pressure\">back pressure</a> to bulk producers before the queues fill up.</li>\n<li><strong>Storage.</strong> Partition <code>deliveries</code> and <code>inbox</code> by user_id so reads for one user stay on one shard, and expire rows after 90 days. Write analytics events to a time-partitioned store, kept separate from operational tables.</li>\n<li><strong>Multiple regions.</strong> Run the pipeline in each region for latency and resilience, but keep each user's preferences in one home region (or replicate them strongly), so an opt-out in one region can't be missed by another.</li>\n</ul>","headings":[{"id":"scaling-and-bottlenecks","title":"Scaling and bottlenecks","depth":3}]},{"id":"trade-offs-to-discuss","title":"Trade-offs to discuss","html":"<h3 id=\"trade-offs-to-discuss\">Trade-offs to discuss</h3>\n<ul>\n<li><strong>Render early or late.</strong> Rendering in the router fixes the content at enqueue time, which suits audits, but makes queue messages bigger. Rendering in the worker keeps messages small and picks up template fixes, but a template edited mid-campaign could change content halfway through, so pin the template version on the notification either way.</li>\n<li><strong>At-least-once versus at-most-once.</strong> At-least-once with dedup means rare duplicates. At-most-once means rare silent losses. For login codes and ticket transfers, a rare duplicate is the lesser evil. For a marketing blast, you might accept the opposite.</li>\n<li><strong>Kafka versus a task queue.</strong> Kafka gives high throughput, replay and ordering per partition, but per-message delays and retries are awkward, so you'd build retry topics. SQS or RabbitMQ give per-message acknowledgement, visibility timeouts and delays out of the box, but no replay. Many systems use Kafka for events and tracking, and a task queue for the send lanes.</li>\n<li><strong>Fallback channels.</strong> \"If push isn't opened in 10 minutes, send SMS\" improves reach for critical messages but costs money and risks a double notification. Limit it to a few categories.</li>\n<li><strong>Batching versus immediacy.</strong> Digests reduce fatigue and cost, but they add delay. Only do it for categories where minutes don't matter.</li>\n<li><strong>Build versus buy.</strong> Hosted notification platforms handle templates, preferences and provider integrations. You trade control and per-message cost for time to market. In an interview, build it, but mention the option.</li>\n</ul>","headings":[{"id":"trade-offs-to-discuss","title":"Trade-offs to discuss","depth":3}]},{"id":"check-yourself","title":"Check yourself","html":"<h3 id=\"check-yourself\">Check yourself</h3>\n<details>\n<summary>During a 10-million-user campaign, users report that login codes take several minutes to arrive. What's the likely cause in this design, and what's the fix?</summary>\n<p>Login codes are probably sharing a queue, workers or provider quota with the campaign, so they wait behind bulk traffic. Give high-priority traffic its own queue per channel with dedicated workers, and put bulk consumers behind a token bucket below the provider quota, leaving room for high-priority sends. Also alert on the age of the oldest message in the high-priority lane, so you catch this before users do.</p>\n</details>\n<details>\n<summary>An email worker crashes after the provider returns 200 but before it records `sent`. What happens next, and how do you keep this from spamming users?</summary>\n<p>The queue redelivers the message, and another worker may send it again, causing a duplicate. The conditional <code>queued → sending</code> write stops two workers sending in parallel. But a row stuck in <code>sending</code> after a crash has to be retried eventually, so a small duplicate window remains. Keep it small by recording right after the provider call and setting a sensible timeout before retrying <code>sending</code> rows. For truly sensitive messages, accept the rare duplicate rather than risk losing the message.</p>\n</details>\n<details>\n<summary>A campaign is scheduled for \"09:00 local\" to users across India, Germany and California. Roughly how is the send load spread in UTC, and what could go wrong around daylight saving time?</summary>\n<p>India (UTC+5:30) sends at 03:30 UTC, Germany at 07:00 or 08:00 UTC depending on daylight saving, and California at 16:00 or 17:00 UTC. So the load arrives in three separate waves, not one spike. If you store fixed offsets instead of IANA zones, users in Germany or California will get it an hour off after the clocks change. Always compute from the zone name at expansion time.</p>\n</details>\n<details>\n<summary>Estimate how many SMS workers you need if the provider allows 100 requests per second per connection, each worker holds one connection, and SMS peak is 600 per second. What else limits you?</summary>\n<p>At 600 / 100 = 6 workers at full use, so run 8–10 to leave headroom for retries and slow responses. The real ceiling is usually the provider's account-level throughput, plus per-country or per-sender-number limits, which no number of workers gets around. Spread traffic across approved sender numbers or short codes where the rules allow, and make sure per-user limits stop runaway retries from burning money.</p>\n</details>\n<details>\n<summary>A user turns off marketing push, but receives one 30 seconds later. Where in the pipeline could this happen, and is it acceptable?</summary>\n<p>The message was probably already past the router, either sitting in the push queue or rendered by a worker, before the preference change landed. Or the router read a cached preference that hadn't expired yet. Re-checking preferences in the worker just before sending closes most of the gap, at the cost of one more lookup per message. A short window is usually acceptable for marketing, but invalidate caches on change and keep the window to seconds.</p>\n</details>\n<h3 id=\"further-reading\">Further reading</h3>\n<ul>\n<li><a href=\"https://developer.apple.com/documentation/usernotifications/sending-notification-requests-to-apns\" target=\"_blank\" rel=\"noopener noreferrer\">Apple: Sending notification requests to APNs</a>: headers such as <code>apns-collapse-id</code> and <code>apns-expiration</code>, plus response codes.</li>\n<li><a href=\"https://firebase.google.com/docs/cloud-messaging/customize-messages/collapsible-message-types\" target=\"_blank\" rel=\"noopener noreferrer\">Firebase Cloud Messaging: collapsible and non-collapsible messages</a>: when the provider replaces a waiting message instead of queuing another. Pages next to it cover priority and time to live.</li>\n<li><a href=\"https://www.rfc-editor.org/rfc/rfc8058\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 8058: Signaling one-click functionality for list email headers</a>: the one-click unsubscribe header.</li>\n<li><a href=\"https://support.google.com/a/answer/81126\" target=\"_blank\" rel=\"noopener noreferrer\">Google: Email sender guidelines</a>: authentication and unsubscribe requirements for bulk senders.</li>\n<li><a href=\"https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: SELECT, the locking clause</a>: <code>FOR UPDATE SKIP LOCKED</code> for job claiming.</li>\n<li>Alex Xu, <em>System Design Interview – An Insider's Guide</em> (2020), \"Design a notification system\".</li>\n<li><a href=\"https://systemdesignschool.io/primer\" target=\"_blank\" rel=\"noopener noreferrer\">System Design School primer</a>: \"Buffer with Message Queues\" and \"Message Queues\".</li>\n</ul>","headings":[{"id":"check-yourself","title":"Check yourself","depth":3},{"id":"further-reading","title":"Further reading","depth":3}]}],"sourceFiles":[]}