Most notification services start as a single function:
def notify(user, message):
email_client.send(user.email, message)
It works for a while. Then product asks for push, then SMS, then an in-app inbox, then "can users pick what they get?" Before long, this one function has become a distributed system.
This post breaks down what a production notification service contains, split into the functional parts users and PMs see, and the non-functional parts that keep it alive at 3 a.m. At the end, there's a framework for deciding whether to build or buy.
The big picture
Your app --> Notification API --> Preference check --> Template render
|
Priority queues (critical / high / low)
|
Channel workers (email, SMS, push, inbox...)
|
Vendor adapters --> Webhooks --> Analytics
Each box hides real engineering work. Let's go through them.
Part 1: Functional components
1. Delivery channels and vendor integrations
Users live on different channels: email, SMS, push, in-app inbox, WhatsApp, Slack or Teams, even automated calls. To maximize engagement you'll want several of them.
Every channel means picking a vendor, learning its API, testing quality of service, monitoring it, and planning a fallback. A common way to keep this manageable is an adapter layer:
class ChannelAdapter:
def send(self, payload) -> DeliveryResult: ...
class SendgridAdapter(ChannelAdapter): ...
class TwilioAdapter(ChannelAdapter): ...
class FcmAdapter(ChannelAdapter): ...
Your core logic talks to one interface, and vendor quirks stay at the edges.
2. Templating engine
Templates quietly become the biggest ownership burden. They need constant changes for copy, personalization, branding, dynamic variables, conditional logic, multi-lingual support, and correct rendering across devices.
Each channel also has different strengths:
- Email: long, detailed content
- SMS: short and attention-grabbing
- Push: rich media and action buttons
If every copy change needs a deploy, your engineers become a bottleneck. The fix is a visual editor so PMs and marketers can build and test without a pull request.
3. User preferences
Marketing emails get a simple unsubscribe link. Product notifications are trickier, because users want some of them (security alerts) and not others (weekly tips).
A decent preference system lets users control:
- Type: which categories they get
- Channel: where they get them
- Frequency: instant, hourly, daily or weekly digest
- Timing: quiet hours
def should_send(user, notification):
prefs = get_preferences(user.id)
if notification.category not in prefs.enabled_categories:
return False
if in_quiet_hours(user, prefs) and not notification.is_critical:
return reschedule(notification, prefs.quiet_hours_end)
return True
Without this, users do the only thing they can: block everything.
4. Batching and digests
Say your product has comments. If 20 people reply to a thread, you don't want 20 notifications. You want one: "20 new comments."
- Batching: define a window, collect events inside it, then send one combined message.
- Digests: send a summary at a cadence the user chooses, such as hourly, daily or weekly.
Depending on the notification type, you may need both.
5. Multi-tenancy
If your product serves other businesses and sends notifications to their users, you need tenant-level isolation and customization: their branding, their sending domain, their channels, their preference rules.
Example: a SaaS tool sending invoices on behalf of customers. The end user doesn't know your brand, so the email must carry your customer's logo, content and sender identity, all running on shared infrastructure.
6. Analytics
PMs need delivery, open and click data to improve notifications. But every channel and vendor reports differently, and some metrics don't exist (you can't track opens on SMS).
That means you need a normalized event model, so cross-channel analysis is possible:
{
"event": "delivered",
"channel": "email",
"notification_id": "n_123",
"user_id": "u_456",
"timestamp": "2026-10-14T10:32:00Z"
}
Map vendor webhooks into this shape and you can compare channels apples to apples.
Part 2: Non-functional components
This is the stuff nobody sees until it breaks.
1. Scalability and load balancing
Notification traffic spikes unexpectedly: a flash sale, a viral feature, an incident. Design for bursts, and balance load across servers and regions.
2. Fault tolerance and retries
Downtime isn't acceptable for OTPs or compliance messages. You need redundancy, state management, fallback vendors, and rate-limit-aware throttling per channel.
One subtle point: not every failure deserves a retry. An expired push token is dead, and retrying it just wastes resources.
RETRYABLE = {"timeout", "rate_limited", "vendor_5xx"}
PERMANENT = {"invalid_token", "unsubscribed", "bounced"}
def handle_failure(msg, reason):
if reason in RETRYABLE:
retry_with_backoff(msg)
elif reason in PERMANENT:
mark_channel_inactive(msg.user_id, msg.channel)
Understanding why something failed matters as much as retrying it.
3. High deliverability
Reaching the user involves several things at once: multiple channels, reliable vendors, cleaning inactive contacts, and managing bounces.
4. Low latency
People expect notifications fast. Cut network round trips, optimize database queries, and shorten delivery paths. It's ongoing work, because performance degrades as you scale.
5. Observability
You need detailed logging, error tracking and performance monitoring so you can diagnose failures quickly and answer compliance questions. If you can't answer "what happened to this user's notification?", debugging becomes guesswork.
6. Message queue prioritization
This one saves a lot of pain. An OTP and a weekly newsletter shouldn't share a queue. If bulk sends clog the pipeline, login codes get delayed and drop-off rises.
Use separate queues with their own throughput limits:
QUEUES = {
"critical": {"workers": 20, "rate": "unlimited"}, # OTP, security
"high": {"workers": 10, "rate": "1000/s"}, # transactional
"low": {"workers": 3, "rate": "200/s"}, # promos, digests
}
This protects critical messages without over-provisioning for everything.
To build or not to build?
Here's a quick framework.
Build in-house when:
- Your needs are simple. Few notification types, low volume, rare changes, a single channel. Direct vendor integration or a lightweight internal service will do.
- You need heavy customization. Your use case is unusual enough that third-party platforms can't support it (like a chatbot over SMS).
- Notifications are your product. If you're something like WhatsApp, owning it makes sense.
Consider alternatives when:
- Resources are limited. Building and maintaining this takes real engineering capacity.
- Time to market matters. A third-party service can get you live far faster.
- You need complex features. Workflows, white-labeling, preferences, personalization, batching and cross-channel logic come ready-made on established platforms.
- You want to focus on your core product. Getting notifications right takes experimentation, channel expertise, scaling work and observability, which pulls attention from what makes your product special.
This is the layer we built SuprSend to handle, so teams can skip the plumbing. Full disclosure: we work on SuprSend, so weigh that accordingly. The checklist above holds whichever way you go.
Wrapping up
A good notification service balances two sides. The functional side gives product teams control over templates, preferences and analytics. The non-functional side gives everyone reliability, speed and visibility. Skipping either one hurts.
Whichever path you choose, the goal is the same: a smooth, meaningful experience that keeps users connected.
Over to you: did you build your notification system in-house or use a platform? What's the one component you wish you'd planned for earlier? Tell us in the comments.
Top comments (0)