The notification system you didn't plan to build
Sending one email takes two lines of code. The system that grows around it takes months. Here is the case for building that yourself, and the case for not, argued through the two requirements that usually settle it: whose brand each message wears, and whether your app has to keep asking the server if anything new arrived.
Every product gets here eventually. Someone files a ticket: "users should be notified when their report is ready." You look at it for about ninety seconds, decide it's a Tuesday, and write this:
send_email(user.email, "Your report is ready", body)
That code is correct. It ships. It works. It is also the last simple thing that will happen to this feature, because "notify the user" turns out to be the visible tip of a delivery system, and the rest of that system arrives one ticket at a time over the next eighteen months.
The tickets that follow
Nobody scopes these at the start, and every one of them is reasonable on its own:
- "Some users don't want this email." Now you have preferences, per user and per notification type.
- "Can it show in the app too?" Now you have an inbox: a table, read/unread state, an unread count, a bell.
- "And push on mobile." Now you have device tokens, two provider SDKs, and dead tokens to prune.
- "But not push for this type." Preferences are now a matrix, not a checkbox.
- "Marketing wants to change the wording." Copy leaves your codebase and becomes data, so now you have templates and somewhere to edit them that isn't a deploy.
- "Legal says password resets can't be opt-out-able." Some types have to ignore preferences entirely, and you want that distinction living in one place rather than seven.
- "The provider was down for forty minutes on Saturday." Now you need an outbox, retries with backoff, and a rule for which failures are worth retrying at all.
- "Support can't tell whether the customer got it." Now you need a delivery log, per recipient, per channel, with the failure reason attached.
None of that is hard, exactly. There is just a lot of it, and it is load-bearing: it sits between your product and your customers, and when it breaks the failure mode is silence. Nobody opens a support ticket about an email they never received.
The build-vs-buy question isn't "can we write this?" You can. It's "is this the system we want to still be maintaining in three years?"
Two requirements settle that question faster than the rest, because both look small and neither is. If you sell to businesses whose own customers get the notifications, the first one is coming for you. If you have an in-app inbox, so is the second.
Requirement one: whose brand is on it?
Here is the case that turns a weekend project into a subsystem.
Cliniq, scheduling software for medical practices
Cliniq is a B2B SaaS. Its customers are 412 clinics. But the people receiving the notifications aren't Cliniq's customers at all. They are the clinics' patients, and a patient has never heard of Cliniq.
When Northside Family Practice's appointment reminder goes out, it has to say Northside Family Practice. Their logo. Their colours. Their phone number in the footer, their reply-to address, their cancellation policy. A patient who gets an email branded "Cliniq" either ignores it or calls the clinic to ask who Cliniq is. Both outcomes are bad, and the second one is bad for the clinic, which is the customer paying the bill.
This is the ordinary shape of B2B2C, and it is where the naive build goes wrong. The obvious move is a template per tenant: copy the appointment reminder 412 times, swap the details, done by Thursday.
Then do the arithmetic. Cliniq has 6 notification types (booked, reminder, rescheduled, cancelled, results ready, balance due) across 3 channels. Per tenant that is 18 templates. Across 412 clinics it is 7,416 rows. When legal changes one sentence in the appointment reminder, that sentence lives in 412 places. You will miss some, and you will find out which ones from a compliance review or a customer.
Separate the brand from the template
The fix is to stop treating the clinic's identity as part of the copy. Write the template once, with slots, and keep each tenant's identity as its own small record of values:
One template, every clinicSubject: Your appointment with {{ clinic_name }} on {{ appointment_date }} Hi {{ first_name }}, This is a reminder of your appointment at {{ clinic_name }} on {{ appointment_date }} at {{ appointment_time }}. Need to reschedule? Call us on {{ clinic_phone }}. Thanks, The team at {{ clinic_name }}
clinic_name and clinic_phone come from the
brand. appointment_date comes from the
event. first_name comes from the
contact. Three sources, merged into one namespace at render time, most specific
wins. Legal changes the sentence in one row and all 412 clinics are correct.
Sending is then one call. You name the tenant's brand and Elaan resolves the rest:
POST /v1/notifications{
"notification_type_key": "appointment_reminder",
"external_id": "patient_84213",
"branding_key": "northside", // which clinic's identity to wear
"variables": {
"appointment_date": "Tue 12 August",
"appointment_time": "09:30"
}
}
→ 202 { "event_id": "01JZQ8V3XKP2M7" }
That single request fans out to every channel the patient has enabled for that type: inbox, email and push, each rendered with Northside's brand values. You didn't write a loop over channels, and you didn't wait for SMTP.
Fallback, not duplication
The reason this holds up at 412 tenants is that a brand is an override rather than a requirement. Most clinics never want custom copy. They want their name and logo in the standard wording. So the lookup walks a ladder and takes the first hit:
- 1This brand, this languageNorthside asked for a custom Spanish reminder. Rare, and it wins when it exists.
- 2This brand, default languageNorthside rewrote the reminder for everyone. Still rare.
- 3Default brand, this languageYour standard Spanish reminder, wearing Northside's values.
- 4Default brand, default languageThe one template you actually maintain. This is where ~99% of sends land.
So onboarding clinic 413 means one brand record (a name, a logo URL, a phone number, a hex colour) and zero new templates. A clinic that later wants its own wording for one message gets a single override row, and everything else keeps falling through to the template you maintain. Duplication only happens where someone asked for a difference.
Building this yourself is maybe two weeks: a brands table, a resolution function, a slot renderer, and a preview screen so support can see what a given clinic's email looks like. That is fine work. The question is whether it is your work, and whether a scheduling company's engineers should still be maintaining a template-resolution ladder in year three.
Requirement two: real-time, or the polling tax
Now the in-app half. The bell in your header needs an unread count, and the count needs to be right. The path of least resistance is a timer:
The default everyone reaches foruseEffect(() => { const id = setInterval(() => { fetch("/api/notifications/unread-count").then(setCount); }, 15000); return () => clearInterval(id); }, []);
This works perfectly in staging, where there are four users. Here is what it costs in production.
Ten thousand people with a tab open, polling every fifteen seconds, comes to 4 requests per
minute each: 40,000 per minute, about 667 per second, 57 million a day. Every one of them
authenticates, opens a database connection, runs a COUNT, and
over 99% of them return the number the client already had. You are paying full
price, continuously, for the answer "nothing changed."
It isn't even good UX. Fifteen seconds is a long time to stare at a stale badge, and shortening the interval to make it feel live multiplies a load figure that was already your largest endpoint. Polling makes you choose between feeling slow and being expensive.
Polling is your app asking "anything new?" 57 million times a day to hear "no" 56.9 million times.
The inversion is a persistent connection that the server writes to when something actually happens. Elaan holds an SSE stream per connected contact: fan-out writes the notification, signals the open connection, and it appears in the bell. No timer, no interval, no wasted round trip. An idle user costs an idle socket instead of four requests a minute.
The whole client integrationimport { ElaanProvider, NotificationBell } from "@elaanio/react"; import "@elaanio/react/styles.css"; <ElaanProvider apiBase="https://api.elaan.io/v1" tokenProvider={tokenProvider}> <NotificationBell /> {/* live badge, popover, mark-as-read */} </ElaanProvider>
There is no setInterval in that snippet and no
EventSource either, which is the point: the streaming endpoint is
the easy 20% of real-time. The other 80% is what a naive build discovers over the following
months:
- reconnecting with backoff when the connection drops, without stampeding the server when a deploy drops all of them at once;
- catching up on what was missed during the disconnect, so a five-second network blip doesn't cost a notification;
- refreshing an expired auth token mid-stream without tearing down the UI;
- keeping four tabs of the same app consistent instead of four times connected;
- falling back to polling where a proxy or corporate network eats the stream, then climbing back out of it;
- doing all of that identically on React, React Native, Vue and Svelte, because you shipped a mobile app too.
That list is where the schedule goes. It is also invisible in a demo, which is why it gets estimated at "a few days."
So: build, or buy?
Both columns have a real answer. Here is how the trade sits:
| Building it | Buying it | |
|---|---|---|
| Time to first send | A day. This part really is easy. | An afternoon, including the in-app inbox. |
| Time to done | Two to four months of engineering across preferences, branding, retries, real-time and a delivery log, usually spread over a year of tickets. | The integration, plus whatever your product needs on top. |
| Per-tenant branding | Yours to design. Easy to get wrong in the duplicating direction. | A brand record per tenant, one template, a resolution ladder. |
| Real-time | SSE is a day. Reconnection, catch-up, token refresh, multi-tab and four client platforms are not. | A provider component. The hard parts are the vendor's problem. |
| Ongoing cost | Provider SDK upgrades, a new channel every year or two, and the on-call that comes with an outbox. | A bill, and a dependency you didn't write. |
| Fits weird requirements | Perfectly, because it's yours. | Until it doesn't, which is the real risk. |
Build it if:
- Notification delivery is your product, or close enough that its behaviour is a competitive edge.
- You need one channel, one brand and no in-app inbox. An SMTP call in a background job really is the right size, and taking a dependency for it would be silly.
- You have constraints a vendor can't meet: data residency in a jurisdiction nobody serves, an air-gapped deployment, an existing message bus everything must route through.
- You have the team to own it. Not to write it, to own it, in year three, after the people who wrote it have moved on.
Buy it if:
- You're multi-tenant and your messages wear your customers' brands. This is the strongest single signal, because the branding ladder is more machinery than it looks and getting it wrong is visible to your customers' customers.
- You need more than one channel. The second channel is where "send a message" becomes "route a message," and routing is the system.
- You want an in-app inbox that updates live, and you'd rather not spend a quarter on reconnection semantics across four client platforms.
- Your differentiator is somewhere else entirely, which for almost every product it is.
Six questions worth asking first
Whether you're evaluating a vendor or your own design doc, these are the ones that separate a notification feature from a notification system:
- Can one template serve 400 tenants? If the answer involves copying a row per tenant, you have found next year's compliance incident.
- What happens when the email provider is down for an hour? The only good answer is a durable queue with backoff, plus a clear rule about which failures are permanent.
- If push fails, does email still go out? Channels have to fail independently. One missing device token should never swallow the whole send.
- Can a password reset ignore preferences? Transactional types must not be opt-out-able, and that rule belongs in one place rather than at every call site.
- Does the bell update without polling? And when the connection drops, does the client catch up on what it missed?
- Can support answer "did she get it?" Per recipient, per channel, with the failure reason, without an engineer reading logs.
If most of those answers are "we'd have to build that," you have a scope, and it isn't a Tuesday.
That's not an argument against building. It's an argument for deciding on purpose, while the whole
thing is still one send_email call and not eighteen months of tickets.
One API for in-app, email and push
Per-tenant branding, per-contact preferences, and a real-time inbox with no polling, plus a self-host option so the exit stays open. Free tier, no card.