Skip to content

Designing a Notification Service

Templates, async queues, bulk fan out, priority lanes, and deduplication with a tracker store.

Builds on Message Queues and Streams and Idempotency and Retries

Stage 5advanced13 min read

The whiteboard for this problem is in excalidraw/3. Designing a Notification Service.excalidraw. This file is the full write up of that board.

1. Problem Statement

Design a notification service that sends notifications to users across channels. The system has to be horizontally scalable and support very high fan out, meaning one trigger can turn into millions of individual sends.

Internal teams, such as a product manager or another backend service, should be able to define a message once and then send it to one user or to everyone.

2. Requirements

2.1 Functional Requirements

RequirementDetail
Create templatesAn internal team defines a template with variables, through a UI
Multi channelEmail, SMS, Android push, Apple push, in app
Send to one userTrigger a template for a single recipient
Send to many users"Notify everyone", or everyone matching a filter
PriorityAn appointment reminder must not wait behind a marketing campaign
No duplicatesA user must not receive the same campaign twice
RetriesA transient provider failure must be retried, not dropped
Respect preferencesA user who opted out of marketing gets no marketing

A template looks like this:

Hello {{user.name}}, order from XYZ and get {{discount}} off!

2.2 Non Functional Requirements

  1. Horizontally scalable. Adding capacity means adding queues and workers, not rewriting anything.
  2. High fan out. One request such as "notify everyone" must expand to millions of sends without the caller waiting.
  3. Load isolation. A large marketing campaign must not starve transactional notifications.
  4. Reliable. A message accepted by the system is eventually delivered or ends in a dead letter queue where someone can see it.
  5. Provider outage tolerant. A provider being down must not lose messages.

2.3 Out of Scope

Building the email and SMS infrastructure itself. We call providers such as SES, Mailgun, OneSignal, Twilio and msg91.

3. Start Simple: Templates

We need a UI and a small backend so an internal team can create templates. The number of templates is small, a few thousand at most, and each one has clear structure with relationships to channels and variables. That fits on a single node, so we start with a relational database.

Loading diagram…

The flow for a team that wants to send something:

  1. Create a template in the control service.
  2. Declare the variables the template needs.
  3. Note the template id, and pass it when triggering a send.

4. Channels and Providers

A user can be reached over several channels:

  1. Email
  2. Android push
  3. Apple push
  4. SMS

Each channel has providers that expose an API, and we call those APIs to actually deliver. The servers that trigger real sends have to configure each provider SDK.

The important observation: these are expensive network calls with high latency. One machine cannot make millions of concurrent outbound calls. That single fact drives the whole rest of the design.

5. Evolving the Architecture

5.1 Synchronous, One User

Loading diagram…

The control service renders the template and calls the provider directly. This works for one user and a manual trigger, and nothing more.

5.2 What Breaks

If a team wants to trigger thousands of notifications at once:

  • Triggering one notification per user is painful for the caller.
  • The control service becomes the bottleneck, because each provider call takes real time.
  • A provider outage blocks the caller and loses messages.

This is the classic place to make things asynchronous.

5.3 Asynchronous with a Queue

Loading diagram…

Each queue message carries what one send needs:

{ user_id: u1, notification_id: n1, channel: email, body: rendered }

Workers stay dumb on purpose. A worker picks a message, renders if needed, calls the provider, acknowledges. Nothing else. That makes them trivially scalable and safe to kill.

This architecture gives us retries and stops the control service from being overwhelmed.

5.4 Bulk Notifications

The queue works well for moderate traffic and one to one triggers. The typical hard case is "notify everyone".

The experience we want: the caller submits one job and the system handles the rest. Someone has to walk the user table and emit one message per user, and that someone must not be the control service.

Loading diagram…

Two queues with two different jobs:

  • SQS 1, the emitter queue. One message equals one notification to one user. Single sends go straight here.
  • SQS 2, the job queue. Anything that needs processing first. Bulk sends go here.

