Design a Notification System
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | An online shop sends order confirmations by email and push | Every team in the company sends through us: login codes, alerts, marketing | We sell the platform to other companies, worldwide |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | ~1M notifications/day, ~50/s peak | 2B/day, ≈ 23K/s average, ≈ 81K/s peak | ~10B/day across tenants, ≈ 405K/s peak worldwide |
| Channels | Email, push | + SMS, in-app | + WhatsApp; each tenant's own sending domains and sender IDs |
| Survives | A provider outage; a worker crash | Losing an AZ; a provider throttling us mid-blast | Losing a region; one tenant ruining its reputation |
| Targets | 99.9%; handed to the provider within 1 min | 99.99%; OTP P99 < 3 s, alerts P99 < 15 s, marketing within 15 min | Per-tenant SLOs; no tenant can degrade another |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a Notification System?
You Already Know One: the Post Office
A notification system is the post office of an app. Other services drop off letters ("order 5521 was confirmed", "here is your login code"). We look up the address, put the letter in the right envelope, stamp it, and hand it to a carrier. The carriers are companies we don't run:
| Channel | Carrier | What the "address" is |
|---|---|---|
| iPhone push | Apple Push Notification service (APNs) | A device token the phone gives the app |
| Android push | Firebase Cloud Messaging (FCM) | A registration token from FCM |
| An email sending service (Amazon SES here), then Gmail, Outlook, Yahoo... | An email address | |
| SMS | An SMS service, then the phone networks | A phone number |
| In-app | Us | A user ID and an open app session |
What Makes It Hard
We don't control the last mile. Apple, Google, the inbox providers and the phone networks decide whether and when a message arrives. Each has its own limits, its own error codes and its own bad days. Three things follow from that:
- Sending is slow and can fail, so nobody else should wait for it.
- A retry can send the same message twice. A login code that arrives twice confuses people; a payment reminder sent twice angers them.
- Sending the wrong thing costs more than sending nothing. A marketing push at 3 a.m. loses users. A text to someone who opted out can break the law. A spammy email campaign can get all our email sent to spam folders.
The Question the Whole Loop Answers
How do we get the right message to the right person once, at the right time, through carriers we don't control?
The answer gets sharper every round:
- Round 1: put a queue between "a thing happened" and "send it", retry safely, and be honest about where "exactly once" stops.
- Round 2: not all messages are equal. Login codes need their own lane; marketing must respect preferences, quiet hours, caps, the law and the carriers' speed limits.
- Round 3: we are the carrier's customer on behalf of thousands of other companies. One tenant's mistake must not cost everyone else their inbox placement, and a region can go down.
Round 1 · Mid-level · "Order Confirmations for One App"
~35 min · SDE II (L5) · 1 region, 3 AZs · ~1M notifications/day · ~50/s peak · 99.9% · handed to the provider within 1 min
R1.1 Establish Design Scope
The interviewer says: "Our shop needs to tell customers about their orders. Design the notification system." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Which events trigger a notification? | Order confirmed, order shipped, order delivered. About 200,000 orders a day. | Three events per order. We derive about 1M notifications a day in R1.7. |
| Which channels? | Email for confirmed and shipped; push for all three. No SMS yet. | Two carriers: an email service and APNs/FCM. Each channel can fail on its own, so we keep them apart (step 1.1). |
| How fast must it arrive? | Within a minute is fine. But checkout must never wait for it. | Sending happens after we answer the order service, not during. That means a queue (step 1.1). |
| Can a customer get it twice? | Try hard not to. Two "order confirmed" emails make people think they were charged twice. | We need an idempotency key and a record of what we sent (step 1.3). We'll also say where "never twice" is impossible. |
| Do we localize or personalize? | Not yet. One language. The text includes the customer's name and order number. | Templates with variables (step 1.5), no translation yet. |
| What if the email provider is down? | Then the emails go out late. They must not be lost. | Messages wait in the queue and are retried; nothing is dropped because a provider had a bad hour (steps 1.1, 1.2). |
Out of scope for this round: SMS, marketing, user preferences and quiet hours, priorities between message types, and other teams using the system. We write that list down; Round 2 brings all of it back.
A provider in this loop is any outside service that carries a message the last mile: APNs, FCM, SES, an SMS service. We call them "carriers" in plain talk and "providers" in the design.
R1.2 Functional Requirements, Derived Step by Step
| Phrase from the problem | Requirement |
|---|---|
| "Tell customers about their orders" | Send a notification when an order event happens |
| "Includes the customer's name and order number" | Render the text from a template plus variables |
| "Email and push" | Keep each customer's email address and push device tokens |
| "Must not be lost" | Retry on failure; park messages that keep failing |
| "Try hard not to send twice" | Accept an idempotency key; remember what was sent |
Not yet: priorities (everything is transactional), preferences and opt-outs, quiet hours, SMS, and campaigns to millions of users.
R1.3 Non-Functional Requirements: the Questions
We state each quality in words first. Numbers come in R1.7.
- Don't block the order flow. Checkout's latency must not depend on Apple or an email provider. If they are slow, we are still fast.
- Don't lose a notification. Once we answer "accepted", the notification must eventually be handed to the provider or recorded as failed with a reason.
- Don't send duplicates. A retry anywhere (the order service, our workers, the queue) must not produce a second email, as far as that's possible.
- Keep a small audit trail. Support asks "did the customer get the shipping email?" We must be able to answer: sent when, to which address, and what the provider said.
R1.4 The API
Send a notification
httpPOST /v1/notifications HTTP/1.1 Content-Type: application/json Idempotency-Key: order-5521-confirmed { "user_id": "u_1001", "template_id": "order_confirmed", "channels": ["email", "push"], "variables": { "first_name": "Ana", "order_id": "5521", "total": "42.90 EUR" } }
httpHTTP/1.1 202 Accepted Content-Type: application/json { "notification_id": "ntf_01J8Z6Q4M3T9V2K7H5B1C0D8E4", "status": "ACCEPTED" }
Why 202 Accepted and not 200 OK? 200 says "done". We aren't done: nothing has gone to Apple or the email provider yet. 202 says "we accepted the job and will do it later". It's the honest answer for asynchronous work, and it tells the caller not to wait for delivery. The caller can check progress later:
httpGET /v1/notifications/ntf_01J8Z6Q4M3T9V2K7H5B1C0D8E4 HTTP/1.1
json{ "notification_id": "ntf_01J8Z6Q4M3T9V2K7H5B1C0D8E4", "deliveries": [ { "channel": "email", "status": "SENT", "provider_message_id": "0100019a-...", "sent_at": "2026-09-26T18:02:11Z" }, { "channel": "push", "status": "FAILED", "reason": "NO_ACTIVE_DEVICE" } ] }
Register a push token. Tokens come from the phone's operating system, not from us. The app sends its token on every launch, because tokens change (after a reinstall, a restore, or when the OS rotates them).
httpPOST /v1/users/u_1001/devices HTTP/1.1 Content-Type: application/json { "platform": "IOS", "token": "a1b2c3...", "app_version": "5.2.0" }
json{ "device_id": "dev_7f3a", "status": "ACTIVE" }
The Idempotency-Key is chosen by the caller. The order service uses order-<id>-<event>, so if it retries the same event, it sends the same key. We define "retry" as "same key", and we'll use that in step 1.3.
Status codes
| Code | Meaning | What the caller does |
|---|---|---|
202 Accepted | Accepted (or a retry of a request we already accepted: same body back) | Nothing |
400 Bad Request | Unknown template, missing variable | Fix the request; don't retry |
409 Conflict | Same idempotency key, different body | A bug in the caller; don't retry |
429 Too Many Requests | Over the caller's rate limit | Back off, then retry with the same key |
503 Service Unavailable | We couldn't accept it right now | Back off with jitter, then retry with the same key |
Recap
- One call to send, one to register a device, one to check status.
- About 200,000 orders a day, three events each, email and push.
- The caller never waits for a provider; we answer
202. - Retries are safe because the caller's key identifies the request.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From a Direct Call to a Queue With Workers
Every step follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is usually the next problem.
Step 1.0: The Baseline
The order service calls the email provider and the push service directly, right after it saves the order.
Synthesizing vector architecture diagram...
It works on day one: few moving parts, and the order service knows immediately whether the send succeeded. Its weakness is that checkout's speed and uptime now include the email provider's speed and uptime.
Step 1.1: Checkout Waits on the Email Provider
The problem: the email provider has a slow afternoon. Each send takes 5 seconds instead of 100 ms, and checkout requests start timing out. Nothing is wrong with our orders. What would you do? How do we stop a slow provider from slowing checkout, without losing any emails?
This also answers the drill question "why not have the order service call the other service directly and wait?": a direct call couples checkout's latency and availability to the provider's; a queue absorbs bursts and outages, and lets each side scale on its own.
Synthesizing vector architecture diagram...
The order service's work ends at the API's 202. Everything to the right of the queues can be slow or down for a while without checkout noticing.
Primitive: Message Queues vs Event Streams · Drill: Message queue order pipeline
Step 1.2: The Provider Returned an Error
The problem: SES answers some sends with a throttling error. Push sends to one token come back "invalid token". One message has a template variable that breaks rendering, and it fails every time. What would you do? Which failures do we retry, how often, and what happens to a message that will never succeed?
This answers the other drill question, "what happens to an event if the consumer throws halfway through?": the worker never deleted the message, so it reappears after the visibility timeout and is retried; after the maximum number of receives, the redrive policy moves it to the DLQ.
Why 10 receives and a 15-minute cap? With delays capped at 10, 20, 40, 80, 160, 320, 640, 900, 900 s between ten attempts, the total is 10 + 20 + 40 + 80 + 160 + 320 + 640 + 900 + 900 = 3,070 s, about 51 minutes. A short provider blip is retried through; a message that fails for 51 minutes is unusual enough that a person should look. For a long provider outage, burning attempts is wrong anyway; R1.9 pauses consumption instead.
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance
Step 1.3: A Retry Sent the Confirmation Twice
The problem: two duplicates in one week. First, the order service timed out waiting for our 202 and called again, so we queued two notifications. Second, a worker sent an email, SES accepted it, and the worker crashed before deleting the SQS message. The message came back and a second worker sent it again.
What would you do? Where do we stop each duplicate, and is there any duplicate we can't stop?
The claim, in pseudocode. DynamoDB has no server-side clock in a condition, so the worker passes its own time and we allow for small clock differences between workers:
textUpdateItem PK = "NTF#ntf_01J8...", SK = "DELIVERY#email" SET status = "SENDING", claimed_at = :now, attempt = attempt + 1 CONDITION status = "QUEUED" OR (status = "SENDING" AND claimed_at < :now_minus_60s)
The 60-second takeover window is longer than a send takes, and far longer than the few seconds workers' clocks can differ. If a worker died mid-send, the next one takes over after 60 s; that's exactly the "unknown outcome" case, and it resends.
Where the records live. DynamoDB, keyed by what we look up:
| Item | PK | SK | Attributes |
|---|---|---|---|
| Idempotency record | IDEM#<producer>#<key> | IDEM | notification_id, request_hash, created_at, expires_at |
| Notification | NTF#<notification_id> | META | user_id, template_id, template_version, created_at |
| Delivery | NTF#<notification_id> | DELIVERY#<channel> | status, attempt, claimed_at, provider_message_id, error_code, sent_at |
We keep idempotency records for 24 hours. DynamoDB TTL deletes expired items only eventually (typically within a few days), so the API treats a record with expires_at in the past as absent, and TTL is only the cleanup crew.
Step 1.4: Where Do Push Tokens Come From, and Some Are Dead
The problem: we need to know which devices a user has. And after a month, a good share of push sends fail because customers uninstalled the app. What would you do? How do we keep the list of devices, and how do we stop sending to dead ones?
| Item | PK | SK | Attributes |
|---|---|---|---|
| Device | USER#<user_id> | DEVICE#<sha256(token)> | platform, token, sns_endpoint_arn, app_version, last_seen_at, status (ACTIVE / INVALID) |
| Contact | USER#<user_id> | CONTACT#email | address, verified, updated_at |
We hash the token for the sort key: tokens have no fixed length (Apple says not to assume one), and the hash gives a fixed-length key without putting the raw token in the key.
Step 1.5: Marketing Wants to Change the Wording
The problem: product wants "Your order is on its way!" instead of "Order shipped". The text is a string inside the order service's code, so every wording change is a deploy of the order service. What would you do? Where does the text live, and how do we change it safely?
| Item | PK | SK | Attributes |
|---|---|---|---|
| Template version | TPL#order_shipped | V#000003 | email_subject, email_body, push_title, push_body, required_vars, created_by, created_at |
| Current pointer | TPL#order_shipped | CURRENT | version = 3 |
The version in the sort key is zero-padded. Sort keys compare as strings, and as strings V#10 sorts before V#9; fixed width keeps "latest version" a simple reverse query.
An example template (email body):
textHi {{first_name}}, your order {{order_id}} is on its way. Track it here: {{tracking_url}}
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | Order service calls providers directly | Checkout depends on providers |
| 1.1 | Checkout waits on the provider | API returns 202; SQS queue per channel; workers | Async: we need status tracking |
| 1.2 | Provider errors | Classify errors; backoff with full jitter; DLQ after 10 receives | Delays under provider trouble |
| 1.3 | Duplicates | Idempotency key at the API; send claim at the worker; collapse IDs | Two writes per delivery; "at least once" |
| 1.4 | Dead tokens | Device registry; SNS disables invalid endpoints | Registry upkeep |
| 1.5 | Wording changes | Versioned templates rendered by workers | A template store and review |
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Follow a request from the top. The order service signs its call with its IAM role; the private API only accepts calls from inside our network. The ingest function checks the template's required variables, writes the idempotency record, and puts one message per channel on that channel's queue, then answers 202. Each worker function receives batches of up to 10 messages, claims each delivery, renders the template, and sends. Bounces and complaints come back from SES later, as events, and mark the address so we stop mailing it.
Tracing one order confirmation
- The order service sends
POST /v1/notificationswithIdempotency-Key: order-5521-confirmed. - Ingest writes
IDEM#orders#order-5521-confirmedwith a condition that it doesn't exist; it succeeds, so this is new. It writes the notification and two deliveries (QUEUED), sends one message to each queue, and returns202with the ID. - The email worker receives a batch, claims
DELIVERY#email(QUEUED→SENDING), reads the contact and template version 3, renders, and calls SESSendEmail. - SES accepts and returns a message ID. The worker writes
SENTwith that ID and deletes the SQS message. - The push worker does the same through SNS, using the collapse ID
order-5521-confirmedso a retry can only replace, never stack. - An hour later, SES reports a hard bounce for a different customer's address. The event handler marks that contact
BOUNCED, and future emails to it fail fast as permanent errors. (With SES's account-level suppression list turned on, SES also refuses sends to addresses that recently hard-bounced or complained: a second guard.)
Why Lambda workers and not servers? At ~50 messages a second, a server fleet would sit idle most of the day. Lambda's SQS integration polls the queue for us, and its maximum concurrency setting caps how many copies run at once. That cap limits parallel invocations, not sends per second: if SES answers in 30 ms, five copies sending back to back would reach about 167 a second. So each invocation also paces itself (R1.7), and the concurrency cap is only the ceiling.
R1.7 Numbers
Targets
| Quality | Target | Why this number |
|---|---|---|
| Availability of the API | 99.9% | At most 0.1% × 8,760 h ≈ 8.8 hours a year of refusing requests. Producers retry with the same key, so a short outage delays, it doesn't lose. |
| Freshness | 99% handed to the provider within 1 minute, when the provider is healthy | The interviewer said "within a minute". We can't promise when Apple or Gmail delivers; we promise when we hand it over. |
| Durability | Every accepted notification is sent or marked failed | SQS keeps messages up to our 14-day retention; the DLQ keeps anything unexplained. |
Traffic
| Item | Math | Result |
|---|---|---|
| Events per day | 200,000 orders × 3 events | 600,000 API requests/day |
| Deliveries per day | 200,000 × (2 email + 3 push) | 1,000,000/day: 400,000 email, 600,000 push |
| Average rate | 1,000,000 ÷ 86,400 s | ≈ 11.6/s |
| Peak rate | 11.6 × 4 (evening peak on sale days; assumption) | ≈ 46/s, call it ~50/s |
| Peak per channel | 40% email, 60% push of 50/s | 20 email/s, 30 push/s |
| Data in at peak | 50/s × 1.5 KB | 75 KB/s: tiny |
Queue depth during a provider outage
- SES is unreachable for one hour at peak:
20/s × 3,600 s = 72,000emails wait in the queue. SQS has no practical limit on backlog; this is nothing. - When SES returns, workers drain at our speed limit of 50/s (below) while new mail keeps arriving at 20/s. Spare capacity is
50 − 20 = 30/s, so the backlog clears in72,000 ÷ 30 = 2,400 s = 40 minutes. - A whole-day outage at the average email rate (
400,000 ÷ 86,400 ≈ 4.6/s) leaves 400,000 messages; spare capacity is50 − 4.6 ≈ 45/s, so draining takes400,000 ÷ 45 ≈ 8,900 s ≈ 2.5 hours. Retention is 14 days, so nothing expires.
SES quota. SES limits each account per region with a sending quota (emails per rolling 24 hours) and a maximum send rate (per second). A new account starts in the sandbox (200 emails a day, 1 per second). We ask to leave the sandbox with a quota of 1,000,000 a day and 50 a second: 2.5× our daily volume and our drain speed.
Workers
| Queue | Per invocation | Max concurrency | Throughput |
|---|---|---|---|
| Up to 10 messages, sent one by one, each send started at least 100 ms after the previous one (≤ 10/s per invocation, however fast SES answers) | 5 | ≤ 5 × 10 = 50/s: the SES send rate we asked for | |
| Push | Up to 10 messages, sends spaced at least 50 ms apart (≤ 20/s per invocation) | 5 | ≤ 100/s: over 3× peak push |
Pacing inside each invocation, times the concurrency cap, is what keeps a 72,000-message backlog from turning into SES throttling errors: the drain is held at or under the rate SES granted, so the drain times above stand. If SES still answers with a throttling error (for example, while a quota change is propagating), that send is treated as retryable, with backoff, like any other throttle. (A shared token bucket, as in Round 2, is the alternative when workers aren't Lambda.)
Latency when healthy: API Gateway plus a warm ingest function, one DynamoDB write and one SQS send: well under 100 ms to 202 (a cold start adds a few hundred ms). SQS to worker: typically under a second. SES or SNS call: about 100 ms. Far inside one minute.
Rough monthly cost (us-east-1 list prices, 30.4 days a month; check the AWS Pricing Calculator before quoting):
| Line | Math | ≈ Monthly |
|---|---|---|
| SES email | 400,000/day × 30.4 = 12.2M × $0.10 per 1,000 | $1,216 |
| SNS mobile push | 600,000/day × 30.4 = 18.2M; first 1M free; 17.2M × $0.50 per million | $9 |
| API Gateway (REST) | 600,000/day × 30.4 = 18.2M × $3.50 per million | $64 |
| Lambda (ingest + workers) | 18.2M ingest calls at ~50 ms; ~1M sends a day at ~100 ms; 512 MB | $40 |
| SQS | ~2 requests per delivery (send, plus batched receive and delete) × 30.4M, plus idle polling | $25 |
| DynamoDB (on-demand) | Writes: ingest ≈ 3.67 per request (idempotency record, notification, 1.67 deliveries) × 600K = 2.2M; workers 2 per delivery (claim, SENT) × 1M = 2M; device registrations ≈ 0.3M: ≈ 4.5M/day × 30.4 × $0.625 per million ≈ $85. Reads ≈ $3; ~18 GB kept 30 days ≈ $5 | $93 |
| CloudWatch logs and alarms | assumption | $30 |
| Total | ≈ $1,480 |
About 82% of the bill is SES. The rest of the system costs less than a team lunch.
R1.8 Trade-Offs
Push through SNS vs straight to APNs and FCM
| SNS mobile push (chosen) | Direct to APNs and FCM | |
|---|---|---|
| Work for us | SNS holds the credentials and connections, and disables invalid endpoints | We run HTTP/2 connection pools, refresh Apple's signing tokens and Google's OAuth tokens, and parse each provider's errors |
| Control | Less: we see what SNS reports | Full: every header, every error code, our own rate limiting |
| Cost at 18M pushes/month | ≈ $9 | $0 in fees, more engineering time |
| Extra state | An SNS endpoint ARN per device | None |
At this volume, SNS saves weeks of work for $9 a month. Round 2 revisits the choice at 1.3 billion pushes a day, and money won't be the reason we change.
One queue for everything vs one queue per channel
One queue is simpler, but an SES outage then fills it with email retries while push messages wait behind them. With a queue per channel, each channel's workers, retries, speed limit and alarms are separate. Two queues cost nothing extra in SQS (we pay per request, not per queue), so we choose one per channel.
Lambda consumers vs polling servers
| Lambda + SQS (chosen) | ECS tasks that long-poll | |
|---|---|---|
| Idle cost | None | Tasks run 24/7 |
| Speed limit toward a provider | Maximum concurrency on the event source | We build it in the worker |
| Long-lived provider connections | Not guaranteed (environments come and go) | Yes: good for APNs HTTP/2 connections |
| Good fit | Low, bursty volume (this round) | Sustained high volume with persistent connections (Round 2) |
R1.9 Failure Modes
| Trigger | What you'd see | How the design responds |
|---|---|---|
| SES outage | Email sends fail with 5xx or time out; the email queue's oldest-message age climbs | The queue absorbs it. Retrying through a long outage would burn each message's 10 attempts and flood the DLQ, so an alarm on the email failure rate (above 50% for 3 minutes) disables the email worker's event source; messages simply wait. A scheduled check sends one test email a minute; when it succeeds, automation re-enables the event source (a half-open circuit breaker). Push is unaffected. |
| Poison message | One message fails on every attempt (for example, a template version was deleted by mistake) | It reaches the DLQ after 10 receives; the DLQ alarm pages. Someone fixes the cause, then moves the messages back with an SQS redrive. Permanent errors never reach the DLQ: they're marked FAILED at once. |
| Worker crash mid-batch | A Lambda invocation times out | Messages not yet deleted reappear after the visibility timeout (set to 6× the function timeout, as AWS recommends for Lambda consumers). The send claim stops re-sending anything already SENT; anything left SENDING is resent after 60 s, the known at-least-once case. |
| One bad message in a batch of 10 | Nine sends succeed, one fails | The function reports only the failed message ID (partial batch response), so the nine successes are deleted and only one is retried. |
| DynamoDB throttling or error at ingest | Ingest can't write the idempotency record | Return 503; the producer retries with the same key. We never enqueue a message we couldn't record. |
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | Checkout decoupled by a queue; idempotency keys make producer retries safe; classified errors with backoff and jitter, a DLQ, and a paused consumer during provider outages REL 4 · REL 5 |
| Performance Efficiency | 202 in well under 100 ms; worker concurrency sized to the provider's granted rate PERF 1 |
| Security | Producers call a private API with IAM-signed requests; each function's role can only do its one job (the email worker may send only from our domain's identity); APNs and FCM credentials kept in Secrets Manager and loaded into SNS; personal data in variables (names, order totals) is never written to logs, and queues and tables are encrypted at rest SEC 2 · SEC 3 · SEC 8 |
| Cost Optimization | About $1,480/month, 82% of it SES; SNS for push because it saves engineering time for $9 a month COST 5 |
| Operational Excellence | Light this round: alarms on any DLQ message, queue age over 60 s, email failure rate, and SES bounce and complaint rates OPS 8 |
| Sustainability | Light this round: nothing runs while no orders arrive; Lambda scales to zero SUS 2 |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Asks what triggers a notification, which channels, how fast, and whether duplicates matter, and says what each answer changes.
- Puts a queue between producers and providers, returns
202, and explains why202and not200. - Separates retryable from permanent errors, uses backoff with jitter, and knows what a DLQ is for.
- Uses an idempotency key at the API and a send record at the worker, and says honestly that a timeout after the provider accepted can still cause a duplicate.
- Keeps a device registry and removes tokens the providers reject.
- Derives traffic, backlog and drain time, and ties worker concurrency to the provider's rate.
Follow-up questions
-
"The order service saves the order, then calls your API, and that call fails. Is the notification lost?" Answer: yes, if the order service simply gives up: this is the dual-write problem (two systems written without a shared transaction, so one can succeed while the other fails). The fix belongs in the order service: write the order and an "outbox" row in the same database transaction, then publish from the outbox. Publishing can read the database's change log (change data capture, for example DynamoDB Streams or Debezium reading a Postgres write-ahead log), or poll the outbox table every half second. Change data capture adds no query load and picks up changes within about a second; polling is simpler to build but runs a query forever, adds load and index churn on a busy table, and adds up to one polling interval of delay. Either way, our idempotency key makes the repeated publish harmless. Drill: CDC outbox dual-write drift
-
"Why not use SQS FIFO to get exactly-once?" Answer: FIFO removes duplicate sends into the queue within a 5-minute window. Our duplicates come from elsewhere: a timeout after SES accepted, or a producer retry after 5 minutes. The idempotency record and send claim handle those. FIFO would also add ordering we don't need.
-
"How do you know the email was delivered?" Answer: SES publishes delivery, bounce and complaint events for each message through a configuration set. For push, APNs and FCM only tell us they accepted it; to know it reached the phone, the app reports receipt to our API. Status reads "SENT" until one of those arrives.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Send it in a background thread" | Messages live in one process's memory, and a slow provider still exhausts the thread pool. |
| "Retry until it works" | Hammers a struggling provider and loops forever on messages that can never succeed. |
| "The provider deduplicates" | SES, APNs and FCM have no idempotency key; a retry after a timeout can send twice. |
| "FIFO gives exactly-once delivery" | Exactly-once into the queue is not exactly-once to the phone. |
| "Keep tokens forever" | Dead tokens waste sends and draw errors that providers use to slow us down. |
Round 2 · Senior · "2B Notifications a Day, OTPs and Marketing"
~40 min · Senior SDE (L6) · 1 region, 3 AZs · 2B notifications/day · ≈ 23K/s average, ≈ 81K/s peak · 99.99% · OTP P99 < 3 s, alerts P99 < 15 s, marketing within 15 min
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built order notifications for one shop: about a million a day, 50 a second at peak, email and push, one region, 99.9%. The order service calls our API with an idempotency key and gets
202 Acceptedin milliseconds; a conditional write on that key makes producer retries safe. The API puts one message per channel on an SQS queue, email and push separately. Lambda workers claim each delivery with a conditional write before sending, render a versioned template, and call SES or SNS mobile push. Errors are sorted: permanent ones fail at once, retryable ones back off with full jitter, and anything unexplained lands in a DLQ after 10 receives. During a provider outage we pause the consumer instead of burning retries. A device registry drops tokens the providers reject. It costs about $1,480 a month, mostly SES. Open costs: 'exactly once' stops at a timeout after the provider accepted, everything is one priority, and there are no preferences or opt-outs."
Architecture v1, compact
textorder service ──IAM-signed──► API Gateway ──► ingest Lambda ──conditional put (Idempotency-Key)──► DynamoDB ├──► SQS email ──► email worker (max 5) ──claim──► SES └──► SQS push ──► push worker (max 5) ──claim──► SNS ──► APNs / FCM each queue ──after 10 receives──► DLQ SES bounces ──► SNS ──► Lambda ──► mark address
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Checkout waits on the provider | 202 + SQS queue per channel + workers | Async status tracking |
| 1.2 | Provider errors | Classified errors, backoff with full jitter, DLQ | Delays in trouble |
| 1.3 | Duplicates | Idempotency key; send claim; collapse IDs | "At least once" |
| 1.4 | Dead tokens | Device registry; SNS disables invalid endpoints | Registry upkeep |
| 1.5 | Wording changes | Versioned templates | A template store |
Open costs: one priority for everything, no preferences or opt-outs, no speed limits of our own, and a design sized for 50 messages a second.
R2.1 The Scope Raise
Interviewer: "Every team in the company now sends through you: 2 billion notifications a day. Login codes, the one-time passwords (OTPs), must arrive in seconds, even while marketing sends a push to 50 million users. Users choose their channels and quiet hours, opt-outs are the law, and the same billing alert must never arrive twice in a day. SMS and an in-app inbox join. And Apple and Google are already throttling us."
A scope raise is not the end of scoping. Before we fix anything, we ask back, and we say what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Which messages are time-critical, and how critical? | OTPs and security alerts: P99 under 3 s. Transactional alerts (orders, billing): P99 under 15 s. Marketing: within 15 minutes of the campaign start. | Three priorities with their own lanes and capacity (step 2.1). |
| Who decides a message's priority? | Today, the sender sets a field. Marketing has already marked a promotion "urgent". | Priority comes from the template's registered category, owned by a reviewer, not from the request (R2.3, step 2.1). |
| What can users control? | Channels per category, quiet hours in their own time zone, and opt-out from anything. Legal wants proof of every opt-in and opt-out. | A preference model, a consent log, and a check before anything is rendered (step 2.2). |
| What exactly is "the same alert twice"? | The billing team sends a reminder for invoice 123 once a day. Their job restarted and sent it three times, with three different request IDs. | Idempotency keys can't catch this; we need a producer-chosen dedup key with a window up to a day (step 2.3). |
| What do we know about provider limits? | FCM returned 429 for 20 minutes during yesterday's blast. We don't know our Apple limits. SMS in the US needs registered senders. | Our own rate limiting per provider, with room reserved for OTPs (step 2.4). |
| How much push fails today? | About 15% of push sends go to tokens that no longer work. | Token hygiene as a pipeline step, not a cleanup script (step 2.5). |
| How many teams send marketing? | About 40. Some users get 10 pushes a day from different teams, and uninstall. | A central frequency cap per user, per category (step 2.6). |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Senders | One order service | ~40 teams |
| Volume | ~1M/day, ~50/s peak | 2B/day; ≈ 23,148/s average; ≈ 81K/s peak |
| Channels | Email, push | Push 65%, email 25%, SMS 10%; plus an in-app inbox |
| Priorities | One | Critical (OTP, security), high (transactional), bulk (marketing, digests) |
| User controls | None | Channels per category, quiet hours, opt-out, frequency caps |
| Dedup | Retries of one request | + the same logical alert within a window up to 24 h |
| Availability | 99.9% (8.8 h/year) | 99.99% (52.6 min/year) |
| Latency | Handed over within 1 min | OTP P99 < 3 s; alerts P99 < 15 s; marketing within 15 min |
| Audit | Status per notification | Every send, kept 5 years (400 GB/day) |
The "Not yet" list from R1.2 is now mandatory: priorities, preferences and opt-outs, quiet hours and SMS.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| One queue per channel, shared by every message type | A 50M-user marketing push fills the push queue. An OTP enqueued behind it waits for millions of messages: login codes arrive in 20 minutes. This is head-of-line blocking: the item at the front delays everything behind it. |
| No preference or opt-out check | We text people who opted out of marketing, and push at 3 a.m. Each producer would have to remember the rules, and 40 teams won't. |
| Dedup by idempotency key, kept 24 h | A restarted job sends the same reminder with a new key. The key only proves "same request", not "same message". |
| Speed limited only by Lambda concurrency | At 52K pushes a second, FCM answers 429 QUOTA_EXCEEDED. Workers that retry quickly make it worse, and OTPs share the throttled budget. |
| SNS disables bad endpoints, one by one | At 1.3B pushes a day, 15% to dead tokens is about 195M wasted sends a day, plus SNS state for every dead device. |
| No per-user limits | Ten teams each send "just one" push. The user gets ten, and uninstalls. |
| Lambda workers at ~50/s | Round 2 needs about 1,600 times the rate, and APNs rewards long-lived connections that Lambda doesn't guarantee. |
The order we fix it in: first what can hurt users or break the law (priorities, preferences, dedup: 2.1–2.3), then what the providers force on us (2.4, 2.5), then what users feel over a day (2.6). Sizing comes after the design, in R2.6.
R2.3 New Requirements and API Additions
Templates carry the category, and the category decides the priority. A team registers a template once; a reviewer approves its category. Requests can't choose their own priority.
httpPOST /v1/templates HTTP/1.1 Content-Type: application/json { "template_id": "login_otp", "owner_team": "identity", "category": "security_code", "channels": ["sms", "push"], "sms": { "body": "{{code}} is your login code. It expires in 5 minutes." }, "push": { "title": "Login code", "body": "{{code}} is your login code." }, "required_vars": ["code"] }
| Category | Priority | Quiet hours | Frequency cap | Needs marketing consent |
|---|---|---|---|---|
security_code, security_alert | critical | Ignored | Exempt | No |
transactional (orders, billing, account) | high | Ignored | Exempt | No, but respects channel choices |
marketing, digest | bulk | Held or dropped | Yes | Yes, per channel |
Send request, extended
httpPOST /v1/notifications HTTP/1.1 Content-Type: application/json Idempotency-Key: billing-job-7731-run-3 { "user_id": "u_1001", "template_id": "invoice_reminder", "variables": { "invoice_id": "123", "amount": "19.99 USD" }, "dedup_key": "invoice-123:reminder", "dedup_window_seconds": 86400, "expires_at": "2026-09-27T09:00:00Z", "drop_if_deferred": false }
dedup_key: the producer's name for "this logical message". Defaults to the idempotency key.dedup_window_seconds: default 300, maximum 86,400 (24 hours).expires_at: after this, the message is worthless and must not be sent (an OTP sets it 5 minutes out).drop_if_deferred: if the user is in quiet hours, drop instead of holding (a flash-sale push that is useless by morning).
Response statuses
| HTTP | status | Meaning |
|---|---|---|
202 | QUEUED | On its way |
202 | DEFERRED_QUIET_HOURS | Held until the user's quiet hours end |
200 | DROPPED_DUPLICATE | Same dedup_key inside the window |
200 | DROPPED_OPT_OUT | The user doesn't accept this category on any requested channel |
200 | DROPPED_FREQUENCY_CAP | Over the user's daily cap for this category |
200 | DROPPED_QUIET_HOURS | In quiet hours and drop_if_deferred was true |
200 for the drops, because the request is finished: we decided, and there is nothing left to do. A retry of an accepted request (same idempotency key) still returns the original 202 body.
Campaigns. Marketing doesn't loop over 50M users and call us 50M times. It hands us a segment and a start time, and our fan-out job expands it at a controlled speed:
httpPOST /v1/campaigns HTTP/1.1 Content-Type: application/json { "campaign_id": "fall-sale-2026", "template_id": "fall_sale_push", "segment_id": "seg_active_90d_us", "start_at": "2026-10-03T16:00:00Z", "drop_if_deferred": true }
The preference model
json{ "user_id": "u_1001", "timezone": "America/Chicago", "quiet_hours": { "start": "22:00", "end": "08:00", "applies_to": ["push", "sms"] }, "channels": { "push": true, "email": true, "sms": true, "in_app": true }, "categories": { "marketing": { "push": true, "email": false, "sms": false }, "digest": { "email": true } }, "version": 17, "updated_at": "2026-09-20T14:03:11Z" }
Quiet hours apply to push and SMS by default, the channels that buzz a phone. The time zone comes from the device registration (the phone knows it), so it follows the user when they travel.
Frequency-cap policy (one row per category and channel, set by the platform, not by each team):
json{ "category": "marketing", "caps": { "push": 3, "email": 2, "sms": 1 }, "per": "local_day" }
Opt-out, with an audit trail
httpPOST /v1/users/u_1001/opt-outs HTTP/1.1 Content-Type: application/json { "channel": "sms", "category": "marketing", "source": "sms_keyword_stop", "occurred_at": "2026-09-26T21:14:02Z" }
Every opt-in and opt-out also becomes an append-only consent record: who, what, which channel, when, and how (settings screen, an email's unsubscribe link, a STOP reply). Legal asks "prove this user agreed to marketing texts on this date", and we answer from this log.
In-app inbox. Templates can set "inbox": true. The worker then also writes the message to the user's inbox (DynamoDB, PK = USER#<id>, SK = MSG#<ULID>). A ULID is a 26-character ID that starts with the time, in a fixed-width alphabet, so string order is time order and "newest first" is one query. If the app is open, the WebSocket gateway pushes it live; see WebSocket, SSE & Long Polling and the chat loop for how connections are tracked.
R2.4 Design Evolution: Priorities, Policy, Dedup and the Providers' Limits
Step 2.1: A Login Code Waited 20 Minutes Behind a Marketing Blast
The problem: marketing sent a push to 50M users at 16:00. At 16:02 a user asks for a login code. It goes into the same push queue behind tens of millions of promotions and arrives at 16:22. They gave up at 16:03. What would you do? How do we make sure an OTP never waits behind bulk traffic, no matter how big the blast?
Synthesizing vector architecture diagram...
A 30M-message backlog in the bulk queues can't touch the critical path: different queue, different workers, and a reserved share of each provider's budget.
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance
Step 2.2: We Texted a User Who Opted Out
The problem: a user replied STOP to a marketing text last week. Yesterday another team's campaign texted them again. Legal is asking how that happened. What would you do? Where do preferences, opt-outs and quiet hours get checked, and what happens to a message that arrives at 3 a.m. local time?
The hold table
| Attribute | Example | Why |
|---|---|---|
| PK | HOLD#2026-09-27T13:07#41 | Release minute in UTC (fixed-width ISO text sorts correctly) plus one of 64 shards, so a busy minute isn't one hot partition |
| SK | ntf_01J8Z... | One item per held notification |
payload | the accepted request (≤ 1.5 KB) | Everything needed to re-run the policy stage |
expires_at | 2026-09-27T20:00Z | Don't send a stale promotion |
cap_counted | true | The cap slot was taken at accept; the release pass must not count it again |
ttl | release + 7 days | Cleanup only |
The releaser never relies on TTL: DynamoDB deletes expired items only eventually (typically within a few days). It deletes each item after queueing it. If it crashes in between, the next run queues the item again, and the send marker from step 2.3 stops the second copy. Two releaser runs overlapping behave the same way, so we need no lock.
What the law asks, in general terms. In the US, marketing texts generally need the recipient's prior consent, and a STOP reply must be honored; commercial email must carry a working unsubscribe that is honored promptly (CAN-SPAM); in the EU, GDPR requires a lawful basis for processing, often consent for marketing, and the ability to prove it. The exact rules differ by country and change, so legal owns them; our job is to make every one of them enforceable in one place, with a record.
Step 2.3: The Same Billing Alert Arrived Twice Today
The problem: the billing job crashed and restarted three times this morning. Each run sent "Invoice 123 is due" with a fresh idempotency key. Our Round 1 dedup saw three different keys and sent three reminders. What would you do? How do we recognize "the same message" when the requests are different?
Why Redis fails open for dedup. If the dedup cluster is unreachable, we send anyway and count the skipped checks. A rare duplicate is a smaller harm than a login code that never arrives. (Preferences, in contrast, fail closed for marketing; see R2.8.)
The eviction trap. Redis can evict keys when memory is full. The ElastiCache default policy, volatile-lru, evicts keys with an expiry, least recently used first; volatile-ttl evicts the keys with the shortest remaining TTL first, which here means 2-minute OTP fingerprints and send markers about to expire. Every eviction is a silent duplicate. So this cluster runs with noeviction: when full, writes fail loudly, we fail open and page, and we size so it never happens (alarm at 70% memory).
Primitive: Distributed Cache Patterns & Eviction
Step 2.4: FCM Returns 429 During a Blast, and Our Workers Retry Harder
The problem: a 50M-user campaign starts. Android sends hit FCM's per-project quota, and FCM answers 429 QUOTA_EXCEEDED. Workers retry within seconds, the quota stays exhausted, and a few OTP pushes are rejected too. On the Apple side, a handful of devices return 429.
What would you do? How fast should we send to each provider, who decides, and what does a 429 mean on each side?
Why a token bucket, and why shared? Two drill questions live here.
- Local counters on each worker or one shared budget? If each of 20 workers enforces "quota ÷ 20" locally, the total is right only while exactly 20 are running: autoscaling to 30 overshoots by 50%, and a skewed load leaves budget unused on idle workers. A shared bucket in Redis holds the one true count. Leasing 100 tokens at a time keeps it to a few hundred Redis calls a second instead of tens of thousands.
- Token bucket or fixed window? FCM counts over rolling, unaligned minutes. A fixed window of our own ("600K per clock minute") allows 600K in the last second of one minute and 600K in the first second of the next: 1.2M inside FCM's one minute. A token bucket refills continuously; with rate
rper second and burstb, the most it can send in any 60 seconds is60r + b, and we keep that under the quota.
Headers that matter. For APNs: apns-priority: 10 (deliver now) for OTPs and alerts, 5 (deliver when it suits the device's power state) for marketing; apns-expiration set to the OTP's expiry, so Apple never delivers a code after it's useless; apns-collapse-id (at most 64 bytes) so repeats replace each other; and apns-push-type: alert. FCM has the same ideas: Android priority high or normal, a ttl, and a collapse_key. Payloads stay under 4 KB on both.
Primitive: Distributed Rate Limiting · Drill: Rate limiting partner API · Loop: Design a Distributed Rate Limiter
Step 2.5: Millions of Dead Tokens Waste Sends
The problem: 15% of push sends go to tokens that no longer work: about 195M wasted requests a day. And a support ticket: a user logged out of a shared tablet, someone else logged in, and the first user's order updates now appear on the second person's screen. What would you do? How do we keep the registry clean, and make sure a token belongs to exactly one user?
Step 2.6: Ten Teams Each Sent This User a Push Today
The problem: each of ten teams sends "only one" marketing push a day. The user gets ten and uninstalls. No team did anything wrong on its own. What would you do? Where does a "max 3 marketing pushes a day" rule live, and how do we count reliably?
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | OTP behind a blast | Queues, worker pools and provider shares per priority (bulkheads) | Reserved capacity |
| 2.2 | Texted an opted-out user | Policy stage at ingest: suppression, preferences, caps, quiet hours; hold table | A lookup per message; a releaser |
| 2.3 | Same alert twice in a day | Producer dedup key → fingerprint, SET NX EX with a per-request window; send markers | Memory; producers choose keys |
| 2.4 | Provider throttling | Per-provider token buckets with reserved shares; long-lived HTTP/2; per-provider error handling | Bulk slows near limits |
| 2.5 | Dead and shared tokens | 410 with timestamp check; UNREGISTERED; staleness; one owner per token | Registry writes |
| 2.6 | Too many teams | Central cap per user, category, channel and local day, checked once at ingest | Some marketing dropped |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Follow a request from the top. Producers connect with mutual TLS. Ingest runs the dedup and policy stage (Redis for dedup and caps, DynamoDB for preferences), then either holds the message or puts it on one of nine queues. Each priority has its own worker pool. Workers lease send tokens from the shared buckets, look up devices or contacts, render, and call the provider. Every outcome is packed into Firehose records and lands in S3. Bounces, complaints, delivery receipts and STOP replies flow back into the suppression list.
Why these AWS services. SMS goes through AWS End User Messaging SMS, AWS's SMS service (SNS can also send SMS; End User Messaging gives us the origination numbers, opt-out lists and delivery events directly). Email stays on SES. We do not build on Amazon Pinpoint: it has been closed to new customers since 2025-05-20 and its support ends on 2026-10-30; its SMS, push and voice features continue as AWS End User Messaging. Push goes straight to APNs and FCM from our workers (R2.7 explains why we left SNS).
Trace 1: an OTP during a 50M-user blast
- At 16:02 the identity service sends
login_otpforu_1001withexpires_at5 minutes out. Ingest finds the categorysecurity_code: priority critical. - In parallel:
SET dedup:<fp> ... NX EX 120in Redis and a strong read of preferences in DynamoDB. SMS is on, the number isn't on the STOP list. Quiet hours and caps don't apply. - Ingest sends to
critical-smsand returns202about 40 ms after the request arrived. - A critical worker receives it within milliseconds: its queue is almost empty while
bulk-pushholds 30M messages. - It takes one token from the SMS bucket's critical share, sets
send:<id>:smstoSENDING:<attempt_id>withNXand a 60-second expiry, and calls End User Messaging. Accepted about 600 ms after the request; the marker is overwritten withSENT. - The bulk blast is unaffected and unaware.
Trace 2: a marketing push at 23:30 in Chicago
- The fall-sale campaign reaches
u_2002, whose preferences sayAmerica/Chicago, quiet hours 22:00–08:00.drop_if_deferredis false. - The policy stage computes the release time: 08:00 local plus a random 23 minutes = 13:23 UTC. It writes
HOLD#2026-09-27T13:23#17and returnsDEFERRED_QUIET_HOURS. - At 13:24 UTC the releaser reads minute 13:23, all 64 shards. The user opted out of marketing push at 07:50; the policy stage now returns
DROPPED_OPT_OUT. Nothing is sent, and the hold item is deleted.
Trace 3: a 410 from Apple
- A bulk worker sends to token
ab12...and gets410with reasonUnregisteredand a timestamp of 2026-09-20 10:00 UTC. - It writes
status = INVALIDon that device iflast_registered_atis earlier than that timestamp. It is (last registered 2026-08-02), so the write succeeds; 410 isn't counted as an error, and the message is deleted from the queue. - The user has a second, active iPhone; the worker had sent to both, and that send succeeded.
Where the audit trail stops. The lake holds the notification ID, user ID, template and version, channel, provider response and timestamps. It does not hold the rendered text or raw addresses: email addresses and phone numbers are stored as an HMAC (a keyed hash), so support can still search "did we text +1 555 0100?" by hashing the number with the same key, and a leaked lake file reveals no contact details.
R2.6 Numbers and Cost
Traffic
| Item | Math | Result |
|---|---|---|
| Average | 2,000,000,000 ÷ 86,400 s | ≈ 23,148/s |
| Peak | 23,148 × 3.5 (flash sales, campaign starts) | ≈ 81,018/s ≈ 81K/s |
| Push / email / SMS per day | 65% / 25% / 10% of 2B | 1.3B / 500M / 200M |
| At peak | 65% / 25% / 10% of 81K/s | ≈ 52.7K/s push, 20.3K/s email, 8.1K/s SMS |
| Priorities (assumption, by template category) | 1% critical, 29% high, 70% bulk | Peak ≈ 810/s critical, 23.5K/s high, 56.7K/s bulk |
| Ingest bandwidth | 81,000/s × 1.5 KB = 121.5 MB/s × 8 | ≈ 972 Mbps ≈ 1 Gbps |
Push connections. Apple publishes no per-connection throughput. It says to use many connections for high volume, not to assume how many streams a connection allows, and to spread traffic across connections. So we plan with an assumption: about 500 pushes per second per HTTP/2 connection, to be replaced by what we measure.
| Item | Math | Result |
|---|---|---|
| If all push went to APNs | 52,650/s ÷ 500 | ≈ 106 connections |
| Our iOS share (assumption: 45% of push) | 52.7K × 0.45 = 23.7K/s; ÷ 500 | ≈ 48 connections |
| Provisioned | 16 push tasks × 4 connections | 64 connections: 32K/s at the assumed rate, 74% busy at peak |
FCM quota. Android is the other 55%: 52.7K × 0.55 ≈ 29.0K/s ≈ 1.74M a minute, about 2.9× FCM's default 600K a minute. FCM grants increases of up to 25% at a time, and only when we already use at least 80% of the quota for 5 minutes in a row daily, with under 5% client errors. Three increases take us 600K → 750K → 937.5K → ≈ 1.17M a minute, ≈ 19.5K/s. For a planned event, Firebase Support can grant temporary quota if we ask at least 15 days ahead (30 days above 18M a minute), at most twice a year.
Our limiter runs at 17,500/s with a burst of 17,500: the most it can send in any 60 s is 17,500 × 60 + 17,500 = 1,067,500, under the 1.17M quota. Shares: critical 1,000/s, high 8,500/s (Android high traffic peaks at 23.5K × 65% × 55% ≈ 8.4K/s), bulk 8,000/s guaranteed plus whatever the others leave.
What that means for a 50M-user push. Android recipients: 50M × 55% = 27.5M.
| Bulk rate | Time for 27.5M | |
|---|---|---|
| 8,000/s (transactional at peak) | 3,438 s ≈ 57 min | |
| 16,500/s (transactional quiet, bulk borrows) | 1,667 s ≈ 28 min | |
| Needed for 15 min | 27.5M ÷ 900 s ≈ 30.6K/s ≈ 1.83M/min | Above any quota we hold |
So the "within 15 minutes" promise holds for campaigns up to 8,000 × 900 = 7.2M Android recipients, about 13M users in total. For bigger ones, marketing either starts earlier, or we book temporary FCM quota 15 days ahead. That's a conversation to have before the campaign, not during it. (The iOS half is not the limit: 22.5M ÷ 900 s = 25K/s, inside our 32K/s.)
Other provider limits. SES quotas are per account and per Region: we need about 500M emails a day and a send rate well above our 5,787/s average, which means a negotiated increase with AWS; bulk email is shaped to whatever rate is granted. US SMS throughput is per sender: a short code starts at 100 message parts a second, so 8,100/s at peak needs raised throughput on several short codes; each sender gets its own bucket. REL 1
Other daily volumes we price below (assumptions, stated so the cost lines can be checked):
| Item | Assumption | Per day |
|---|---|---|
| Held for quiet hours | ~10% of bulk | ≈ 140M hold writes, and as many deletes |
| Inbox copies | ~20% of pushes set inbox: true | ≈ 260M inbox writes |
| Device registrations written | ~150M daily app users; we write only when something changed or last_seen is over a day old | ≈ 150M writes |
| Final status items (for the status API) | Critical and high only, kept 7 days | ≈ 600M writes |
Dedup memory. The track's formula, then a correction:
| Item | Math | Result |
|---|---|---|
| Typical (5-min windows), at 64 B per key | 23,148/s × 300 s × 64 B | ≈ 0.44 GB |
| Worst case (every window 24 h), at 64 B | 2B keys × 64 B | ≈ 128 GB |
64 bytes per key is a floor: a 32-byte binary key plus a TTL plus one hash-table entry. Real Redis keys cost more: the key string's header and allocator rounding (a 36-byte string takes a 48-byte slot), the main table entry and its bucket, and a second entry in the table of keys with an expiry. The dedup key also stores a value, the ~30-character notification_id, which takes about 64 bytes more as a small string object. We plan with about 200 bytes per dedup key and about 128 bytes per send marker (both to be confirmed with MEMORY USAGE on a test cluster):
| Item | Math | Result |
|---|---|---|
| Typical | 23,148/s × 300 s × 200 B | ≈ 1.4 GB |
| Worst case | 2B × 200 B | ≈ 400 GB |
| Send markers (1 h), at the peak rate | 81K/s × 3,600 s × 128 B | ≈ 37 GB |
| Planned total | 400 + 37 | ≈ 437 GB ≈ 407 GiB |
We size for the worst case, because the window is the producer's choice, not ours. Dedup cluster: 15 shards, each a primary and one replica in another AZ, on cache.r7g.2xlarge (52.82 GiB). ElastiCache reserves 25% of memory by default, leaving ≈ 39.6 GiB per shard, ≈ 594 GiB in all: the worst case fills 407 ÷ 594 ≈ 69%, just under our 70% alarm, which is why the alarm is there. Caps cluster: about 300M users receive bulk each day (assumption), ≈ 1.5 capped counters each, ~100 B per counter: 450M × 100 B ≈ 45 GB ≈ 42 GiB for one day of keys; 2 shards give 79 GiB usable (53%).
Audit lake
| Item | Math | Result |
|---|---|---|
| Per day | 2B events × 200 B (compressed; assumption) | 400 GB/day |
| Five years | 400 GB × 365.25 × 5 | ≈ 730.5 TB |
| Firehose, naive | 2B records a day, each billed as at least 5 KB: 10 TB/day | ≈ $8,800/month |
| Firehose, packed | ~9 events of ~500 B per record (4.5 KB, billed as 5 KB): 222M records × 5 KB = 1.11 TB/day ≈ 33.8 TB/month × $0.029/GB | ≈ $980/month |
Firehose bills every record in 5 KB steps, so we pack events before sending: nine times fewer records, one ninth of the bill.
OTP latency budget (P99). Parallel steps cost the slower of the two, not the sum. Adding P99s is pessimistic; if the sum fits, the real P99 fits.
| Step | P99 budget |
|---|---|
| ALB + mutual TLS + auth | 5 ms |
Dedup SET NX ‖ strong preference read (parallel) | max(2 ms, 10 ms) = 10 ms |
| SQS send | 20 ms |
Wait in critical-* (no backlog: pool sized for 3× peak) | 100 ms |
| Worker: contact lookup, token lease, send marker | 15 ms |
| Provider API call (SMS or APNs) | 500 ms |
| Total, first attempt | 650 ms |
| One retry after a fast failure (backoff ≤ 500 ms + second call) | +1,000 ms → 1.65 s |
That's inside 3 s with a retry to spare. What we promise is handed to the provider; how long a carrier takes to reach the handset isn't ours to promise, and we say so.
Monthly cost (us-east-1 list prices, 30.4 days; SMS priced as US 10DLC for illustration):
| Line | Math | ≈ Monthly |
|---|---|---|
| SMS (End User Messaging) | 200M/day × 30.4 = 6.08B × ($0.00581 base + carrier fees of a few tenths of a cent, varying by carrier; ≈ $0.0088 in all, illustrative). The line scales with the carrier fee. | $53.5M |
| Email (SES) | 500M/day × 30.4 = 15.2B × $0.10 per 1,000 | $1.52M |
| Push (direct to APNs/FCM) | no per-message fee | $0 |
| SQS | ~0.5 requests per message with batching × 60.8B messages | $12.2K |
| DynamoDB (on-demand) | Strong preference reads $7.6K; device and contact lookups $3.8K; final status for critical and high $11.4K; hold writes and deletes $10.6K; device writes $2.9K; inbox $4.9K; storage $1K | $42K |
| ElastiCache | 34 nodes (dedup 15 shards × 2, caps 2 shards × 2) × ~$0.70/h (r7g.2xlarge, Valkey; check the calculator) × 730 h | $17.4K |
| ECS Fargate | ~150 vCPU on average (250 at peak, 1.5× for losing an AZ) × ~$0.0395/h × 730 h | $4.3K |
| Firehose + S3 | $1K + $3.6K (year 5: 90 days in S3 Standard, the rest in Glacier Instant Retrieval) | $4.6K |
| NAT gateway + internet egress to APNs/FCM | ~39.5 TB/month × $0.045/GB processing, plus tiered data transfer out | $5.2K |
| ALB, CloudWatch, KMS | assumption | $6K |
| Total | ≈ $55.1M |
SMS is 97% of the bill. Email is about 3%. Everything we built, queues to caches to the lake, is about $92K, or 0.17%. The most valuable cost decision in this system isn't a cache size: it's moving a message from SMS to push or email whenever the user and the law allow, because at the illustrative $0.0088 one SMS costs as much as about 88 emails. COST 5
R2.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Separate queues vs one queue with a priority field | Separate queues, pools and provider shares per priority | Nine queues to run, reserved capacity partly idle. SQS can't reorder by priority, so the field alone does nothing. |
| Hold vs drop in quiet hours | Hold by default; drop when the producer says the message is worthless later | A hold table and a releaser; held messages re-checked at release |
| Dedup window vs memory | Up to 24 h per request, sized for the worst case | ~$17K/month of memory (both clusters); cheap next to SMS, and it keeps "once a day" a real promise |
| SNS mobile push vs direct APNs/FCM | Direct | We run connections, tokens and error handling. At 1.3B pushes/day SNS would cost ≈ $19.8K/month, 0.04% of the bill: money isn't the reason. Control is: our own per-provider buckets and FCM quota planning, Apple's 410 timestamp, per-priority headers and connection pools. |
| Strong preference reads vs a cache | Strong reads, ≈ $7.6K/month | Higher read cost. A cache is fine only with care: write the new value into the cache when preferences change, and fill a miss with "set if absent", never a plain overwrite, or a slow fill can put back an opt-in the user just revoked. |
| Standard vs FIFO for critical queues | Standard | FIFO's ordering isn't needed for OTPs, and its 5-minute dedup is weaker than ours |
| Cap at ingest vs at send | Ingest, once, after dedup | A held message counts on the day it was accepted; a crash can over-count by one |
R2.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| Provider throttling during a blast | FCM 429, bulk-push queue age climbing | Bulk's bucket share shrinks; bulk messages go back with a visibility delay of at least a minute; critical and high keep their reserved shares. The campaign finishes later, and marketing sees an honest estimate. |
| Preference store unavailable | DynamoDB errors or timeouts on the strong read | Fail toward not sending marketing, but still send OTPs. Critical messages go out: the user just asked for the code, and SMS STOP lists are also enforced by End User Messaging itself. High and bulk requests get 503; producers retry with the same key. Campaign fan-out pauses. |
| Dedup cluster down | Redis connection errors | Fail open: send, count the skipped checks, page. Duplicates possible for the duration. |
| DLQ redrive loop | The same messages cycle queue → DLQ → queue | Redrives go to an intake queue that runs the policy stage again (expired OTPs are dropped, new opt-outs honored). Each message carries a redrive count; at 2, it goes to an S3 quarantine for a person. We never redrive the raw DLQ straight into worker queues. |
| An AZ is lost | A third of tasks; some Redis primaries | Tasks run in 3 AZs with 1.5× capacity; ElastiCache promotes replicas in the surviving AZs (a few seconds of dedup misses, failing open); SQS and DynamoDB are regional. REL 10 · REL 11 |
| APNs signing key revoked | 403 InvalidProviderToken on every iOS push | Close all APNs connections, load the new key from Secrets Manager, reconnect. Apple accepts a provider token refresh at most once every 20 minutes and rejects tokens older than an hour, so each connection refreshes every 20–60 minutes. |
R2.9 Production Gotchas
| Gotcha | Symptom | Cause | Fix |
|---|---|---|---|
Retrying 4xx errors | APNs connections closed; FCM error ratio blocks quota increases | Retrying BadDeviceToken, INVALID_ARGUMENT, PayloadTooLarge | Retry only throttling, 5xx and timeouts; permanent errors fail at once |
| One queue for everything | OTPs 20 minutes late during campaigns | Head-of-line blocking | Queues, pools and provider shares per priority |
| Preferences checked at the producer | Opted-out users contacted by the one team that forgot | Forty implementations | One policy stage; nothing skips it, including redrives |
| PII in the audit trail | Phone numbers and message text in log files and the lake | Logging the rendered message | Log IDs and codes; HMAC the addresses; never log variables SEC 7 |
| Caps counted in workers | Users get fewer messages than the cap allows | At-least-once redelivery increments twice | Count once, at ingest, after dedup |
| Eviction on the dedup cluster | Silent duplicates after a traffic spike | volatile-lru or volatile-ttl evicted fingerprints | noeviction, size for the worst case, alarm at 70% |
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | Bulkheads per priority; provider quotas planned and tracked (FCM, SES, SMS throughput); fail-open dedup and fail-closed marketing; redrives through the policy stage; 1.5× capacity across 3 AZs REL 1 · REL 5 · REL 10 |
| Performance Efficiency | OTP P99 budget of 650 ms first try, parallel lookups costed as max; long-lived HTTP/2 connections with many streams; Redis for per-message checks, DynamoDB for records PERF 3 · PERF 4 |
| Security | Mutual TLS between producers and ingest; priority from reviewed templates, not requests; addresses HMAC'd and text never logged; consent log for legal proof SEC 7 · SEC 9 |
| Cost Optimization | ≈ $55.1M/month, 97% SMS; infrastructure 0.17%; Firehose records packed to beat the 5 KB rounding COST 3 · COST 5 |
| Operational Excellence | Alarms on queue age per priority, provider error and throttle rates, DLQ inflow, cluster memory, OTP P99 OPS 8 |
| Sustainability | Dedup, caps and token hygiene remove sends nobody wants (about 195M dead-token sends a day); bulk shaped rather than bursted onto peak capacity SUS 2 · SUS 3 |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Sees head-of-line blocking and isolates priorities end to end: queues, workers, connections and provider budget.
- Puts preferences, opt-outs, caps and quiet hours in one policy stage, and re-checks held and redriven messages.
- Separates "same request" (idempotency key) from "same message" (dedup key and window), and sizes the memory for the worst case the API allows.
- Knows each provider's limits and signals: FCM's per-project quota and how it grows, APNs' per-token
429and its410timestamp. - Counts things once, in a place that isn't at-least-once.
- Finds the real cost: SMS, not infrastructure.
Follow-up questions
-
"Why not let producers mark a message critical when it's really urgent?" Answer: because every team's message is "really urgent" to that team. Priority comes from the template's category, approved by a reviewer. A team that needs a critical template asks for one, and the critical pool stays small enough to keep its promise.
-
"A user changes time zone mid-flight. What happens to held messages?" Answer: the hold time was computed when the message was accepted. On release, the policy stage runs again with the new time zone; if the user is now in quiet hours, the message is held again. The device registration updates the time zone on the next launch.
-
"Could you send OTPs by push first and fall back to SMS, to save money?" Answer: yes, and at roughly $0.0088 per US text (illustrative; carrier fees vary) it's worth it: send the push with a short expiry; if the app hasn't confirmed receipt within ~5 seconds, send the SMS. It needs the app's receipt beacon, and it adds 5 seconds for users whose push doesn't arrive, which fits inside a 5-minute code.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "A priority field in one queue" | Queues don't reorder; workers and provider budget are shared anyway. |
| "The idempotency key deduplicates alerts" | It identifies requests; three runs of a job are three requests. |
| "APNs 429 means slow down everything" | It's about one device token; FCM's 429 is about the project. |
| "Delete the token on any 410" | The app may have registered it again after Apple's timestamp. |
| "Caching preferences is free speed" | A careless refill can resurrect an opt-in the user just revoked. |
Round 3 · Architect · "Global, Multi-Tenant, and Deliverability"
~45 min · Principal (L7) · 3 legal zones, 6 regions · ~10B notifications/day · ≈ 116K/s average, ≈ 405K/s peak · per-tenant SLOs · no tenant can degrade another
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Round 2 in 60 seconds. "We run the company's notification platform in one region: 2 billion a day, about 23,000 a second on average and 81,000 at peak, 65% push, 25% email, 10% SMS, 99.99%. Priority comes from each template's reviewed category: critical, high or bulk. Each priority has its own queues, worker pool and reserved share of every provider's rate, so a 50-million-user blast can't delay a login code; OTPs are handed to the provider in about 650 ms at P99. One policy stage at ingest applies suppression, preferences, frequency caps and quiet hours in the user's time zone, holds messages until morning, and re-checks them on release. A producer-chosen dedup key, hashed with user, channel and template, is set in Redis with a window of up to 24 hours; we sized that for the worst case, about 400 GB. Workers lease tokens from per-provider buckets, keep long-lived HTTP/2 connections to APNs and FCM, and clean tokens on 410 and UNREGISTERED. It costs about $55 million a month, 97% of it SMS; our own infrastructure is about $92,000. Open costs: one region, one company's reputation, one SMS route per country, and nobody outside the company can use it."
Architecture v2, compact
textteams ─mTLS─► ALB ─► ingest: dedup (Redis SET NX EX) → suppression + prefs (strong read) → caps (INCR) → quiet hours (hold table) └─► SQS: {critical, high, bulk} × {push, email, sms} + DLQs worker pools per priority ─lease tokens (per-provider buckets, reserved shares)─► APNs · FCM · SES · End User Messaging SMS outcomes ─packed─► Firehose ─► S3 (5 years) bounces · complaints · STOP ─► suppression list
Steps so far
| Step | Problem | Component |
|---|---|---|
| 1.1–1.2 | Checkout waits; provider errors | Queue per channel, 202; classified retries with jitter; DLQ |
| 1.3–1.5 | Duplicates; dead tokens; wording | Idempotency key, send claim; device registry; versioned templates |
| 2.1 | OTP behind a blast | Bulkheads per priority |
| 2.2 | Opted-out user texted | Policy stage; hold table; re-check on release |
| 2.3 | Same alert twice a day | Dedup key + window; send markers |
| 2.4 | Provider throttling | Per-provider token buckets with reserved shares |
| 2.5–2.6 | Dead tokens; too many teams | Token hygiene with 410 timestamps; central caps |
Open costs: one region; one set of sending identities; one SMS route per country; one customer.
R3.1 The Scope Raise
Interviewer: "We now sell this platform to other companies. About 10 billion notifications a day across all tenants, in North America, Europe and Asia. One tenant's spammy campaign must not get everyone's email sent to spam. We send SMS to about 100 countries, each with its own carriers and rules. EU tenants' data must stay in the EU. A provider or a whole region can go down. And tenants want delivery analytics."
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| How many tenants, and how uneven? | About 5,000. The top 10 send 40% of all traffic; most send a few thousand a day. | Per-tenant quotas and fair scheduling, and a way to carve out the giants (step 3.1). |
| Do tenants bring their own push credentials and email domains? | Yes: their own APNs keys and Firebase projects, and their own domains. Some big ones want their own IP addresses. | Push quotas become per tenant; email reputation can be separated per tenant (step 3.2). |
| What exactly must stay in the EU? | For EU tenants: user profiles, contact details, message content, logs and analytics. Contracts say "processed and stored in the EU". | Regional pipelines with a home region per tenant; any failover stays inside the EU (step 3.4). |
| What must survive a region loss, and how fast? | Login codes and security alerts, within minutes. Marketing can wait for the region to come back. | Fail over critical and high traffic only, to a partner region in the same zone (step 3.4). |
| Do tenants pick the SMS provider? | No, we do. Tenants pay the per-country price. They care that codes arrive. | Several providers per country, routed by health and cost (step 3.3). |
| What analytics do tenants need? | Every event (sent, delivered, bounced, opened, clicked) within a minute, pushed to them, and 13 months of history to query. | A per-tenant event stream with signed webhooks, and a tenant-partitioned lake (step 3.5). |
| Anything in the current stack we should know about? | An earlier team prototyped campaign features on Amazon Pinpoint. | Pinpoint's support ends 2026-10-30. A build-vs-buy decision per layer (step 3.6). |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Customers | One company, 40 teams | ~5,000 tenants; the top 10 send 40% |
| Volume | 2B/day, ≈ 81K/s peak | ~10B/day; ≈ 115,741/s average; ≈ 405K/s peak worldwide |
| Channels | Push, email, SMS, in-app | + WhatsApp; push 60%, email 36%, SMS 3%, WhatsApp 1% |
| Footprint | 1 region, 3 AZs | 3 legal zones (NA, EU, APAC), 2 regions each |
| Identity | Our domain, our numbers | Each tenant's own domains, IPs (some), sender IDs, push credentials |
| Isolation | Between priorities | + between tenants: capacity, reputation, data |
| Availability | 99.99% | 99.99% per tenant for critical traffic, through a region loss |
| Analytics | Internal audit lake | Per-tenant events in under a minute; 13 months queryable |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| Shared sending domain and IPs | Mailbox providers judge reputation by domain and IP. One tenant's purchased list raises complaints, and every tenant's email moves toward spam folders. SES can even pause the whole account. |
| Capacity shared first-come | One tenant's 30M-message campaign fills the bulk queues; every other tenant's newsletter waits an hour behind it. |
| One SMS route per country | A carrier problem in Brazil stops every tenant's Brazilian login codes, and we find out from their support tickets. |
| One region | A regional outage stops login codes for every tenant. And EU tenants' data sits in a US region, which breaks their contracts. |
| Analytics in our audit lake | Tenants can't query it, and a mixed lake makes every query a chance to show one tenant another's data. |
| Priority, dedup and caps keyed by user only | User IDs from different tenants can collide; every key needs the tenant in it. |
R3.3 New Requirements and API Additions
Tenants and API keys. Every call carries a tenant API key. We store only a hash of it, scoped to one tenant and one environment (test or live), and support two active keys so tenants can rotate without downtime.
httpPOST /v1/notifications HTTP/1.1 Host: api.eu.notify.example Authorization: Bearer ntk_live_7Hq... Idempotency-Key: 5f1c... Content-Type: application/json { "user_id": "cust-88", "template_id": "login_otp", "variables": { "code": "482913" } }
The host name names the tenant's legal zone. An EU tenant's keys only work on api.eu....
Tenant settings
json{ "tenant_id": "t_acme", "plan": "enterprise", "home_zone": "EU", "home_region": "eu-west-1", "partner_region": "eu-central-1", "quotas": { "critical_per_sec": 500, "high_per_sec": 5000, "bulk_per_sec": 20000, "bulk_per_day": 50000000 }, "email": { "ses_tenants": ["acme-transactional", "acme-marketing"], "ip_pool": "dedicated-acme" }, "push": { "apns_key_secret": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:t_acme/apns", "fcm_project": "acme-prod" } }
Sending identities. A tenant adds its domain and gets DNS records to publish:
httpPOST /v1/tenants/t_acme/sending-domains HTTP/1.1 Content-Type: application/json { "domain": "mail.acme.example" }
json{ "domain": "mail.acme.example", "dns_records": [ { "type": "CNAME", "name": "abc1._domainkey.mail.acme.example", "value": "abc1.dkim.amazonses.com" }, { "type": "MX", "name": "bounce.mail.acme.example", "value": "10 feedback-smtp.eu-west-1.amazonses.com" }, { "type": "TXT", "name": "bounce.mail.acme.example", "value": "v=spf1 include:amazonses.com ~all" }, { "type": "TXT", "name": "_dmarc.mail.acme.example", "value": "v=DMARC1; p=none; rua=mailto:dmarc@acme.example" } ], "status": "PENDING_VERIFICATION" }
(SES's Easy DKIM actually gives three DKIM CNAMEs; one is shown.) SMS senders are registered per tenant and country (POST /v1/tenants/t_acme/sms-senders), because in the US and many other countries the tenant's business, not ours, is the registered sender.
Webhooks
httpPOST /v1/tenants/t_acme/webhooks HTTP/1.1 Content-Type: application/json { "url": "https://hooks.acme.example/notify", "events": ["delivered", "bounced", "complained", "opened", "clicked"] }
Each delivery to the tenant is signed:
httpPOST /notify HTTP/1.1 Host: hooks.acme.example Content-Type: application/json X-Notify-Signature: t=1790445735,v1=5d41402abc4b2a76b9719d911017c592... { "events": [ { "event_id": "evt_01J9...", "type": "delivered", "notification_id": "ntf_...", "channel": "email", "occurred_at": "2026-09-26T18:02:13Z" } ] }
v1 is an HMAC-SHA256 of t + "." + body with the tenant's webhook secret. The tenant recomputes it and rejects the call if it doesn't match or if t is more than 5 minutes old (so a captured request can't be replayed later). Delivery is at least once; tenants deduplicate by event_id.
R3.4 Design Evolution: Tenants, Reputation, Routes, Regions and Events
Step 3.1: One Tenant's Blast Starved Everyone
The problem: at 16:00 a large retailer sends 30M marketing messages. Within a minute the bulk queues hold 30M of their messages, and a small tenant's 2,000-person newsletter, sent at 16:01, waits an hour behind them. What would you do? How does every tenant get a fair share, and what do we do with a tenant that's bigger than a fair share can ever be?
The two tenant-hotspot questions. Your biggest tenant is about to grow 10×; what happens? In a shared pool, they'd become the permanent noisy neighbor, and the fair queue would keep everyone else safe but slow them down. We move them to a dedicated cell before the migration, with a routing override (tenant → cell) that's a configuration change, not a data migration: new messages go to the new cell, and the old queues drain. How do you run a report across all tenants? Not by querying 5,000 tenants' partitions one by one from the live system (a scatter-gather that loads production). Platform-wide reports read the analytics lake with Athena: eventually consistent, minutes behind, and nowhere near the send path.
Primitive: Distributed Rate Limiting · Database Sharding & Partition Keys · Drill: Sharding tenant hotspot
Step 3.2: One Tenant's Spam Hurt Everyone's Inbox Placement
The problem: a new tenant uploads a bought list and mails it. Complaints spike, some addresses are spam traps, and within a day Gmail sends more of every tenant's mail from our shared domain and IPs to spam. SES warns that our account is under review. What would you do? How do we make each tenant's reputation its own, and stop a bad sender before it hurts others?
How the brake works. SES publishes every bounce and complaint through the tenant's configuration set; our event handler keeps a rolling rate per SES tenant; crossing our threshold flips the tenant's marketing to paused in our policy stage (new campaigns get 403 TENANT_MARKETING_PAUSED), and we can also pause the SES tenant directly (R3.9). SES's own reputation findings arrive as EventBridge events, so an SES-side pause reaches our dashboards within seconds.
Step 3.3: SMS to Brazil Is Failing on One Route
The problem: since 09:00, login codes to Brazilian numbers are accepted by our SMS provider but only 40% arrive. The provider's status page is green. Tenants' users can't log in. What would you do? How do we notice, route around it, and keep costs sane across 100 countries?
The two thread-stall questions. Why does a slow provider exhaust our workers even though it returns no errors? Each in-flight call holds a worker slot for its whole duration. By Little's law, calls in flight = arrival rate × latency: at 1,000 sends a second, 200 ms latency means 200 in flight, but 5 s means 5,000, and a pool of 1,000 slots is gone. The route's own bulkhead (a fixed slot pool per route) keeps that stall inside the route, and the breaker moves traffic away. Why not just set a 50 ms timeout on every provider call? A healthy SMS API often takes a few hundred ms, so a 50 ms timeout would fail healthy calls; each failed call becomes a retry, adding load exactly when things are slow; and for SMS, a timeout after the provider accepted means a duplicate text. Timeouts set just above the normal P99 catch hung calls; the breaker, looking at the error and delivery rates over a window, decides when a route is unhealthy, and half-open probes decide when it's back.
Primitive: Circuit Breaker, Bulkhead & Fault Tolerance · Drill: Circuit breaker cascading thread stall
Step 3.4: EU Data Must Stay in the EU, and a Region Can Fail
The problem: EU tenants' contracts require their data to be processed and stored in the EU. And last quarter a regional outage stopped every tenant's login codes for 90 minutes. What would you do? Where does each tenant's data live, what fails over where, and what happens to data written on both sides during a failover?
Conflicts during a failover. DynamoDB global tables check a condition only against the local copy, and by default resolve concurrent writes to the same item with last writer wins. Normally each tenant writes only in its home region, so there's no conflict. During a failover, both regions can write: a user opts out in the partner region while the home region, still half-alive, writes an older opt-in to the same item. Last writer wins can then keep the opt-in. Two rules prevent that:
- Consent is append-only. Every opt-in or opt-out is a new item (
CONSENT#<occurred_at>#<event_id>), never an overwrite, so replication can't lose one. The current state is computed from the log. - Opt-out wins ties. If an opt-in and an opt-out for the same channel and category are within 5 minutes of each other (more than any clock skew we expect), the opt-out wins. Suppression entries are add-only during a failover.
The replication-lag questions. A user opts out in Frankfurt, and a second later a campaign in Ireland texts them anyway: what went wrong? Asynchronous replication: Ireland read its local copy before the opt-out arrived, typically under a second behind. For marketing in normal operation this can't happen, because each tenant's traffic runs only in its home region, where the opt-out was written; during a failover, the window exists, which is why marketing doesn't fail over. Why not replicate every write synchronously? Every write, including 10 billion notifications' worth of status, would wait for another region, and a partner outage would block the healthy region. We scope strong consistency narrowly: if legal asks for guaranteed cross-region consent, DynamoDB's multi-Region strong consistency could hold just the consent table; it runs in exactly three Regions and adds cross-region latency to each consent write, a fine price for a rare write.
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active · Drill: Replication multi-region consistency
Step 3.5: Tenants Want Delivery Analytics and Webhooks
The problem: tenants ask "was this delivered?", "what's my bounce rate by campaign?", and want events pushed to their systems in under a minute. Some of their endpoints are slow; some are down for hours. What would you do? How do we give each tenant its events and history without one tenant's broken endpoint or big query hurting others?
Primitive: Change Data Capture & the Outbox Pattern · Message Queues vs Event Streams
Step 3.6: Should We Build the Providers Ourselves?
The problem: the CFO asks why we pay per message for email and SMS when we could run our own mail servers and connect to carriers. An engineer suggests the opposite: move everything onto a managed engagement platform and delete our pipeline. What would you do? Which layers do we build, which do we buy, and how do we decide?
Pinpoint, precisely. Amazon Pinpoint closed to new customers on 2025-05-20, and its support ends on 2026-10-30 (about a month from now). Its SMS, voice, push and WhatsApp capabilities continue as AWS End User Messaging, and email stays on SES. Campaign and journey features have no direct successor in that family, so anything built on them must move. We don't propose Pinpoint for anything new.
Round 3 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 3.1 | One tenant starves everyone | Per-tenant quotas; tiered pools with SQS fair queues; per-tenant provider buckets; cells for giants | Limits to manage; cells to run |
| 3.2 | One tenant's spam hurts all | Tenant domains with SPF/DKIM/DMARC; two SES tenants per customer; IP pools by tier; our own brakes; bulk-sender rules | Warm-up, DNS work, IP cost |
| 3.3 | One SMS route fails silently | Several routes per country, scored on delivery receipts, with breakers; senders registered on two routes; our own opt-out list | Contracts; OTP duplicates on failover |
| 3.4 | Residency and region loss | Zones of two regions; home and partner; global tables within a zone; critical and high fail over; append-only consent | Six regions; spare capacity |
| 3.5 | Tenants want events | Event stream; webhook system with signatures, retries and breakers; tenant-partitioned lake; replay from the lake | A second reliability problem |
| 3.6 | Build vs buy | Build orchestration; buy email and SMS delivery; push direct; no Pinpoint | Delivery-layer lock-in |
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Read it zone by zone. Each zone is a pair of regions running the full pipeline, with its tenant data replicated only inside the pair. A tenant's traffic normally runs only in its home region; the partner holds a copy of its settings, preferences, consent and tokens, and spare capacity for its critical and high traffic. Delivery fans out to SES (per-tenant SES tenants and IP pools), an SMS router with several routes per country, push with the tenant's own credentials, and WhatsApp. Events flow to the tenant's webhooks and to a tenant-partitioned lake in the same zone. The APAC zone has the same layout.
Trace 1: a bad tenant's campaign is stopped
- A self-serve tenant,
t_newco, uploads 400,000 addresses and starts a campaign at 10:00. Their quota caps them at 200 a second, so the campaign takes over half an hour; the fair queue keeps other self-serve tenants' mail moving. - In the first 90 seconds, 18,000 messages go out. Bounce events for them arrive within minutes: by 10:06, 1,100 hard bounces (6.1%) and 21 complaints (about 0.12%).
- Our rolling rate crosses 4% bounces. The policy stage pauses
t_newco's marketing: queued messages are dropped at send time withTENANT_MARKETING_PAUSED, new campaign sends get403, and their account manager is notified. - The SES tenant
newco-marketingwas also flagged by SES's reputation policy; because it sits in the self-serve AWS account, no enterprise tenant's sending was at risk. t_newco's password resets (SES tenantnewco-transactional) keep flowing.
Trace 2: SMS route failover for Brazil
- At 09:00 route A's delivered-within-60-s share for Brazil drops from 97% to 40%. API calls still succeed.
- At 09:05 the 5-minute window confirms it; route A's breaker opens for Brazil. OTPs move to route B, where every tenant sending OTPs to Brazil was registered at onboarding.
- OTPs sent on route A between 09:00 and 09:05 whose receipts never came are re-sent on route B with the same code. Some users get the code twice.
- At 09:20 the breaker lets 1% of Brazilian bulk traffic probe route A (half-open); at 10:10 its delivery share is back to normal, and the breaker closes.
Trace 3: an EU tenant during a regional outage
- eu-west-1 has a severe outage at 14:00. Route 53 health checks on
api.eu...fail, and within a couple of minutes the name resolves to eu-central-1. t_acme's login-code requests arrive in Frankfurt. Preferences, consent, suppression and tokens are there (replicated a second or so behind). The partner's reserved capacity serves their critical and high traffic.- Their bulk requests get
503 BULK_PAUSED_FAILOVER; the campaign tool retries later. - Messages already queued in eu-west-1 wait there. When the region recovers, they drain; the policy stage re-checks each (expired OTPs are dropped, opt-outs made in Frankfurt during the outage have replicated back and are honored).
- No EU data went outside the EU at any point, apart from the message itself going to Apple, Google, SES's EU endpoints and carriers, which every tenant's data-processing agreement lists.
R3.6 Numbers and Cost
Traffic
| Item | Math | Result |
|---|---|---|
| Average | 10,000,000,000 ÷ 86,400 s | ≈ 115,741/s |
| Peak | 115,741 × 3.5 | ≈ 405,093/s ≈ 405K/s |
| By zone (assumption) | NA 50%, EU 30%, APAC 20% | Peaks ≈ 202.5K/s, 121.5K/s, 81K/s |
| Per region (tenants split about evenly between a zone's two regions) | zone peak ÷ 2 | NA ≈ 101K/s, EU ≈ 60.8K/s, APAC ≈ 40.5K/s |
| Failover sizing (largest region) | own 101K/s + partner's critical and high (30% × 101K ≈ 30.4K/s) | ≈ 131K/s of ingest capacity per NA region |
| By channel per day | 60% / 36% / 3% / 1% | Push 6B, email 3.6B, SMS 300M, WhatsApp 100M |
Each region is Round 2's design at Round 2's scale or a little above: the largest home region carries about 1.25× Round 2's 81K/s peak, plus failover headroom. Its dedup cluster is sized with the same method: its daily volume × 128 bytes for the worst case.
Webhook volume
| Item | Math | Result |
|---|---|---|
| Events per notification | sent + delivered or bounced + a little engagement (assumption) | ≈ 3.3 |
| Share subscribed by tenants (assumption) | 30% of all events | |
| Events per day | 10B × 3.3 × 30% | ≈ 9.9B ≈ 10B/day (≈ 116K/s average) |
| Webhook calls | ~20 events per call on average | ≈ 5,800/s average, ≈ 20K/s at peak |
| Egress | 10B × ~400 B = 4 TB/day ≈ 121.6 TB/month | ≈ $9.3K/month at tiered internet rates |
Dedicated IPs. Assume 400 enterprise tenants with 3 dedicated IPs each: 1,200 × \$24.95 ≈ \$30K a month.
Monthly cost. Two very different kinds of money: fees we pass through to tenants (per message, charged to the tenant who sent it), and our own platform.
| Line | Math | ≈ Monthly |
|---|---|---|
| Pass-through: SMS | 300M/day × 30.4 = 9.12B × $0.03 blended (assumption: US 10DLC is ≈ $0.009, illustrative: $0.00581 base plus carrier fees of a few tenths of a cent; many countries cost several times that; the line scales with these fees) | $274M |
| Pass-through: email | 3.6B/day × 30.4 = 109.4B × $0.10 per 1,000 (list price; volume plans lower it) | $10.9M |
| Pass-through: SES tenants | 109.4B × $0.005 per 1,000 emails, plus $0.005 per tenant per month | $0.55M |
| Pass-through: WhatsApp | An AWS per-message fee plus Meta's fee by country and message category | not priced here |
| Push | Tenants' own APNs/FCM; no per-message fee | $0 |
| Platform core | Round 2's infrastructure ($92K per 2B/day, minus its $4.6K lake) scaled ×5 | $437K |
| Partner-region reserve | ~30% of compute and cache (Round 2's $4.3K + $17.4K, ×5) for partners' critical and high traffic | $33K |
| Replicated writes | Global-table writes billed in both regions of a zone | $20K |
| Dedicated IPs | 1,200 × $24.95 | $30K |
| Webhooks | Egress $9.3K + packed SQS $1.8K + dispatchers $1.5K | $12.6K |
| Lake | ~33B events/day (3.3 per notification) × ~200 B = 6.6 TB/day into Firehose, packed ~22 per record (per tenant and day, or with record deaggregation on): ≈ $6.6K ingest + ≈ $4K dynamic partitioning; stored compressed at ~60 B per event ≈ 2 TB/day; S3 at year 5 (mostly Glacier Instant Retrieval) ≈ $18K | $29K |
| Our platform | ≈ $0.56M | |
| Everything | ≈ $286M + WhatsApp fees |
Where the money is. Provider fees are about 99.8% of what flows through the system; our platform is about $0.56M a month, 0.2%. That shapes the business: we charge tenants the provider fees plus a margin, and our costs scale with orchestration, not delivery. It also shapes the engineering: routing SMS by cost among healthy routes (step 3.3) moves more money than any infrastructure tuning could. COST 3
R3.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Shared vs dedicated IPs | Dedicated for enterprise; shared pools of peers for standard; a separate account for self-serve | Warm-up time and $24.95/IP; shared-pool tenants still share some fate, bounded by our brakes and SES tenants |
| Fair queuing vs strict priority | Both: strict between priorities (an OTP always beats marketing), fair between tenants inside a priority | A tenant with a huge backlog waits longer than first-come would give it; that's the point |
| Cross-region failover vs residency | Fail over only inside the legal zone, only critical and high | A zone that loses both regions stops; bulk waits for the home region |
| Build vs buy | Build orchestration; buy email and SMS delivery; push direct | Lock-in at the delivery layer; we track providers' lifecycles (Pinpoint's end of support is the reminder) |
| Global table LWW vs strong consistency for consent | Append-only consent with opt-out-wins; multi-Region strong consistency kept as an option for the consent table only | Rules we must explain to legal instead of a single copy |
| Two SES tenants per customer vs one | Two (transactional, marketing) | Twice the SES tenants (10,000 by default per account; we request more) |
Closing the loop. The opening question was: how do we get the right message to the right person once, at the right time, through carriers we don't control? The answer is now:
- The right message: templates with reviewed categories, rendered from versions that never change.
- To the right person: one owner per device token, contact details kept clean by the providers' own answers, and consent recorded as facts that replication can't lose.
- Once: idempotency keys for requests, dedup keys with windows for logical messages, send markers for redeliveries, collapse IDs on devices, and honesty about the one case no one can close: a timeout after the provider accepted.
- At the right time: priorities with their own lanes, quiet hours in the user's time zone, caps across teams, and fairness across tenants.
- Through carriers we don't control: we learn each carrier's rules and stay under them, score routes by what actually arrives, keep each tenant's reputation its own, and fail over only where the law allows.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A provider outage (FCM, SES in one region, an SMS route) | Error or throttle rates jump; or, for SMS, delivery receipts stop | Push and email: queues absorb it, bulk pauses its consumers, critical retries with backoff. SMS: the route's breaker opens and traffic moves (step 3.3). |
| Reputation incident (a dedicated IP is blocklisted) | SES reputation finding, a jump in bounces from one mailbox provider | The affected tenant's marketing pauses; the IP is taken out of its pool; transactional mail moves to a clean IP in the tenant's pool; we work the delisting; the tenant's list practices are reviewed before marketing resumes. SEC 10 |
| A tenant flood (a bug loops sends) | One tenant at its quota, 429s to it only | Quotas bound it; fair queues protect others; caps and dedup stop users being spammed; we can drop the tenant's quota to zero for bulk while keeping critical. |
| A regional outage | Route 53 health checks fail for a region | Critical and high move to the partner within minutes; bulk waits; queued messages are re-checked on recovery (Trace 3). |
| Tenant webhook endpoints down | Webhook backlog and age grow for one tenant | Retries with backoff for 24 hours, then events are parked for replay; the endpoint's breaker stops us from spending dispatchers on it; the tenant's dashboard shows the backlog. |
| Both regions of a zone down | The zone's API is unavailable | Tenants in that zone can't send until one region returns. Residency rules out moving EU traffic to the US; that's a business decision we state up front, not a surprise. REL 13 |
R3.9 Runbook and Incident Response
Golden signals, per region and per tenant OPS 8 · REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| OTP handed-to-provider P99 | > 3 s for 5 min | P1 | Which step grew: queue wait, provider call? Check the critical pool and route breakers |
| Critical queue oldest-message age | > 5 s | P1 | Scale the critical pool; check its provider shares |
| High queue oldest-message age | > 10 s | P2 | Scale; check for one tenant's surge |
| Bulk backlog vs campaign plan | > 30 min behind | P3 | Provider throttling? Tell the tenants affected |
| Provider throttle and error rates | FCM 429 > 1% or APNs 4xx > 5% of sends, for 5 min | P2 | Whose project or key? Check that permanent errors aren't being retried |
| SMS route delivered share | < 80% of its 7-day normal, per country | P2 | Confirm the breaker opened; notify the provider |
| SES bounce / complaint rate (account) | > 2% / > 0.05% | P1 | Find the tenant; pause its marketing (CLI 7) |
| SES bounce / complaint rate (tenant) | > 4% / > 0.08% | auto | Automatic marketing pause; account manager notified |
| DLQ inflow | any message in a critical DLQ | P2 | Inspect; redrive through the intake queue after the fix (CLI 5) |
| Webhook backlog per tenant | oldest > 1 h | P3 | Endpoint down? Tell the tenant |
| Global-table replication latency | > 30 s for 5 min | P2 | Partner may be stale; hold any planned failover drill |
Emergency: stop all marketing now (a reputation incident, a legal instruction, a bad template sent to everyone). Each step can be repeated safely. OPS 10
- Set the bulk-pause flag (CLI 3). The fan-out job stops expanding campaigns, and bulk workers stop receiving within a few seconds. Messages stay in the queues.
- If one campaign's queued messages must never be sent (for example, a wrong price in a template), cancel the campaign (CLI 4). Bulk workers keep a list of cancelled campaigns, refreshed every few seconds, and drop that campaign's messages at send time with
CAMPAIGN_CANCELLED: the same mechanism asTENANT_MARKETING_PAUSEDin Trace 1. Never purge a shared bulk queue: there are no per-campaign queues, so it holds every tenant's marketing. A purge is only for a queue in a giant tenant's dedicated cell, when everything in it must go (CLI 4b). - If one tenant is the cause, pause its SES marketing tenant directly (CLI 7) as well.
- When it's fixed: clear the flag; bulk resumes at its shaped rate.
DLQ redrive always targets the intake queue, which runs the policy stage again: expired OTPs are dropped, new opt-outs honored, and each message's redrive count is increased; at 2 it goes to quarantine instead.
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace names, account IDs and times with real ones.
text# 1. How old is the oldest OTP waiting in eu-west-1? (age is a CloudWatch metric, not a queue attribute) aws cloudwatch get-metric-statistics --region eu-west-1 --namespace AWS/SQS --metric-name ApproximateAgeOfOldestMessage --dimensions Name=QueueName,Value=critical-sms --statistics Maximum --period 60 --start-time 2026-09-26T14:00:00Z --end-time 2026-09-26T14:30:00Z # 2. How many messages are waiting and in flight in a bulk queue? aws sqs get-queue-attributes --region eu-west-1 --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/bulk-push --attribute-names ApproximateNumberOfMessages ApproximateNumberOfMessagesNotVisible # 3. Pause all marketing in this region (our own flag, read by fan-out and bulk workers) aws ssm put-parameter --region eu-west-1 --name /notify/bulk/paused --value true --type String --overwrite # 4. Cancel one campaign: bulk workers drop its queued messages at send time (other campaigns and tenants untouched) aws ssm put-parameter --region eu-west-1 --name /notify/campaigns/t_acme/fall-sale-2026/cancelled --value true --type String --overwrite # 4b. IRREVERSIBLE, dedicated cells only: delete every message in one giant tenant's cell bulk queue (never a shared queue, never critical or high) aws sqs purge-queue --region eu-west-1 --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/cell-acme-bulk-push # 5. Move fixed messages from a DLQ back through the policy stage, at a gentle rate aws sqs start-message-move-task --region eu-west-1 --source-arn arn:aws:sqs:eu-west-1:111122223333:critical-sms-dlq --destination-arn arn:aws:sqs:eu-west-1:111122223333:redrive-intake --max-number-of-messages-per-second 50 # 6. Check the redrive's progress aws sqs list-message-move-tasks --region eu-west-1 --source-arn arn:aws:sqs:eu-west-1:111122223333:critical-sms-dlq # 7. Pause one customer's marketing SES tenant aws sesv2 update-reputation-entity-customer-managed-status --region eu-west-1 --reputation-entity-type RESOURCE --reputation-entity-reference arn:aws:ses:eu-west-1:111122223333:tenant/newco-marketing/tenantId --sending-status DISABLED # 8. Look at an SES tenant's status and its open reputation findings aws sesv2 get-tenant --region eu-west-1 --tenant-name newco-marketing aws sesv2 list-recommendations --region eu-west-1 --filter '{"RESOURCE_ARN":"arn:aws:ses:eu-west-1:111122223333:tenant/newco-marketing/tenantId"}' # 9. Is the SES account itself healthy? (enforcement status, send quota) aws sesv2 get-account --region eu-west-1 # 10. Account bounce rate over the last day aws cloudwatch get-metric-statistics --region eu-west-1 --namespace AWS/SES --metric-name Reputation.BounceRate --statistics Maximum --period 3600 --start-time 2026-09-25T14:00:00Z --end-time 2026-09-26T14:00:00Z # 11. Numbers that replied STOP to a tenant's senders aws pinpoint-sms-voice-v2 describe-opted-out-numbers --region eu-west-1 --opt-out-list-name t-acme # 12. Stop the bulk push workers entirely (messages stay queued) aws ecs update-service --region eu-west-1 --cluster notify-euw1 --service bulk-push-workers --desired-count 0
A purge can take up to 60 seconds to finish, and a queue can't be purged again within 60 seconds. End User Messaging SMS still uses the pinpoint-sms-voice-v2 name in the CLI; that's the SMS service, not Pinpoint's campaign features.
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Zones of two regions with failover for critical and high traffic; per-tenant quotas and cells; SMS routes with breakers; provider and SES tenant quotas tracked per region REL 1 · REL 10 · REL 13 |
| Performance Efficiency | Strict priority across tiers, fair queues inside; each region near Round 2's proven scale; OTPs routed to the fastest healthy SMS route PERF 1 · PERF 3 |
| Security | Hashed, scoped, rotatable tenant API keys; data tagged by residency zone and kept there; signed, time-limited webhooks over TLS; tenant filters applied by us on every lake query; a runbook for reputation incidents SEC 2 · SEC 7 · SEC 9 · SEC 10 |
| Cost Optimization | ≈ $0.56M/month platform vs ≈ $286M of pass-through fees, metered per tenant; cost-aware SMS routing; build only the orchestration COST 3 · COST 10 · COST 11 |
| Operational Excellence | Per-tenant golden signals with first actions; an emergency marketing stop; DLQ redrives through the policy stage; a CLI playbook OPS 8 · OPS 10 |
| Sustainability | Regions chosen by where users and laws are; bulk shaped and deferred instead of bursting onto peak capacity; lake tiered to colder storage and kept only as long as needed SUS 1 · SUS 2 · SUS 4 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Treats every shared resource as a way one tenant can hurt another: capacity, provider quota, sending reputation, data, analytics. Isolates each, and names the cost.
- Knows how inbox reputation actually works (domains, IPs, SPF/DKIM/DMARC, complaint rates, bulk-sender rules) and builds automatic brakes below the providers' own thresholds.
- Measures SMS routes by delivery, not by API success, and ties failover to sender registration.
- Designs residency first and failover inside it, and explains what global tables do with conditions and conflicts, including during a failover.
- Separates pass-through costs from platform costs, and decides build vs buy per layer, including a vendor's end of life.
Follow-up questions
-
"A tenant wants to move from the US zone to the EU zone." Answer: it's a migration, not a setting. We create the tenant in the EU zone, copy its settings, preferences, consent log, suppression and tokens there, set up EU SES tenants and verify its domains in the EU regions, switch its API keys to the EU host, then drain and delete the US data. Its lake history moves too, or is deleted, as its contract says.
-
"A tenant says a user never got their code. How do you answer?" Answer: from the tenant's event stream and our audit: accepted at, which priority and route, handed to the provider at, and the provider's receipt (or none). If the SMS route shows no receipt, the breaker history for that country and time tells us whether it was a route problem.
-
"Why not let big tenants run their own SES accounts and just orchestrate?" Answer: some will want exactly that, and it's a valid enterprise option ("bring your own sending account"): their reputation becomes fully theirs. The costs are onboarding complexity, per-account quotas they must manage, and that our brakes become advice. We'd offer it to the largest tenants, not as the default.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "Shared IPs are fine if content is good" | Reputation follows domains and IPs; one bad list hurts everyone on them. |
| "Fail over by switching the provider when its API errors" | SMS routes can fail silently; only delivery receipts show it. |
| "Replicate everything to every region" | Breaks residency and pays to replicate bulk traffic that can wait. |
| "Global tables resolve conflicts, so consent is safe" | Last writer wins can keep an older opt-in written during a failover. |
| "Put DAX in front of the preferences global table" | Writes replicated from the other region bypass the local DAX, so it serves stale preferences until its cache entries expire. |
| "Build on Pinpoint" | Its support ends on 2026-10-30; SMS, push and WhatsApp continue as AWS End User Messaging, email on SES. |
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0–5 min | Scoping: which events, channels, how fast, duplicates, provider down? | Restate Round 1 in 60 seconds | Restate Round 2 in 60 seconds |
| 5–15 min | Requirements + API (202, idempotency key, device registration) | Scope raise → what breaks | Scope raise → what breaks |
| 15–40 min | Steps 1.0–1.5: queue → retries and DLQ → idempotency and send claim → device registry → templates | Steps 2.1–2.6: bulkheads, policy stage, dedup window, provider limits, token hygiene, caps | Steps 3.1–3.6: tenant fairness, reputation isolation, SMS routes, zones and failover, events, build vs buy |
| 40–50 min | Numbers: traffic, backlog, drain time, cost | Numbers: FCM quota and blast time, connections, dedup memory, OTP budget, provider costs | Numbers: per-region load, failover capacity, webhooks, pass-through vs platform cost |
| 50–60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint. For the queue underneath all of this, see Design a Distributed Message Queue.
The Two Sentences That Matter Most
- Opening a round: "Before I design: what triggers a message, which channels, how fast must it be handed over, and how bad is a duplicate compared with a missed message?"
- When the scope is raised: "Here's what breaks, and I'll fix it in this order: anything that sends the wrong thing to the wrong person, then anything that delays login codes, then the carriers' limits, then cost."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What if the email provider is down for an hour?" (REL 5) | The queue holds the messages, we pause the consumer instead of burning retries, and we drain at the provider's rate when it's back. | 1 | Steps 1.1–1.2, R1.9 |
| "How do login codes survive a marketing blast?" (REL 10) | Separate queues, workers and reserved provider shares per priority, so bulk can't touch the critical path. | 2 | Step 2.1 | |
| "What are your hard limits?" (REL 1) | FCM's per-project quota and how it grows, SES's rate per region, SMS throughput per sender; bulk is shaped to fit them. | 2–3 | Step 2.4, R2.6 | |
| "What if a region goes down?" (REL 13) | Critical and high traffic fail over to the partner region in the same legal zone; bulk waits. | 3 | Step 3.4 | |
| Performance | "How is an OTP handed over in under 3 seconds?" (PERF 1) | A 650 ms P99 path with parallel lookups and no backlog, with room for one retry. | 2 | R2.6 |
| Security | "How do you protect personal data?" (SEC 7) | Classify it, keep it out of logs, HMAC addresses in the audit lake, and keep each zone's data in its zone. | 2–3 | R2.5, Step 3.4 |
| "How do tenants trust your webhooks?" (SEC 9) | TLS plus an HMAC signature over timestamp and body, rejected if older than 5 minutes. | 3 | R3.3 | |
| "What do you do when a tenant sends spam?" (SEC 10) | Automatic marketing pause below SES's thresholds, SES tenant pause, IP pulled from the pool, reviewed before resuming. | 3 | Step 3.2, R3.8 | |
| Cost | "Where does the money go?" (COST 3) | SES in Round 1 (82% of ≈ $1,480); from Round 2 on, SMS by far (97%), and our own infrastructure is under 0.2%. | 1–3 | R1.7, R2.6, R3.6 |
| "Why not run your own mail servers?" (COST 11) | Reputation and carrier relationships are a specialty; we build orchestration and buy delivery. | 3 | Step 3.6 | |
| Operations | "How do you stop a bad campaign right now?" (OPS 10) | One flag pauses bulk everywhere in a region; cancelling a campaign drops its queued messages at send time without touching anyone else's. | 3 | R3.9 |
| "How do you know it's healthy?" (OPS 8) | OTP P99, queue age by priority, provider throttle and error rates, bounce and complaint rates, DLQ inflow, per tenant. | 1–3 | R1.10, R3.9 | |
| Sustainability | "Where is this system wasteful?" (SUS 3) | Sends nobody wants: dead tokens, duplicates, and the tenth push of the day; hygiene, dedup and caps remove them. | 2 | Steps 2.3, 2.5, 2.6 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| Decoupling | Queue between producers and providers; 202; one queue per channel. | Bulkheads per priority, end to end, including provider budget. | Fair queuing between tenants inside strict priorities; cells for giants. |
| Duplicates | Idempotency key; send claim; honest about the timeout case. | Dedup key with a window, sized for the worst case; counts done once, not in at-least-once consumers. | Knows what's lost on regional failover (dedup state) and accepts it for OTPs. |
| Respecting the user | Templates; no sends to rejected addresses. | One policy stage: suppression, preferences, caps, quiet hours; consent log; re-check on release and redrive. | Consent that survives replication conflicts; residency by design. |
| The carriers | Retries with backoff and jitter; DLQ; pause during outages. | Each provider's quota and error semantics; token buckets with reserved shares; token hygiene. | Reputation isolation and bulk-sender rules; SMS routes scored by delivery; vendor lifecycles. |
| Numbers | Traffic, backlog and drain time, cost. | FCM quota math and blast time, dedup memory corrected, OTP budget with max for parallel steps, provider costs. | Per-region and failover capacity, webhook volume, pass-through vs platform cost. |
| Evolving under new scope | Builds from the direct call, one problem at a time. | Opens with what breaks; fixes what harms users first. | Asks where one tenant can hurt another, and what the law and the business must decide. |