Design Airbnb's notification system
Remix this answer and make it yours, or start blank. Either way you work beside the reference, one chapter at a time, and it stays private until you publish it.
Problem statement
Send booking confirmations, reminders and host messages over push, email and SMS, respecting user preferences and time zones, without duplicates.
Requirements
We are building Airbnb's shared notification platform: one service that every product team calls to reach guests, hosts and employees over push, email, SMS and in-app inbox. It handles four kinds of send: event-triggered (booking confirmed), scheduled relative to a trip (check-in day lock code), bulk campaigns (promotion to prospective hosts), and interest signals (a saved listing becomes bookable for your dates).
Functional requirements
- Product services submit a notification intent (
booking_confirmedfor user U, booking B); the platform picks channels, renders the template in the user's locale and delivers. - Respect per-user preferences per category and channel (marketing email off, trip reminders on), legal opt-outs (SMS STOP, email unsubscribe), and quiet hours in the recipient's local time zone.
- Schedule sends relative to a trip milestone ("09:00 local on check-in day"), and cancel or move them when the booking is cancelled or the dates change.
- Run bulk campaigns to a segment of millions, with throttling, pause and kill switch.
- Never send the same logical notification twice, even across retries, replays and duplicate upstream events.
- Track delivery status per message (sent, delivered, bounced, opened) from provider webhooks; expose history for support agents.
Non-functional requirements
- Transactional notices (booking confirmation, security codes) reach the provider within 5 s p99; marketing can lag minutes to hours.
- At-least-once internally, effectively-once to the user via idempotency keys.
- Bulk traffic must never delay transactional traffic.
- A provider outage (one SMS vendor down) degrades to a fallback vendor or channel, not to lost messages.
- Auditable: for any message, explain why it was or was not sent.
Back-of-the-envelope estimation
- ~150M active users, ~2M bookings/day. Each booking drives ~10 transactional notices over its life (confirmation, reminders, host messages, review asks) → 20M/day ≈ 230/s average, ~2K/s peak.
- Host-guest messages: ~10M/day, each fanned to push + email digest → ~20M sends/day.
- Bulk campaigns: up to 50M recipients in one run; at a 20K/s throttle that is ~45 min.
- Total ~100M deliveries/day ≈ 1.2K/s average, 25K/s during campaigns.
- Message log: 100M/day × ~1 KB = 100 GB/day; keep 90 days hot (~9 TB), older in object storage.
- Scheduled sends pending at any moment: ~5M future trips × ~4 milestone notices ≈ 20M timers.
API design
POST /v1/notifications
{ "idempotency_key": "booking_confirmed:B123:guest",
"type": "booking_confirmed", "recipient": {"user_id": "U9", "role": "guest"},
"data": {"booking_id": "B123", "listing_id": "L5"},
"send_at": null, "priority": "transactional" }
→ 202 { "notification_id": "n_7f..", "status": "accepted" }
→ 200 with the original notification_id on a repeated idempotency_key
POST /v1/schedules # trip-relative timer
{ "key": "checkin_lockcode:B123", "type": "checkin_lock_code",
"anchor": {"booking_id": "B123", "milestone": "check_in"},
"offset": "-PT0H", "local_time": "09:00" }
DELETE /v1/schedules/{key} # booking cancelled
POST /v1/campaigns { "template": "host_promo_q4", "segment_id": "seg_42",
"rate_per_s": 20000, "start_at": "...", "channels": ["email"] }
POST /v1/campaigns/{id}:pause | :resume | :cancel
POST /v1/interest { "user_id": "U9", "listing_id": "L5", "dates": ["2026-12-20","2026-12-27"] }
GET /v1/users/{id}/preferences ; PUT same with {category: {channel: bool}}
GET /v1/notifications/{id} # status timeline + suppression reason
POST /v1/providers/{vendor}/webhooks # delivery receipts, bounces, STOP replies
Most producers do not call HTTP directly: they emit domain events (BookingConfirmed) on Kafka, and a trigger rules service maps events to notification intents. The HTTP API exists for services without an event, for tools and for support.
Data model
| Table | Store | Key / partition | Notes |
|---|---|---|---|
notification |
MySQL (sharded by recipient_id) | PK notification_id; unique (idempotency_key) | type, recipient, data JSON, state, created_at |
delivery |
Cassandra | partition notification_id, cluster channel+attempt | provider, provider_msg_id, status, timestamps |
delivery_by_user |
Cassandra | partition (user_id, month), cluster ts desc | support history, frequency caps |
preference |
MySQL + Redis cache | PK user_id | JSON {category: {channel: on/off}}, quiet hours, tz, locale |
suppression |
MySQL + Redis set | PK (channel, address_hash) | hard bounces, STOP, spam complaints; global, beats preferences |
schedule |
MySQL | PK schedule_key; index (fire_bucket, state) | anchor, resolved fire_at UTC, version |
campaign, campaign_chunk
|
MySQL | campaign_id; chunk (campaign_id, chunk_no) | segment snapshot ref, rate, state, cursor |
template |
MySQL + CDN cache | (template_id, version, locale) | owner team, category, allowed channels, approval state |
interest |
MySQL | PK (user_id, listing_id); index listing_id | date range, created_at, last_notified_at, TTL 60 days |
provider_msg_index |
Redis / DynamoDB | provider_msg_id → (notification_id, channel) | for webhook lookup, TTL 30 days |
Relational stores hold things with uniqueness and state transitions; the append-heavy delivery log goes to Cassandra because it is write-mostly and read by key.
High-level design
flowchart LR
P[Product services] --> K[Kafka domain events]
K --> TR[Trigger rules]
API[Notification API] --> IN[Intake and dedup]
TR --> IN
SCH[Scheduler] --> IN
CMP[Campaign runner] --> IN
IN --> EL[Eligibility and preferences]
EL --> RQ["Priority queues: tx, reminder, bulk"]
RQ --> RD[Render]
RD --> PA["Provider adapters: APNs FCM email SMS"]
PA --> V[External vendors]
V -- webhooks --> WH[Webhook ingest]
WH --> LOG[(Delivery log)]
PA --> LOG
EL --> PREF[(Preferences and suppression)]
-
Booking confirmation email. Payments emits
BookingConfirmed. Trigger rules emit two intents (guest, host) with keysbooking_confirmed:B123:guest|host. Intake inserts intonotificationwith the unique key; a duplicate event hits the constraint and is dropped. Eligibility checks categorytrip_transactional(cannot be turned off for email; push follows preference), resolves locale and channels, then enqueues on the transactional topic. Render fills the template; the email adapter sends via the primary vendor and recordsprovider_msg_id. The vendor's delivered webhook updates the delivery row. -
Bulk outreach to prospective hosts. Marketing creates a campaign on a frozen segment snapshot (list of user_ids in object storage). The runner splits it into 10K-user chunks, and workers lease chunks, run eligibility in batch (marketing opt-in, suppression, frequency cap of 2 promos/week, quiet hours) and push to the bulk topic at the campaign rate. Key:
campaign:C42:U9. -
Check-in day lock code SMS. On confirmation a schedule
checkin_lockcode:B123is created with fire_at = 09:00 in the listing's time zone on check-in date, converted to UTC. The scheduler fires it into intake, where a fresh read of the booking confirms it is still active and the code is fetched at send time (never stored in the schedule). Date changes rewrite the schedule with a higher version; a cancellation deletes it. -
Listing becomes bookable. The availability service emits
CalendarOpened(L5, dates). The interest matcher looks upinterestrows by listing_id, intersects dates, and emits intents keyedinterest:L5:U9:2026-12-20, capped at one per user per listing per week.
Deep dives
Eligibility, preferences and suppression under load. Every send runs the same ordered checks, and the first one that fails becomes the recorded reason: (1) template category allowed for the role, (2) global suppression for the address, (3) user preference for category × channel, (4) frequency cap, (5) quiet hours → defer, not drop. Preferences live in Redis (one hash per user, ~200 B, 150M users ≈ 30 GB) with MySQL as truth and write-through on change. Campaign workers check in batch: HMGET/pipelined lookups for 10K users per chunk take ~20 ms. Suppression is checked again at the adapter right before sending, because a STOP reply can arrive between enqueue and send. Transactional security messages (2FA codes) bypass quiet hours and caps but never bypass legal suppression on SMS; they fall back to email or push instead.
One pipeline for triggers and bulk. Event triggers, schedules, campaigns and interest matches all become the same notification intent, so dedup, eligibility, rendering and auditing are written once. Isolation comes from separate Kafka topics and consumer pools per priority, not separate systems:
| Lane | Examples | Target latency | Isolation |
|---|---|---|---|
| transactional | confirmation, 2FA, host message | < 5 s | own topic, reserved provider quota |
| reminder | check-in, review ask | < 1 min | own topic |
| bulk | campaigns, interest | minutes-hours | own topic, token-bucket throttle, preemptible |
Provider quota (for example 3K SMS/s on a short code) is split by token buckets per lane, with transactional allowed to borrow from bulk but never the reverse.
Provider adapters, retries and webhook reconciliation. Each adapter wraps a vendor behind send(message) → provider_msg_id | retryable_error | permanent_error. Retries use exponential backoff with jitter (1 s, 4 s, 16 s, up to 1 h) and a per-message deadline; a booking reminder past check-in is dropped as stale. Circuit breakers per vendor switch SMS to the secondary vendor after 5% errors over 30 s. To avoid duplicates on retry after a timeout, pass our delivery_id as the vendor's idempotency or reference field where supported, and before re-sending a timed-out SMS, query vendor status by reference. Webhooks (delivered, bounced, complaint, STOP) are verified by signature, written raw to Kafka first, then applied: look up provider_msg_id → delivery row, apply status only if it moves forward in the state order (sent < delivered < opened; bounced terminal). Hard bounces and complaints write to suppression. A nightly job flags deliveries stuck in sent for over 24 h with no receipt.
Time-based sends without scanning every trip. Don't run "find trips checking in today" over all bookings. Store resolved fire_at UTC per schedule, bucketed by minute: fire_bucket = floor(fire_at / 60s). A scheduler shard owns a range of buckets; every minute it reads WHERE fire_bucket = now AND state = 'pending' (index scan of ~15K rows/min at 20M timers spread over ~1 year), claims them with a conditional update (state pending → firing, version = v), and emits to intake. The schedule key doubles as the idempotency key, so a scheduler crash that re-fires produces a duplicate intent that intake drops. Time zone comes from the listing, not the guest's device; convert with the tz database at the time of writing and recompute when tz rules change (rare, handled by a re-resolve job). For "send at the user's 09:00" on non-trip messages, store a local wall time and resolve per user.
Effectively-once delivery. Duplicates arise from repeated upstream events, Kafka redelivery, worker crashes after send and before ack, and campaign chunk retries. Defenses by layer: unique idempotency_key at intake; a delivery row per (notification, channel) created before the vendor call with state sending, so a redelivered message sees it and checks status instead of re-sending; vendor-side reference IDs; and campaign keys of campaign:user so a re-run chunk dedups per user. The remaining hole (vendor accepted, we crashed before recording, vendor has no idempotency) is narrow and accepted for push; for SMS, status lookup closes it.
Trade-offs
- One shared pipeline gives consistent dedup and audit but couples teams to one platform; lanes and quotas keep a bad campaign from hurting confirmations, at the cost of more topics and tuning.
- Checking preferences at enqueue and again at send costs an extra cache read per message but catches opt-outs that arrive mid-flight.
- Minute-bucketed schedules are cheap and scale, but give ±60 s precision; fine for reminders, not for anything needing exact seconds.
- Frozen campaign segments make runs reproducible and auditable, but a user who opts in after the snapshot is missed until the next run.
- Effectively-once relies on vendor idempotency; with push providers that lack it we accept a rare duplicate rather than risk a lost transactional message.
- Deferring for quiet hours improves experience but makes some notices late; transactional types are exempt by template category, which template review must enforce.
Interviewer expectations
Mid-level (L4)
- Separates intake, a queue and channel workers; uses a template service and per-channel senders.
- Stores preferences and checks them before sending; mentions retries.
- Gives a rough volume estimate and a sensible message table.
Senior (L5)
- Designs idempotency end to end (intake key, delivery row before send, campaign keys) and explains each duplicate source.
- Isolates transactional from bulk with lanes and provider quotas; handles vendor failover and webhook reconciliation with forward-only status.
- Builds a scheduler that fires by time bucket, handles time zones from the listing, and reschedules on booking changes.
Staff (L6+)
- Frames the platform as one intent model for events, schedules, campaigns and interest signals, with an auditable "why not sent" trail.
- Covers operational safety: campaign kill switch, segment snapshots and small canary batches before full blast, poison chunk quarantine and replay from the raw event log.
- Discusses template governance (owner, category, review), legal opt-out precedence, and what is out of scope (search ranking for interest matching, vendor contracts).