Tech Stack20 June 2026 · 9 min read

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

How to architect a SaaS notification system across in-app, email, and push: fan-out routing, preference management, digest grouping, and when to buy Knock, Courier, or Novu.

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

If you're building a B2B SaaS product and your notification system is a sendEmail() call inside a route handler, this post is about the exact moment that breaks and what you replace it with.

Notification systems look simple until they're not. The actual architecture problem has four moving 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 of these wrong and users report bugs that aren't bugs — they're just invisible, undelivered, or duplicated notifications.

This post walks the full architecture — from the simplest setup that actually works to the trade-offs at scale. It covers channel fan-out, digest grouping, preference management, and where the build-vs-buy decision on Knock, Courier, and Novu actually lands.

Why Does the In-App Inbox Beat Every Other Notification Channel?

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

The in-app inbox is the one notification surface you fully own — push, email, and SMS all route through someone else's infrastructure.

Mobile push requires the user to have granted permission. iOS enterprise opt-in rates sit around 40–60% depending on the product category; in some B2B tools the real number is much lower because users dismiss the permission modal on first load and never see it again. Email arrives sorted by spam filters, promotions tabs, and category rules you have no control over. SMS costs money per message and is regulated under TCPA in the US. The in-app feed, by contrast, lives inside your product — every user who opens the app sees it, unconditionally.

That ownership changes how you should prioritize. Build the in-app feed first. Even a simple database table (notifications with userId, type, content, readAt) that your frontend polls every 30 seconds is better than leading with email and treating in-app as an afterthought.

The real-time delivery question comes next. A polling approach is fine for low-volume notification load — say, under 50 notifications per user per day. Once you're building activity feeds for comment threads or multi-user collaboration, polling adds latency and unnecessary request pressure. At that point you want WebSockets or a pub/sub layer.

Ably delivers in-app messages with ~65ms latency across 15 global data centers and is the lowest-friction path for adding real-time delivery to an existing Next.js backend. Pusher is cheaper at low volume (free tier: 200,000 messages/day) but locks you to a single data center region post-signup — a constraint you can't change without recreating the account. You can self-host Socket.io on your own infrastructure, though scaling it past a single server requires Redis pub/sub coordination that quickly becomes its own operational problem.

One thing worth noting: real-time delivery services handle message transport, not notification logic. They don't manage read states, digest batching, or user preference lookups. That's a separate concern — either your own service layer or something like Knock.

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 means taking a single application event and delivering it to multiple channels based on per-user preferences and notification category rules.

The naïve implementation is synchronous fan-out directly in your route handler. It works at 100 users. At 10,000 concurrent users it creates request latency whenever any single provider is slow. Wednesday at 11pm, your email provider responds in 4 seconds instead of 200ms — and every API request that triggers a notification sits blocked, waiting. At 100,000 users, that single slow call cascades across your entire API surface.

The correct pattern for growth-stage B2B SaaS is async fan-out through a queue:

  1. Your application publishes an event: notification.created with userId, eventType, and data payload.
  2. A worker consumes the event, resolves per-user preferences, and determines which channels should receive this notification type.
  3. The worker fans out to channel workers: email.worker, push.worker, inapp.worker — each independently retryable.
  4. Each channel worker attempts delivery with exponential backoff and routes failed messages to a dead-letter queue.

This is the architecture Inngest and Trigger.dev are built to handle without you managing queue infrastructure yourself. Inngest's durable functions model handles the retry graph automatically — write the fan-out logic as a TypeScript function and Inngest persists and retries across steps. The equivalent on Redis + BullMQ works but requires you to explicitly wire idempotency keys, DLQ processing, and backoff configuration.

Fan-out at scale adds one more wrinkle: high-subscriber events. A comment in a channel with 100,000 subscribers means 100,000 notification records need to be written. Writing them all synchronously on the event is expensive. According to a systems architecture analysis by Codelit, platforms at scale use a hybrid model: fan-out on write for small subscriber counts (under roughly 1,000), fan-out on read for high-subscriber events — the notification service stores the event once and computes per-user relevance at query time. Same pattern Twitter uses for high-follower accounts.

