The notifications were correct, and every one of them went to nobody
I built notifications in both directions — a parent told when a child finishes something, a child told when new work arrives — wired nine send points, thought hard about each one, and then looked at the table of registered device tokens.
It had no rows. Not a few. None.
The app had never called either of the two registration endpoints. They existed, they worked, and nothing anywhere invoked them. So every notification the new sender produced fanned out to a list of zero devices, successfully, with no error, on both sides.
The sending code was correct. The receiving devices were correct. The whole feature was a no-op, and nothing in the system was in a position to say so, because sending to nobody is not a failure — it is a send with a recipient count of zero.
Three independent halves, one of which is silent
A push feature has three parts that fail independently:
- Registration. The device obtains a token from the platform and gives it to your server.
- Send. Your server takes an event and posts a payload to the push service.
- Acceptance. The platform on the device decides whether to display what arrived.
Only the middle one produces logs you will naturally look at. Registration failing produces an empty table, which looks identical to a table nobody has written to yet. Acceptance failing produces nothing at all — the message arrives and is dropped.
I built the middle one first because it is the interesting one. Build registration first, and do not write a line of the sender until you can point at a row in the token table that came from a real device. The sender is easy to test once a recipient exists. A recipient is hard to notice the absence of once a sender exists, because the sender keeps reporting success.
Two details caught me on registration itself, both of which are the sort of thing you only learn by doing it.
The app framework offers its own push token, which routes through the framework vendor’s relay. Our server posts directly to the underlying push service, which rejects that token entirely — they are different identifiers for different delivery routes. The correct call is the one that returns the native device token.
And the token rotates. It is not a durable identifier. So registration runs from each shell where the credential is first guaranteed to exist, and is re-asserted every time the app comes to the foreground. A registration that happens once, at install, degrades quietly into an out-of-date token, and the failure looks exactly like the empty-table case.
Acceptance is configured in a file, not in code
The third half bit later, and it is worth naming because it is invisible to every test you would think to write.
The platform requires a default notification channel to be declared in the application manifest, and the server’s payload must reference the same channel. If they do not match, the message arrives on the device and is silently discarded. No error, no callback, nothing in the app’s own logs.
That manifest entry cannot be produced by runtime code. It is contributed at build time by a plugin, which means it requires a rebuild and reinstall on every test device to take effect — a fact that turns a two-minute fix into a twenty-minute one, and which I discovered by wondering why a message the server had definitely sent had definitely not appeared.
Delivery features have build-time requirements, and build-time requirements are invisible to tests, to hot reloading and to code review. Keep a list of them per feature. It is short and it saves an evening.
Proving it works without spamming anyone
Verifying push against production is awkward: the only convincing evidence is a notification appearing on a real device, and generating those repeatedly means sending real messages to real people.
The push service has a validation mode that accepts the exact payload, checks the credentials, the project, the token and the message shape, returns success, and delivers nothing. One call proved every part of the chain except the final render, without a single message reaching anybody.
That is worth generalising. Before building a test harness for a delivery integration, check whether the provider has a dry-run mode. Most do — payment gateways, email transports, push services — and it converts “I think this is configured correctly” into a fact.
Every notification 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 at all nine call sites: a notification 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 specific silences, each of which required understanding the domain rather than the code.
A task that verifies automatically pays itself and never enters the review queue, so there is nothing for a parent to review and no notification is sent. A “please review” alert about a queue with nothing in it is worse than silence — it trains people to ignore the channel.
A child retaking a quiz is practising. Only the first paying attempt announces anything, on exactly the same rule that decides whether it pays. Reusing the payment rule for the notification rule was deliberate: two rules that ought to agree will eventually disagree if they are written twice.
Assigning a lesson that is still a draft sends nothing, because a draft is invisible to the child’s device — the notification would arrive and open onto a list that does not contain it. Publishing covers the other ordering, so the child hears about it exactly once, when it becomes real.
One change of principle sits underneath all of that. An earlier version of this product could not read its own notification content, so every notification was a contentless doorbell saying “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. Worth stating because it is a case where a privacy architecture, quite correctly, made a feature worse, and changing the architecture changed what the feature could be.