The iterator reads from a read replica of the user table, applies the filter criteria, and pushes one emitter message per matching user. It reads the replica so a campaign scan never competes with production writes.

5.5 Priority Lanes

Some notifications matter more than others. An appointment reminder is far more important than a marketing push.

With one emitter queue, a single marketing campaign of ten million messages keeps every worker busy and everything else starves behind it.

Fix: replace the single emitter queue with several priority queues, each with its own worker pool.

Loading diagram…

A message is now (notification_id, user_id, priority), for example (n1, u1, p1).

What this buys us:

  • Horizontal scalability, add queues and add workers.
  • Load isolation, a P3 marketing flood cannot starve P1 traffic.

Typical mapping: P1 for OTP, security alerts and appointment reminders, P2 for transactional updates such as order shipped, P3 for marketing.

5.6 Deduplication

Priority lanes do not solve duplicates. Receiving a marketing notification is already annoying, receiving the same campaign three times is worse.

So we track what was already sent, in a key value store.

key:   <campaign_id>:<user_id>
value: sent_at

Sizing it:

100M users x (4 B user id + 4 B campaign id) = 800 MB
5 concurrent campaigns  ~ 4 GB

5.7 Where to Check, and the Optimization

The naive version checks in the worker. That means the iterator pushes every user into a priority queue, workers pull each message, and only then discover the notification was already sent. We paid for the enqueue, the storage and the dequeue to learn nothing.

Better: the iterator consults the tracker before enqueueing. Already sent users never enter the queue at all.

Loading diagram…

The tracker needs to be:

  1. Lightweight and shardable.
  2. In memory with periodic persistence.
  3. Able to support a Bloom filter, which is space efficient.

Redis satisfies all three, so the tracker is Redis.

A Bloom filter answers "have we sent this already" in constant time and tiny space. It can say yes when the answer is actually no, which here means we skip a send that we could have made. For marketing that is acceptable. For a P1 OTP we skip the filter and use an exact key, because a missed OTP is a real failure.

The worker still writes the tracker entry after a successful send, so the filter stays truthful for the rest of the campaign.

6. API Design

6.1 Template Management

MethodPathPurpose
POST/api/templatesCreate a template
GET/api/templates/:idRead one template
PATCH/api/templates/:idEdit body or variables
POST/api/templates/:id/versionsPublish a new version
GET/api/templatesList and search templates
{
  "name": "diwali_discount",
  "channel": "EMAIL",
  "subject": "Your Diwali offer",
  "body": "Hello {{user.name}}, order from XYZ and get {{discount}} off!",
  "variables": ["user.name", "discount"],
  "category": "MARKETING",
  "defaultPriority": "P3"
}

6.2 Sending

MethodPathPurpose
POST/api/notifications/sendSend to one user
POST/api/notifications/bulkSubmit a bulk job
GET/api/notifications/:idDelivery status of one send
GET/api/jobs/:idProgress of a bulk job
POST/api/jobs/:id/cancelStop a running campaign

Single send:

{
  "templateId": "t_881",
  "userId": "u_1729",
  "channel": "SMS",
  "priority": "P1",
  "variables": { "discount": "20 percent" },
  "idempotencyKey": "order-5512-shipped"
}

The idempotencyKey makes a retry by the caller safe. Two calls with the same key produce one notification.

Bulk send:

{
  "templateId": "t_881",
  "campaignId": "c_diwali_2026",
  "priority": "P3",
  "filter": { "country": "IN", "lastActiveWithinDays": 30, "plan": "free" },
  "channels": ["EMAIL", "PUSH"],
  "dedupe": true
}

Response is a job id and 202 Accepted. The caller does not wait for millions of sends.

{ "jobId": "j_4410", "status": "QUEUED", "estimatedRecipients": 8400000 }

Job progress:

