← Blog

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:

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.

Worked example

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 clinic
Subject: 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:

  1. 1This brand, this languageNorthside asked for a custom Spanish reminder. Rare, and it wins when it exists.
  2. 2This brand, default languageNorthside rewrote the reminder for everyone. Still rare.
  3. 3Default brand, this languageYour standard Spanish reminder, wearing Northside's values.
  4. 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.

The same ladder handles language. A contact carries a preferred language, and a template can have a per-language variant. Northside's Spanish-speaking patients get the Spanish reminder if it exists and the standard one if it doesn't. The send call doesn't change, and a missing translation falls back instead of failing.

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 for
useEffect(() => {
  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 integration
import { 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:

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 itBuying 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:

Buy it if:

The bigger risk in buying is leverage, not cost. Notification infrastructure sits on the path between you and every customer you have, and a vendor knows it. Ask the exit question before you integrate rather than after: can you run this yourself if the pricing or the company changes? Elaan answers that by being self-hostable. Same image, your Postgres, your infrastructure, so moving to run it yourself is a deployment decision rather than a rewrite.

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:

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.

Try it

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.