DEV Community

Cover image for The notification system you didn't plan to build
Elaan.io
Elaan.io

Posted on Originally published at elaan.io

The notification system you didn't plan to build

Originally published on the Elaan blog.

Sending one email is one line of code. The system that grows around it takes months. We had to build that system three times for different products before making it the product, so here is the case for building it yourself and the case against, argued through the two requirements that usually settle it.

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)
Enter fullscreen mode Exit fullscreen mode

That code is correct. It ships. It works. It is also the last simple thing that happens to this feature. "Notify the user" is the visible tip of a delivery system, and the rest of that system arrives one ticket at a time over the next eighteen months.

We know because we had to build it three times for different products before making it the product. Each time it started as one line and ended as a subsystem nobody had planned.

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 it is hard. 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 files a support ticket about an email they never got.

So the question was never "can we write this?" You can. It is "do I want to still be maintaining this in three years?"

Two requirements settle it faster than the rest. Both look small. Neither is. If you sell to businesses whose own customers get the notifications, the first is coming for you. If you have an in-app inbox, so is the second.


Requirement one: whose brand is on it?

This is the one 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 we went wrong the first time. 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:

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 }}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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. This brand, this language. Northside asked for a custom Spanish reminder. Rare, and it wins when it exists.
  2. This brand, default language. Northside rewrote the reminder for everyone. Still rare.
  3. Default brand, this language. Your standard Spanish reminder, wearing Northside's values.
  4. Default brand, default language. The 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 about 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);
}, []);
Enter fullscreen mode Exit fullscreen mode

This works perfectly in staging, where there are four users. Then you ship it.

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 number that was already your largest endpoint. Polling makes you choose between feeling slow and being expensive.

Ten thousand users at fifteen seconds is one point on that curve, and it may be nothing like yours. Put your own concurrency and interval into the polling tax calculator. At a few hundred concurrent users it will tell you polling is fine and you should keep it, which is the honest answer more often than a notifications vendor's blog usually admits.

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>
Enter fullscreen mode Exit fullscreen mode

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." We estimated it at a few days once.


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.

The real risk in buying is leverage, not cost. This sits on the path between you and every customer you have, and a vendor knows it. Ask the exit question before you integrate, not after: can you run this yourself if the pricing changes, or if the company does? 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 are evaluating a vendor or your own design doc, these 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 is not an argument against building. We built it three times and would do it again for the right product. It is an argument for deciding on purpose, while the whole thing is still one send_email call and not eighteen months of tickets.


Elaan is one API for in-app, email and push, with 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: console.elaan.io/register · docs

Top comments (0)