Back to BlogArchitecture & Engineering

Building a SaaS Notification System: In-App and Email

Rupak Amin

Founder & Lead Engineer, RAITHub

7 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

A SaaS notification system turns events into messages a user sees, across in-app and email, from one source of truth. Model each event once, fan it out only to channels a user opted into, store in-app notifications so read state follows the user across devices, batch noisy events into digests, and deliver email through a queue safe to retry.

If you would rather have notifications built into your product for you, see how RAITHub would build this below.

What does a SaaS notification system actually have to do?

It has to take something that happened ("Maria commented on your task") and get it to the right person, on the channels they want, without sending it twice, losing it, or burying them in noise. Teams usually underbuild this: a direct email call inside a request handler, with no record and no preferences. It works in the demo and fails the moment a customer says "stop emailing me every time".

Separate the three jobs so each stays simple: record the event, decide the channels, and deliver on each channel. The record is the source of truth; the delivery is disposable.

LayerJobWhat breaks if you skip it
EventRecord what happened, once, for the affected usersNo history, no in-app list, no way to retry or audit
PreferencesDecide which channels each user wants per event typeUsers get spammed and mark you as junk
DeliveryRender and send on each channel, with retriesA mail provider blip loses the notification for good

What should the notification data model look like?

One notification row per recipient per event, plus a per-user preference table and a per-channel delivery log. The notification row powers the in-app bell; the delivery log powers retries and support.

-- One row per recipient per event. Powers the in-app list and read state.
CREATE TABLE notifications (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     uuid NOT NULL,
  type        text NOT NULL,            -- 'comment.created', 'invoice.paid'
  payload     jsonb NOT NULL,           -- the data the template needs
  read_at     timestamptz,             -- NULL means unread
  created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON notifications (user_id, created_at DESC) WHERE read_at IS NULL;

-- Per user, per type, which channels are on.
CREATE TABLE notification_prefs (
  user_id     uuid NOT NULL,
  type        text NOT NULL,
  in_app      boolean NOT NULL DEFAULT true,
  email       boolean NOT NULL DEFAULT true,
  PRIMARY KEY (user_id, type)
);

Keep the list of notification types and their default channels in code, so a deploy is the only way to add a type. The partial index on unread rows keeps the bell-count query fast even when a busy account has hundreds of thousands of old notifications.

How do you fan one event out to the right channels?

Write the in-app notification synchronously, then enqueue the other channels. Resolve preferences once, default to the type's defaults when a user has no row, and never send on a channel the user turned off.

type Channel = 'in_app' | 'email'

export async function notify(db: Tx, userId: string, type: string, payload: object) {
  const prefs = await resolvePrefs(db, userId, type) // row, or the type's defaults
  if (prefs.in_app) {
    await db.query(
      'INSERT INTO notifications (user_id, type, payload) VALUES ($1, $2, $3)',
      [userId, type, payload],
    )
  }
  if (prefs.email) {
    // Enqueue, do not send inline: a slow mail API must not block the request.
    await enqueue('send_email', { userId, type, payload })
  }
}

The email worker renders a template and hands the message to your provider. Because it runs from a queue, a provider outage means a retry, not a lost message. The same outbox-and-queue discipline underlies outgoing webhooks; the reasoning is in sending webhooks to your SaaS customers.

How do you stop notifications from becoming spam?

Give users real control and batch the noisy events. The two levers are preferences and digests.

  • Per-type preferences. Let users turn each channel on or off per event type, with sensible defaults. A billing receipt stays on; a "someone liked your post" can default to in-app only.
  • Digests. For high-volume types, collect events and send one summary on a schedule ("12 new comments today") instead of 12 emails. The digest is a scheduled job, covered in a scheduler and recurring jobs for your SaaS.
  • Deduplication. Collapse repeats: three edits to the same document in a minute should be one notification, not three. A short grouping key plus a window does this.
  • A quiet default. New users should not be opted into every email. Opt them into the few that matter, and let them add more.

Should you build this or use a notification provider?

OptionExamplesChoose this whenWatch out for
Notification-infrastructure SaaSHosted multi-channel notification platformsYou want in-app, email and push with a prebuilt inbox and preference centre fastYour notification data lives in their system; check pricing per message and lock-in
Mail provider onlyA transactional email API, no in-app layerYou only need email and have no in-app bell yetNo history, no read state, no single preference centre across channels
Build it inThe model and dispatch aboveYou want one event model, your own data, and in-app plus email under your controlYou own retries, digests and the preference UI; budget for all three
Hire a team to build itRAITHub or another studioNotifications must tie into onboarding, comments and billing events cleanlyInsist the handover includes the preference centre and the retry behaviour, tested

Do-it-yourself estimate: 4–8 days for the event model, in-app list with read state, a preference centre, email delivery from a queue and basic digests, if you already have a mail provider and a job runner. The main risk is sending email inline from request handlers, which couples your app's uptime to your mail provider's.

How RAITHub would build this

As part of a new SaaS build, or added to an existing product, scoped in writing after the free audit.

  • Event model: a typed set of notification types, each with default channels, recorded once per recipient.
  • In-app inbox: a notifications table with read state, a fast unread count and a mark-all-read action that survives across devices.
  • Email delivery: templated messages sent from a queue, safe to retry, with a delivery log for support.
  • Preferences and digests: a per-type preference centre, deduplication for repeat events and scheduled digests for noisy types.

Timeline: inside a new product, this sits in the 4–6 week fixed-scope SaaS build; added to an existing backend, it fits the 6–12 week backend range alongside other work, with the exact scope in the quote.

You receive: automated tests and CI for dispatch and retries, handover docs and runbooks, and full IP assigned to you under NDA.

Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service, or book the audit.

Frequently asked questions

Should I build in-app notifications and email together?

Yes, from one event model. If you build email alone and bolt in-app on later, you end up with two sources of truth and inconsistent preferences. Modelling the event once and treating channels as outputs keeps them in step.

Why not just send email directly from my request handler?

Because your app's uptime then depends on your mail provider's, and a transient failure loses the message. Record the notification in your database, then deliver email from a queue so a failed send is retried, not lost.

How do I stop users getting too many notifications?

Give per-type, per-channel preferences with quiet defaults, batch high-volume events into scheduled digests, and deduplicate repeats within a short window. Control plus batching is what keeps you out of the spam folder.

Where should read state live for in-app notifications?

On the server, as a read timestamp on each notification row. Browser-only read state does not follow the user to another device, so the bell shows unread counts that are already read elsewhere.

How long does it take to build a SaaS notification system?

Around 4 to 8 days for the event model, in-app inbox, a preference centre, queued email delivery and basic digests, assuming a mail provider and job runner exist. Inside a new build it is part of the 4 to 6 week fixed-scope SaaS project.

SaaS notificationsIn-app notificationsTransactional emailNotification preferencesPostgreSQLTypeScript

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.