Webhooks are a fourth channel worth designing into your fan-out layer early. Enterprise customers want to pipe your notifications into their own Slack channels, Jira boards, or internal tooling. A webhook job in the fan-out queue works exactly like any other channel worker: attempt delivery, retry on failure, dead-letter on exhaustion.

Should You Build Your Notification System 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 your own when notification requirements are simple, stable, and unlikely to add per-user channel preferences or digest batching within the next six months.

Buy when multi-channel routing, preference management, and a hosted notification feed consume more engineering time than your actual product features. That threshold arrives sooner than most teams expect.

The honest economics: building Scale-tier notification infrastructure — multi-vendor fallback, per-channel preference management, observability, GDPR/CAN-SPAM compliance, and hosted preference pages — typically requires 6–12 months, a team of 3–5 engineers, and costs exceeding $500,000 in the first year when salaries, infrastructure, and opportunity cost are included. That's not a reason to immediately reach for a vendor. It IS a reason to be honest about what stage you're at.

Let me back up — that figure is for Scale-tier (100k+ users, five-plus channels, compliance infrastructure). Most teams evaluating Knock or Courier are at Growth stage, where the calculation is different.

For MVP (under ~1,000 users): call provider APIs directly. Resend or Postmark for email — the Next.js + Resend integration is the lowest-friction path on a Next.js backend. FCM for Android push, APNs for iOS push. A simple notifications table in your database for in-app. No orchestration layer.

At growth stage (1,000–100,000 users), the build-vs-buy question gets real:

| Scenario | Recommended approach | |---|---| | Engineering team owns everything, TypeScript stack | Knock — 2–4 hour setup, pre-built React feed components, 10k free/month | | Design or growth team edits notification templates | Courier — no-code template builder, 50+ provider integrations | | Self-hosting required, open-source preferred | Novu — MIT-licensed, 20k+ GitHub stars, eliminates per-notification costs | | MVP, email + in-app only | Build: Resend + simple notifications table |

Knock is the vendor worth evaluating first for a developer-native implementation. According to Knock's implementation comparison, basic multi-channel delivery takes 2–4 hours to set up. It ships production-ready React components for the notification feed — real-time updates, read states, and a preference center modal — which removes a real chunk of UI work. The pricing jump from the free tier to the next plan is steep, so model your expected monthly notification volume before committing.

Courier has a drag-and-drop template builder that lets non-engineers modify notification content without a deployment. If your growth team will own notification campaigns, that matters. If only engineers touch notifications, that surface is overhead.

Novu is the open-source option (MIT-licensed, 20k+ GitHub stars). Self-host on your own infrastructure and you eliminate per-notification costs entirely at scale. You're responsible for uptime, database migrations, and version updates — true ownership cuts both ways.

Notification Preferences: The Architecture Decision Teams Always Defer

Per-user notification preferences control which channels receive which notification categories. Most teams bolt this on after the first wave of "I'm getting too many emails" tickets. By then the preference model is already fighting an established data shape.

The minimum viable preference model has two dimensions: category (comment notifications, billing alerts, security events) and channel (email, push, in-app). A user can turn off push for comment notifications while keeping email on for billing. Store this as a flat object in your user profile — the exact schema matters less than having a consistent lookup path your router calls on every fan-out event.

The suppression rule that almost always gets overlooked: some notifications must bypass user preferences entirely. Password reset emails, payment failure alerts, security breach notices. A user who opted out of all email still needs to receive "your payment method failed." Add a mandatory flag on notification categories from day one and make the router skip preference lookup for those categories.

Digest grouping is the preference variant most teams underinvest in. Instead of 15 separate "new comment" push notifications over an hour, a digest batches them: "You have 15 new comments." Building this yourself requires a time window (batch for 15 minutes, then deliver), a deduplication check, and a count-aware template. Knock has digest workflows as a built-in primitive. On BullMQ, it's a delayed job keyed per user per notification category — the job accumulates events until the window expires, then delivers once.