{
  "jobId": "j_4410",
  "status": "RUNNING",
  "scanned": 3100000,
  "enqueued": 2870000,
  "skippedDuplicate": 230000,
  "sent": 2650000,
  "failed": 1200
}

7. Data Model

The meta database is relational because templates, channels, providers and preferences all have real relationships and low volume. Delivery records are high volume and append only, so they go in a wide column or document store.

7.1 Tables

templates

ColumnTypeNotes
iduuidPrimary key
nametextUnique, human key used by callers
channelenumEMAIL, SMS, PUSH_ANDROID, PUSH_IOS, IN_APP
categoryenumTRANSACTIONAL, MARKETING, SECURITY
default_priorityenumP1, P2, P3
created_byuuidForeign key to internal_users.id
created_attimestamptz

template_versions

ColumnTypeNotes
iduuidPrimary key
template_iduuidForeign key to templates.id
versionintegerIncrements per template
subjecttextEmail only
bodytextWith {{variable}} placeholders
variablesjsonbDeclared variable names
is_activebooleanOne active version per template

Versions exist so an in flight campaign keeps rendering the body it started with, even if someone edits the template mid run.

channel_providers

ColumnTypeNotes
iduuidPrimary key
channelenumWhich channel this provider serves
providertextSES, MAILGUN, TWILIO, MSG91, FCM, APNS
weightintegerShare of traffic, for failover and cost split
rate_limit_per_secintegerWhat the provider contract allows
is_enabledbooleanKill switch

user_notification_preferences

ColumnTypeNotes
user_iduuidPrimary key part, foreign key to users.id
channelenumPrimary key part
categoryenumPrimary key part
opted_inboolean
quiet_hours_starttimeLocal time, no marketing before this
quiet_hours_endtime
timezonetext

Composite key of (user_id, channel, category) so a user can accept transactional SMS while refusing marketing SMS.

notification_jobs

ColumnTypeNotes
iduuidPrimary key
template_iduuidForeign key to templates.id
campaign_idtextUsed as the dedupe namespace
priorityenum
filter_criteriajsonbWhat the iterator applies
statusenumQUEUED, RUNNING, PAUSED, DONE, FAILED
scanned_countbigintIterator progress
enqueued_countbigint
cursortextLast user id scanned, for resume
created_attimestamptz

The cursor is what makes a bulk job resumable. An iterator that dies at user three million restarts from three million, not from zero.

notification_deliveries (Cassandra or DynamoDB, not relational)

ColumnTypeNotes
user_iduuidPartition key
sent_attimestampClustering key, newest first
notification_iduuid
job_iduuidNull for single sends
template_iduuid
channelenum
providertextWhich provider handled it
statusenumQUEUED, SENT, DELIVERED, OPENED, FAILED, BOUNCED
attempt_countinteger
error_codetextProvider error on failure

This table is the high volume one, tens of millions of rows a day. It is partitioned by user_id because the query we actually run is "show me this user's recent notifications".

7.2 Relationships

Loading diagram…

In words:

  • One template has many versions, one version belongs to one template.
  • One template is used by many jobs, one job uses one template.
  • One job produces many deliveries, one delivery belongs to at most one job.
  • One user receives many deliveries and sets many preference rows, one per channel and category pair.
  • One provider handles many deliveries.

7.3 The Tracker Store

Not a table. Redis, per campaign:

Exact form:  SETNX  dedupe:{campaign_id}:{user_id}  1   with TTL of campaign length
Bloom form:  BF.ADD dedupe:{campaign_id}  {user_id}

Sharded by campaign id so one huge campaign spreads across nodes, and the whole namespace can be dropped when the campaign ends.

8. Key Flows

8.1 Single Transactional Send

Loading diagram…

8.2 Bulk Campaign

Loading diagram…

9. Deep Dives

9.1 Why Two Queue Types

