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?
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?
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:
- The application publishes an event such as notification.created with a userId, an event type, and a payload.
- A worker consumes the event, loads the user's preferences, and decides which channels apply.
- The worker hands the message to one job per channel: email, push, in-app, and optionally webhook.
- 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?
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.
| Option | Fits when | Pricing shown on its site (checked 30 Sep 2026) |
|---|---|---|
| Build: Resend plus a notifications table | Email and in-app only, small team | Provider usage costs plus your time |
| Knock | Engineers own notification workflows | Free tier 10K messages per month; Starter $250 per month for 50K |
| Courier | Non-engineers edit templates | 10,000 sends per month free, then $0.005 per send |
| Novu | You want to self-host | Self-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.



