Skip to content
Tech Stack20 June 2026 · 7 min readUpdated 11 October 2026

SaaS Notification System Architecture: In-App + Email + Push

How to architect a SaaS notification system across in-app, email, and push: queue-based fan-out, preferences and suppression, digests, and when to buy Knock, Courier, or Novu.

SaaS Notification System Architecture: In-App + Email + Push

What you'll learn

  • Why build the in-app inbox before email and push?
  • How do you route the same event across email, push, and in-app?
  • Should you build notifications or buy Knock, Courier, or Novu?
Article contents

If your B2B SaaS product sends notifications with a sendEmail() call inside a route handler, this page is about the moment that stops working and what replaces it. Your first action: open the handler, find every provider call that runs before the HTTP response returns, and write down how many there are.

A SaaS notification system looks simple until it isn't. The real architecture problem has four parts: how events trigger notifications, how you route them across channels, how you respect user preferences and suppression rules, and what you deliver into each channel's inbox, feed, or push payload. Get one wrong and users report bugs that aren't bugs, just invisible, undelivered, or duplicated messages. The numbers and thresholds below come from vendor documentation I checked on 30 September 2026. Where I give a rule of thumb of my own, I say so.

Why build the in-app inbox before email and push?

A concrete wall with three letterbox slots, the central one open and glowing cyan from within, the two flanking slots dark and closed

Build the in-app inbox first because it's the one notification surface you fully own. Push, email, and SMS all pass through permissions, filters, or carriers that you don't control.

Push needs user consent. On Android, notifications are off by default for newly installed apps on Android 13 and higher until the user grants the POST_NOTIFICATIONS runtime permission, so you can't assume push reaches anyone on day one. Email lands in whatever tab or spam folder the recipient's provider chooses. The in-app feed lives inside your product, and every user who opens the app can see it.

That ownership changes priorities. Start with a simple table (notifications with userId, type, content, createdAt, readAt) that your frontend reads on page load or polls on an interval. That beats launching with email and treating in-app as an afterthought. My rule of thumb, not a benchmark: polling is fine while notifications are occasional and nobody is waiting on one in real time.

When polling starts to feel slow, for example live comment threads or several people editing the same thing, add a real-time transport. Options include a hosted service such as Ably or Pusher, or Socket.io on your own servers. Pusher's pricing page lists a free Sandbox plan with 200k messages per day and 100 concurrent connections. Keep in mind what these services are: they move messages. They don't track read state, batch digests, or look up user preferences. That logic stays in your code or in a notification platform.

How do you route the same event across email, push, and in-app?

A concrete surface with a Y-junction channel carved into it, filled with cyan water splitting into three diverging paths under dramatic directional light

Fan-out routing takes one application event and delivers it to several channels based on per-user preferences and category rules. Do it asynchronously, through a queue, rather than inside the request that caused the event.

The naive version calls every provider from the route handler. Consider a two-person team whose email provider starts answering in seconds instead of milliseconds: every API request that triggers a notification waits on it. That's a hypothetical, but it's the failure mode synchronous fan-out invites, and it gets worse as traffic grows. The safer pattern has four steps:

  1. The application publishes an event such as notification.created with a userId, an event type, and a payload.
  2. A worker consumes the event, loads the user's preferences, and decides which channels apply.
  3. The worker hands the message to one job per channel: email, push, in-app, and optionally webhook.
  4. Each channel job retries with backoff and moves to a dead-letter queue when retries run out.

Durable-execution tools exist for step 4. Inngest's documentation says a step is retried if it throws an error, and that completed steps aren't re-run, so one failing channel doesn't resend the others. On Redis with BullMQ you can build the same thing, but you wire idempotency keys, backoff, and dead-letter handling yourself.

High-subscriber events add a second question. A comment in a channel with 100,000 members implies 100,000 inbox rows if you write one per recipient at event time. The alternative is to store the event once and work out who should see it when they open their feed. A common approach is a hybrid, writing per-recipient rows for small audiences and computing on read for large ones. Where exactly you switch depends on your database and traffic, so measure instead of copying a number from a blog post.

Channel choice is a product decision, and this is my recommendation rather than a standard. Use in-app for anything that makes sense while the user is already working. Use push for time-sensitive events that need action outside the product, such as a payment that needs extra authentication or a reminder shortly before an appointment. Use email for receipts, summaries, and anything the user may want to keep as a record. If a message needs immediate action and the user may not be in the app, send push and in-app together. Store which channels actually fired for each event, so support can answer "did I get notified?" from data instead of guessing.

Webhooks are worth designing in as a fourth channel early. Enterprise customers often want your events in their own Slack, ticketing, or internal tools, and a webhook job in the queue behaves like any other channel worker.

Should you build notifications or buy Knock, Courier, or Novu?

A concrete surface split down the center, hand tools on the left in deep shadow, a sealed product box with a single cyan-lit edge on the right

Build when your requirements are simple and stable, and buy when routing, preferences, and a hosted feed cost more engineering time than the product features your customers pay for.

For an early MVP, call provider APIs directly: Resend or Postmark for email (the Next.js and Resend integration is the shortest path on that stack), FCM and APNs for push, and a notifications table for in-app. No orchestration layer is needed yet. The decision gets real once you add per-user channel preferences or digests.