The job queue and the emitter queue carry different units of work. A job message is one row and expands into millions. An emitter message is one send. Mixing them in one queue means a single fat message blocks a worker for hours while every other message waits.

9.2 Provider Rate Limits

Every provider caps throughput. Workers hold a shared token bucket per provider, in Redis, and block on it before calling out. Without this we get throttled by the provider, and provider throttling usually means dropped messages rather than a clean retry.

9.3 Retries and Dead Letters

Failures split into two kinds:

KindExamplesAction
TransientProvider 5xx, timeout, throttleRetry with exponential backoff and jitter, up to five attempts
PermanentInvalid number, hard bounce, unsubscribed device tokenDo not retry, mark FAILED, disable the destination

Retries add jitter so a provider recovering from an outage does not get hit by every worker at the same instant. After the final attempt, the message goes to a dead letter queue where an on call engineer can inspect and replay it.

9.4 Idempotency

Queues deliver at least once, so a worker will occasionally see the same message twice. Two guards:

  1. The tracker check, which is already there for campaigns.
  2. A per notification key from idempotencyKey, checked with SETNX before the provider call.

Both are cheap Redis operations, and both are far cheaper than a duplicate SMS.

9.5 Quiet Hours and Preferences

Checks happen at two points. The iterator filters coarse criteria such as country and plan, because that reduces enqueue volume. The worker checks quiet hours right before sending, because a message may sit in the queue long enough for local time to change. A marketing message inside quiet hours is rescheduled rather than dropped.

10. Failure Handling

FailureEffectHandling
Provider outageSends on that channel failRetry with backoff, then shift weight to a backup provider
Worker crash mid sendMessage not acknowledgedQueue redelivers, idempotency key prevents a double send
Iterator crashBulk scan stopsResume from cursor on the job row
Tracker Redis downCannot dedupeFor P1 keep sending, correctness of delivery wins. For P3 pause the campaign, a duplicate marketing blast is worse than a delay
Meta DB downNo new templates or triggersIn flight queues keep draining, workers cache the active template version
Queue backed upDelivery delay growsAutoscale workers on queue depth, and P1 has its own pool so it stays fast
Bad template pushedBroken messages at scaleVersioning plus a canary send to an internal list before a full campaign

11. Tradeoffs

DecisionChosenAlternativeReason
Send pathAsynchronous queueSynchronous provider callProvider calls are slow, one machine cannot fan out
Queue layoutSeparate job and emitter queuesOne queueA bulk job would block emitter workers
PriorityQueue per priority with its own workersSingle queue with a priority fieldReal isolation, no starvation
Dedupe locationAt the iteratorAt the workerAvoids enqueueing and dequeueing work we then throw away
Dedupe structureBloom filter for marketing, exact key for P1Exact keys everywhereSpace efficient where a false positive is acceptable
Tracker storeRedisRelational tableNeeds speed, sharding, TTL and Bloom support
Template storeRelationalDocument storeSmall volume, real relationships, versioning
Delivery logWide column storeRelationalVery high write volume, queried only by user and time
User scanRead replicaPrimaryCampaign scans must not touch production write load

12. Interview Talking Points

  • Start synchronous, then show exactly what breaks. The argument for queues is more convincing when the failure is named.
  • The line "these are expensive network calls with high latency, one machine cannot make millions of them" is the sentence that motivates the whole design.
  • Explain the two queue types and why one queue is not enough.
  • Priority lanes need their own worker pools. A priority field on a single queue does not prevent starvation.
  • The dedupe placement optimization, checking at the iterator rather than the worker, is the detail that shows you are thinking about cost and not just correctness.
  • Say the Bloom filter tradeoff out loud: a false positive means one skipped marketing message, which is fine, and that is why P1 does not use it.

In this topic

Whiteboard diagram

Open full screen
Loading diagram… this one is large
Designing a Notification Service

Done with this topic?

How well do you know it?

PreviousDesign a Rate Limiter