One hard constraint worth knowing: FCM (Android) and APNs (iOS) both limit push payloads to approximately 4 KB. Your push channel worker must truncate or summarize content before delivery. Doesn't show up at MVP scale. A real bug in the field once you're sending content-rich notifications.


What does your current notification architecture look like? If it's still synchronous provider calls inside a route handler and you're past 1,000 active users, the queue migration is the highest-return infrastructure change available. The SaaS MVP stack guide covers the rest of the core infrastructure — auth, database, billing. Notifications are the piece that gets deferred longest and breaks loudest when it does.

DL

Dusko Licanin

Full-Stack Developer · Banja Luka, Bosnia

Full-stack developer shipping SaaS MVPs, web apps, and mobile apps 2× faster than agencies using AI-augmented workflows. Live portfolio: BookBed, Callidus, Pizzeria Bestek.

Frequently Asked Questions

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

The simplest in-app notification architecture is a database table with userId, type, content, readAt, and createdAt columns, queried by the frontend on page load or on a polling interval. No queue, no pub/sub, no third-party service. This works well for products with under 1,000 users and fewer than 50 notifications per user per day. Add a real-time layer (Ably, Pusher, or Socket.io) once polling latency becomes noticeable to users — that threshold is usually around a 30-second delay on time-sensitive events. Add an orchestration service like Knock or Novu only when you need cross-channel delivery or per-user preference management, not before.

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

Knock and Courier target different team structures, not different technical requirements. Knock is optimized for engineering ownership: it ships pre-built React components for the notification feed (real-time inbox, read states, preference center), a TypeScript SDK, and a workflow engine that handles retry logic and digest batching. Setup takes 2–4 hours according to Knock's own documentation. Courier prioritizes non-technical ownership with a drag-and-drop template builder that lets design or marketing teams modify notification content without a deployment. If your engineering team owns notification templates end-to-end and you want a production-ready in-app feed without building it from scratch, Knock is the better fit. If non-engineers need to edit notification copy regularly, Courier's no-code layer is genuinely useful rather than overhead.

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

Store notification preferences as a flat JSON object on the user profile — a preferences field containing a nested map of category to channel booleans. For example: comments: { email: true, push: false, inApp: true }, billing: { email: true, push: true, inApp: true }. The schema matters less than having one consistent path your notification router reads on every fan-out event. Add a mandatory field at the category level from day one — notifications like payment failure alerts and password resets should bypass user preferences entirely. Don't store preferences in a separate normalized table unless you need to query across users by preference value; a JSONB column in PostgreSQL or a subcollection in Firestore handles per-user lookups efficiently.

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

Route by urgency and session state. In-app notifications are best for everything that makes sense in context — a user is active in your product and the notification is relevant to what they're doing. Push is right for time-sensitive events that require action outside the product: an appointment reminder 30 minutes before, a payment that needs 3D Secure authorization, a critical system alert. Email works best for notifications with rich content, longer reading time, or reference value — billing receipts, weekly summaries, onboarding sequences. The practical rule: if the user needs to act immediately, push; if they need to act soon and may not be in the app, push and in-app; if they need a record or context, email. [Codelit's systems analysis](https://codelit.io/blog/notification-system-architecture) notes that SMS has ~98% open rates but is expensive and legally regulated — reserve it for truly critical alerts where push delivery cannot be guaranteed.

What is fan-out in a notification system and how does it work?

Fan-out is the process of taking one notification event and delivering it to multiple recipients or channels. A comment on a shared document needs to notify every collaborator — that's fan-out across users. A billing alert needs to arrive via email, push, and in-app — that's fan-out across channels. The two standard approaches are fan-out on write (you create a notification record for each recipient immediately when the event occurs, suitable for small subscriber counts) and fan-out on read (you store the event once and compute each user's notification at query time, suitable for high-follower events with thousands of recipients). Most production systems use a hybrid: fan-out on write for standard cases, fan-out on read for events with large subscriber counts. The queue-based async delivery pattern described in this post handles the channel fan-out side; the write-vs-read decision handles the recipient fan-out side.