Notifications are where SaaS products quietly lose users. Not dramatically, not in a way that shows up in your churn interview – just slowly, as people stop trusting that the thing will actually tell them what matters. They miss a payment failure. They don’t see the approval request. They get seventeen emails about something irrelevant and start filtering your domain straight to trash. By the time you notice, the damage is done and you’re staring at a notification system that has grown like mould across your codebase.
We’ve inherited enough of these systems to know the pattern. Someone built email notifications in sprint two, added in-app alerts six months later, bolted on SMS for the enterprise tier, and now there are four different places where notification logic lives, no single source of truth for user preferences, and a support queue full of “I never got the email” tickets. The product works. The notifications don’t. And untangling it takes longer than building it right would have.
Here’s what a sensible notification architecture actually looks like – built once, extended cleanly, and trusted by users.
Start With the Notification Domain, Not the Delivery Channel
The most common mistake is structuring your notification system around channels – email service here, push service there, SMS module somewhere else. This feels logical until you need to change the message, add a channel, or let users control their preferences. Then you’re hunting through three different parts of the codebase to change one sentence.
The better model is to treat a notification as a domain event that gets routed to channels. You have a NotificationEvent – something like invoice.payment_failed or task.assigned – and a separate routing layer that decides what to do with it. The event carries the data. The router decides who gets it, through what channel, and whether they’ve opted out.
In practice this means:
- A central event bus (we typically use something like BullMQ on Node or Celery on Python for this, depending on the stack)
- A notification service that subscribes to events and owns the routing logic
- Channel adapters – email, in-app, SMS, Slack webhook – that receive a standardised payload and handle delivery
- A user preference store that the routing layer queries before dispatching anything
This structure means adding a new channel is a new adapter, not a surgery on existing logic. And when a user says “I only want critical alerts by SMS,” that preference lives in one place and applies everywhere.
User Preference Architecture Is Not Optional
Most early-stage SaaS products treat notification preferences as a single boolean – “email notifications on or off.” That’s fine for an MVP. It stops being fine around the time your power users start complaining and your enterprise prospects start asking about granular controls in their security questionnaire.
A preference model that actually scales has three layers:
- Notification type – what kind of event is this? Payment failures, task assignments, system alerts, marketing updates. These should be enumerated and categorised, not freeform.
- Channel preference per type – a user might want payment failures by email and SMS, task assignments only in-app, and marketing updates never.
- Frequency and digest settings – can individual alerts be batched into a daily digest? Real-time or delayed?
The database schema for this is straightforward: a notification_preferences table with user_id, notification_type, channel, and enabled. You query it in your routing layer before every dispatch. The UI surface to manage it doesn’t need to be complex – a simple preferences page with sensible defaults covers most cases.
One thing worth deciding early: what’s the default state? Opt-in or opt-out for each notification type? Get this wrong and you either spam users or leave them unaware of critical product events. For transactional notifications – payment failures, security alerts, things that affect the user’s account – default on and make them non-optional. For everything else, give users a clean choice.
In-App Notifications Are a Product Feature, Not a Badge Count
The in-app notification bell with a red badge is table stakes. What’s actually useful – and what distinguishes products users trust – is a notification centre that shows context, status, and history.
When we build in-app notification systems, the data model we reach for consistently is:
id,user_id,notification_typetitle,body,action_url– the content the UI rendersread_at– nullable timestamp, null means unreaddismissed_at– separate from read; users sometimes want to clear without actingmetadata– a JSONB column for type-specific context (invoice ID, task reference, whatever links back to the relevant entity)
Real-time delivery is where teams often over-engineer early. WebSockets are powerful but they add operational complexity – you need sticky sessions or a pub/sub layer (Redis works here) to broadcast across multiple server instances. For most products under a few thousand concurrent users, Server-Sent Events are simpler, one-directional, and sufficient. You can always migrate later. Start with what you can actually maintain.
Polling is not dead either. A 30-second poll on the notification endpoint is genuinely fine for many products. Don’t let architectural purism push you into WebSocket complexity before you need it.
Email Delivery Is an Infrastructure Problem, Not Just an Integration
Plugging in SendGrid or Postmark takes an afternoon. Making email delivery reliable takes more thought. The failure modes are subtle: emails land in spam, bounce rates creep up, your shared IP reputation degrades because someone else on the platform sent bulk marketing through the same SMTP pool as your transactional alerts.
Separate your transactional and marketing email streams from day one. Different sending domains, different IP pools if your volume justifies it. Transactional email – account alerts, payment failures, security notifications – should go through a dedicated domain and subdomain (e.g. notifications.yourproduct.com.au) with tight SPF, DKIM, and DMARC configuration. Your deliverability for the emails that matter depends on keeping that domain clean.
Build retry logic into your notification service with exponential backoff – most email providers have transient failures, and silently dropping a retry is how critical notifications disappear. Log every dispatch attempt: what was sent, to whom, what the provider responded, and when. That log is your first line of defence when a user says they never received something.
Budget-wise: for an early-stage product, Postmark’s transactional tier or SendGrid Essentials covers you comfortably at under $50 AUD/month. Once you’re sending hundreds of thousands of emails monthly, the economics of dedicated IPs and higher tiers start making sense. Don’t buy infrastructure ahead of the problem.
The Digest Pattern Saves Relationships With Power Users
Power users generate a lot of events. If every event fires a notification in real time, those users either turn off notifications entirely or start ignoring them – which means they also miss the ones that matter. The digest pattern is the fix, and it’s worth building sooner than most teams do.
The implementation is simpler than it sounds. Instead of dispatching immediately, your notification service writes to a notification_queue table with a scheduled_for timestamp. A background worker runs on a schedule – hourly, daily, whatever the digest cadence is – queries for pending notifications grouped by user, renders a summary, and dispatches the bundle.
You need to think about two things. First, not every notification can be digested – payment failures and security alerts should fire immediately regardless of digest settings. Flag these as priority: immediate in your notification type config and route them around the queue. Second, digest rendering needs to be smart enough to summarise gracefully. “You have 47 new task assignments” is not useful. Group by type, surface the most recent and most relevant, and link to the full list in-app.
A fintech we shipped last year had a workflow product where users could be in dozens of active deals simultaneously. Without digests, their inbox was a wall of notifications by 9am. We built a configurable digest system – users set their cadence, immediate alerts still fired – and support tickets about notification fatigue dropped off almost completely within the first month of production.
Observability and the Support Ticket You’re Trying to Avoid
The moment a user says “I never got the notification,” you need to be able to answer the question in under two minutes. That means your notification service needs structured logging that answers: was the event triggered? Was it routed? Was it dispatched? What did the delivery provider say?
A simple notification_logs table – event type, user, channel, status, provider response, timestamp – gives you a searchable audit trail without building a full observability platform. For a production SaaS doing a few thousand notifications a day, this table and a basic admin query interface is enough to handle 95% of support enquiries. You can add Datadog or your preferred log aggregator later when volume justifies it.
Set up alerts on your own notification failure rate. If your email bounce rate climbs above 2-3%, your deliverability is about to degrade and you want to know before your users start complaining. If your in-app delivery latency spikes, something is wrong with your queue worker. These are cheap alerts to set up and expensive problems to ignore.
If you’re about to start a build and you want someone to think through this architecture with you before you’re locked in, talk to Amora about your build – it’s a much easier conversation before the first notification goes out than after the first support ticket lands.
Notification systems don’t fail loudly. They fail quietly, eroding trust until users stop relying on the product to keep them informed. Getting this right isn’t a feature – it’s the infrastructure that makes every other feature feel reliable.
Got something you want built?
Amora Digital is an Australian software and AI agency. We scope it, build it, and ship it – live in 28 days. No offshore teams. No surprises.