OptionFits whenPricing shown on its site (checked 30 Sep 2026)
Build: Resend plus a notifications tableEmail and in-app only, small teamProvider usage costs plus your time
KnockEngineers own notification workflowsFree tier 10K messages per month; Starter $250 per month for 50K
CourierNon-engineers edit templates10,000 sends per month free, then $0.005 per send
NovuYou want to self-hostSelf-hosting is documented; the docs list multiple services plus Redis and MongoDB

Prices change, so check the pricing pages before you commit, and model your expected monthly volume first. The jump from a free tier to the first paid plan can be large. Courier's drag-and-drop template builder matters only if a growth or design team will really edit notification copy. If only engineers touch notifications, that surface is overhead. Novu removes per-message fees, but the self-hosting docs recommend a multi-service deployment, and you take on uptime, upgrades, and database operations. Some cloud-only features also aren't available when you self-host.

I haven't benchmarked these vendors against each other, so treat this table as a map of where each one fits, not a ranking.

How do you handle notification preferences, suppression, and digests?

Store preferences as a map of category to channel and read it on every fan-out, with a mandatory flag for categories that must bypass preferences. Teams usually defer this until the first "too many emails" complaint, by which point the data shape is already set.

The minimum model has two dimensions: category (comments, billing, security) and channel (email, push, in-app). A user can switch off push for comments and keep email for billing. The exact schema matters less than one consistent lookup path that your router calls every time.

Suppression is the rule people forget. Some messages must ignore preferences entirely: password resets, payment failures, security alerts. A user who opted out of all email still needs to hear that their payment method failed. Add the mandatory flag to categories from day one.

Digests are the most underbuilt preference. Instead of 15 separate push messages in an hour, you send one saying there are 15 new comments. Building it needs a time window, deduplication, and a template that knows the count. On BullMQ that's a delayed job per user and category that collects events until the window closes, then sends once. Notification platforms offer this as a built-in workflow step, so check the current docs for the vendor you pick.

Test the whole path with a real account before shipping: change a preference, trigger an event, and confirm the router respects it in every channel. Then repeat with a mandatory category to confirm it still gets through. Those two checks catch most preference bugs early.

One hard limit to design for: Firebase documents that the maximum payload for FCM notification and data messages is 4096 bytes, and 1000 characters when sending from the Firebase console. Your push worker has to truncate or summarize long content. I haven't verified Apple's APNs payload limit on a primary page, so look it up in Apple's documentation before you write that part.

What does your handler do today? Count the provider calls that run before the response returns. If any exist and you have real traffic, move the first one behind a queue this week, and read the SaaS MVP stack guide for the surrounding infrastructure.

Part of the SaaS Backend Infrastructure: Jobs, Webhooks, Email, and Admin Tooling guide.

Free resource

Free SaaS MVP Scope Template

A Notion document with the full feature checklist, MVP vs. nice-to-have table, pre-build questions, and cost signals — so you walk into any developer call knowing exactly what to ask for.

Get the template →
DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps using AI-augmented workflows — without agency coordination overhead. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

What is the simplest in-app notification architecture for a small SaaS product?

The simplest architecture is a database table with userId, type, content, createdAt, and readAt columns that the frontend reads on page load or polls on an interval. It needs no queue, no pub/sub, and no third-party service. Add a real-time layer such as Ably, Pusher, or Socket.io once polling delay becomes noticeable on time-sensitive events, and add an orchestration platform such as Knock or Novu only when you need cross-channel delivery or per-user preference management. Measure before you add each layer.

How does Knock compare to Courier for a developer-owned notification system?

Knock and Courier suit different team structures more than different technical needs. Knock is aimed at engineering ownership, with workflows defined by developers, while Courier emphasizes a drag-and-drop template builder so design or growth teams can edit copy without a deployment. If only engineers touch notifications, Courier's no-code layer is extra surface area, and if non-engineers edit copy regularly it is useful. Pricing differs too, so compare the current pricing pages against your expected monthly volume before you commit.

How should per-user notification preferences be stored in a SaaS database?

Store preferences as a map of category to channel settings on the user profile, for example comments with email, push, and in-app flags. The exact schema matters less than one consistent lookup path that your router reads on every fan-out event. Add a mandatory flag at the category level from day one so password resets, payment failures, and security alerts bypass user preferences. A JSON column in PostgreSQL or a field or subcollection in Firestore handles per-user lookups fine, so avoid a separate normalized table unless you must query across users by preference.

When should a notification go to email, push, or in-app?

Route by urgency and whether the user is in the product. In-app suits anything relevant to what the user is doing right now. Push suits time-sensitive events that need action outside the product, and email suits receipts, summaries, and anything worth keeping as a record. If a message needs immediate action and the user may be away, send push and in-app together. This is a recommendation, not a standard, so log which channels fired for each event to see what your users actually receive.

What is fan-out in a notification system?

Fan-out means taking one event and delivering it to multiple recipients or channels. A comment on a shared document fans out across users, and a billing alert fans out across email, push, and in-app. For recipients, you can write a row per recipient at event time, which suits small audiences, or store the event once and compute each user's view on read, which suits large audiences. For channels, publish an event to a queue and let independent workers retry each channel, so one slow provider does not block your API.