Skip to content
All posts
7 min read

The hard part of notifications isn't sending them

A demo turned 'can it also ping me?' into a must-have. Delivering a message took an afternoon; deciding who it's for and what 'seen' means took the rest of the project.

Semmy Verdonschot
Semmy VerdonschotFull-Stack Engineer
  • #architecture
  • #backend
  • #security

Halfway through a prototype demo, someone asked the question that quietly rewrites your backlog: "can it also let me know?" The dashboard was doing its job, all the information was there and people could find it, but a dashboard only works when you're looking at it. A planner needs a ping the moment a container has been standing too long. A driver wants to know his sick note was approved without opening anything. Management wants a record that a planner acted on the alert.

That sounds like a feature. It's an afternoon of work: a table, a POST route, a bell icon. I built exactly that first, and it worked. Then the questions started that the afternoon version had no answer for, and every one of them was about addressing and state, not delivery.

Where should it even live?

The first fork was whether notifications belong inside the dashboard's backend or somewhere on their own. Inside was tempting, and honestly it's the right call more often than architecture blog posts admit. No extra deployment, no extra secret, no extra thing to keep alive at 3am.

What tipped it was a sentence from the client's own developer: whatever I built had to be reusable by their other products. And once you picture a second product publishing a notification, "inside the dashboard's backend" gets strange. A message from a warehouse kiosk isn't logically a dashboard feature, but it would have to travel through dashboard routes and carry dashboard auth to get out. So it became its own small service: publishers POST to it, end users read from it, and it knows nothing about anyone's business logic. It validates who a message is for and how urgent it is, and publishes.

The third option, letting the frontend subscribe to the collection directly and filter client-side, I ruled out immediately. That puts every audience rule in code the user controls, which means the user can turn it off. Fine for a read-only display in a hallway. Not fine for a system that carries audit messages about people's actions.

The bill for that choice came due right away in extra infrastructure. The payoff came a few weeks later when the kiosk work started and there was simply nothing to write. It published to the same endpoint and the audience layer already worked.

Who is a message for?

Everything downstream gets easier once you commit to a small vocabulary, so I settled on three audiences and refused to add a fourth:

  • user — one exact recipient. "Your sick note was approved."
  • role — everyone holding a role. "Container standing too long" to all planners.
  • broadcast — everyone authenticated. "Maintenance tomorrow morning."

That's the whole model. It's deliberately boring, and it's the reason the rest of the service stayed small.

What "seen" means is the actual problem

Here's where the afternoon version fell apart. My notification rows had a read boolean, which is what everyone writes first, and it's completely correct right up until a message has more than one recipient. Planner A opens the alert, marks it read, and it vanishes for planner B, who never saw it. The bug isn't in the code. The data model is claiming that "read" is a property of the message, and it isn't. It's a property of the pair: this message, this person.

The obvious fix is fan-out. One row per recipient, so a role message to four planners writes four rows, each with its own read state. Simple, and it works, and I still didn't do it. Four rows is fine; a broadcast to two hundred users is two hundred rows and two hundred realtime events for one sentence about maintenance. Worse, membership isn't frozen: hire a planner on Tuesday and you now have to decide whether to retroactively mint rows for Monday's messages. Every answer to that question is unpleasant.

So the message stays a single row, and read state moved into its own small collection of (notification, user, timestamp) records. The server joins the two when it builds your feed. The thing I like about it is how the storage scales: not with how many people could see a message, but with how many actually clicked. A broadcast nobody opens costs one row.

For single-recipient messages the plain boolean stays, because splitting one recipient into a join table buys you nothing. The frontend never learns that two mechanisms exist — it asks for a feed and gets a feed.

Messages have to die, too

A notification that outlives its reason is noise, and noise trains people to ignore the bell. When a planner marks a container alert as handled, the alert message should disappear for every planner, not just the one who did the work. That's one call from the source mutation: here's what changed, clean up after it.

Audit messages are deliberately excluded from that sweep. They exist precisely so management can see that something happened, so having the action itself erase its own paper trail would defeat the point. Management clears those by reading them.

Realtime, but only as a doorbell

I wanted a WebSocket server. I had it half-designed before admitting that I'd be rebuilding reconnect handling, heartbeats and channel fan-out that the backend platform already ships. So the service just writes the row, the platform's realtime layer pushes the event, and the frontend treats that event as nothing more than a nudge to refetch.

The important discipline is what happens on that refetch: the server applies the access rules again, every time. The client also filters what it receives, but that filter is there so nothing flickers on screen for a moment before disappearing — it is not a security control, and pretending otherwise is how you end up with a "secure" system whose rules live in the browser's DevTools.

Anything marked critical also fires an email, and it fires it fire-and-forget. A mail server having a bad afternoon must never be able to block a notification from being published. With no SMTP configured at all, the service logs what it would have sent instead of failing, which makes local development and CI runs pleasant rather than a source of stray emails.

Two doors, two keys

There are exactly two ways to talk to the service. Publisher backends use a shared service key. End users use their own token. The rule that matters is the one that falls out of that split: an end user can never publish. If they could, the "send an email on critical" path becomes a spam cannon aimed at their colleagues, authorised by their own login.

One more guard earned its place through paranoia. Drivers sign in on shared tablets with a PIN, so their identity is tied to a vehicle rather than a person. If a publisher ever miscasts one of those vehicle identifiers into the recipient field, a message meant for one person lands on every tablet with that ID. The service rejects it outright with a 400. It's five lines, it sits in one place, and it protects every current and future publisher without any of them knowing it exists.

What I left out on purpose

Mobile push isn't built, because there is no mobile app to push to. What I did was make sure the shape of the service doesn't block it: publishers hand over an audience, a severity and a body, and they know nothing about channels. Adding push later is a new module next to the mailer, and not one line changes in any publisher. That's the whole reason to keep publishers ignorant.

Cross-tab read sync is missing too. Open the dashboard twice and it takes up to a minute for one tab to notice the other marked something read. I priced a second realtime channel against that annoyance and decided a minute is fine. It might not stay fine, and that's an honest thing to write down rather than quietly pretend the gap isn't there.

The takeaway

Nobody asks for a notification system. They ask to be told when something happens, which sounds like plumbing and turns out to be a design problem about identity: who is this for, what does "seen" mean for them, and when does it stop being true. Get those three right and delivery really is the afternoon of work it looked like. Get them wrong and you'll ship the boolean, watch one person's click erase everyone else's alert, and rebuild the whole thing anyway — just with users who have already learned to ignore the bell.

Semmy Verdonschot

Written by

Semmy Verdonschot

Full-Stack Engineer from Limburg, Netherlands, writing on secure, modern web development and the craft of shipping software.