Let's talk
product

Every alert was sent correctly, and nobody received one

Nine kinds of alert, each carefully built, every one delivered to zero people. Why app notifications fail silently, and the rule that stops people ignoring them.

·

A laptop on an office desk showing the assign and publish screen in Sazinga Engage.

Your app sends alerts. A parent should hear when a child finishes something; a child should hear when new work arrives. The alerts were built, nine different kinds, each one thought about carefully. And not one of them ever reached a phone.

Nobody noticed, because nothing failed. Sending an alert to nobody is not an error. The system reported every send as a success, because it had successfully sent to a list of zero people. From the outside, the app simply seemed quiet.

The cost of an alert that never arrives is the same as the cost of one that arrives about nothing: people stop expecting the channel to tell them anything, and once they have learned to ignore it, the one that mattered is ignored too. That is the failure both halves of this article are about.

What was actually going on

For an alert to reach a phone, the phone first has to hand your system a delivery address. The screens for doing that existed and worked. The app never called them. So the list of addresses had no entries — not a few, none — and every alert fanned out to an empty list.

The sending side was correct. The phones were correct. The whole feature was a no-op, and nothing in the system was in a position to say so.

A delivery feature has three parts that fail independently: the phone registering its address, the server sending, and the phone deciding whether to display what arrives. Only the middle one produces anything you would naturally look at. Registration failing looks exactly like a list nobody has written to yet. Display failing produces nothing at all; the message arrives and is dropped.

I built the middle part first because it is the interesting one. The rule that came out of it: build registration first, and do not write a line of the sender until you can point at an address that came from a real phone. A sender is easy to test once a recipient exists. A missing recipient is hard to notice once a sender exists, because the sender keeps reporting success.

What we changed

Registration now runs from the first moment the phone can supply an address, and again every time the app comes back to the foreground, because the address changes over time. A registration done once, at install, quietly goes stale, and a stale address looks exactly like no address.

The third part bit later. The phone’s operating system will only display an alert on a channel the app declared when it was built, and the server has to name the same channel. If they do not match, the message arrives and is silently discarded — no error, nothing in the app’s own logs. That declaration lives in the app’s build, not in code that can be changed on the fly, so fixing it meant rebuilding and reinstalling on every test device. A two-minute fix became a twenty-minute one. Delivery features have build-time requirements, and build-time requirements are invisible to tests, to live reloading and to review. Keep a list of them per feature.

Proving it worked without pestering anyone: the delivery service offers a dry-run mode that checks the credentials, the address and the message shape, reports success, and delivers nothing. One call proved every link in the chain except the final display, without a single message reaching a person. Most delivery providers have such a mode — payment, email, push — and it turns “I think this is configured correctly” into a fact.

Every alert must have something behind it

The other half of the work was deciding when not to send, and this is the part I would keep unchanged.

The rule, applied to all nine kinds: an alert must lead somewhere real. If tapping it opens an empty screen, or a queue with nothing in it, it should not have been sent.

That produced three deliberate silences. Work that is checked automatically pays itself and never enters the parent’s review queue, so no “please review” alert goes out; an alert about an empty queue is worse than silence, because it trains people to ignore the channel. A child retaking a quiz is practising, so only the first paying attempt announces anything, on exactly the same rule that decides whether it pays — two rules that ought to agree will eventually disagree if written twice. And assigning a lesson that is still a draft sends nothing, because the child’s device cannot see a draft; the alert would open onto a list that does not contain it. Publishing the lesson is what announces it, so the child hears exactly once, when it becomes real.

What it did not fix

An earlier version of this product could not read its own alert content, for privacy reasons, so every alert was a contentless doorbell: “something happened, open the app”. This version can, and the content is the entire point — a parent wants the sentence, not a prompt to go and find it. That is a case where a privacy design, quite correctly, made a feature worse, and changing the design is what changed what the feature could be. The trade-off is real and it was made deliberately.

The mechanism

The app framework offers its own push token, routed through the framework vendor’s relay; our server posts directly to the underlying push service, which rejects that token outright. The correct call is the one that returns the native device token, and it is re-asserted on every foreground because the token rotates. The default notification channel is contributed to the application manifest at build time by a plugin, and the payload’s channel id must match it exactly or the OS drops the message. The push service’s validation-only mode accepts the exact payload, checks project, credentials, token and shape, and delivers nothing.

Where this ends up

Sazinga Engage assigns a module with a due date and a reminder schedule. The rule above is the one those reminders have to obey: a reminder that opens onto nothing should never have been sent, because the person who taps it will not tap the next one.

This came out of building Sazinga Engage

Publishing the training is easy, finishing it is the hard part. The problem above is one we met while building it, and what we did about it is in the product.

If you run something like this, there is one thing you can do without a call: send one training document.