The Notification Delivery Layer: Controlling Volume Without Losing Messages
A notification system that only asks "should this be sent?" will eventually
send everything, correctly, all at once and the person holding the phone
will switch it off.
This post is about the opposite failure, and it's the one users
actually complain about: everything arrived, every item correct, and the phone
buzzed nine times in a minute.
That isn't a delivery bug. Every send was authorised, addressed, and
legitimate. The defect sits in a question nobody had assigned to a layer: how
many device interruptions should a set of already approved messages cost?
That question turns out to belong squarely to the delivery layer answering
it needs a count and some titles, and nothing about what the messages mean. It
also turns out that answering it badly is worse than not answering it at all,
because a system that quietly collapses notifications is a system that quietly
loses them. Here's how we drew the line.
THE PROBLEM VOLUME IS A DELIVERY CONCERN, NOT A DOMAIN ONE
Three layers could plausibly own "too many notifications," and picking wrong
is expensive.
The domain knows what a message means, so it looks like the right owner. It
isn't. The service that handles community activity has no idea the health
service is about to send something in the same ten seconds. Volume is a
property of the set, and no single producer can see the set.
The user's preferences are the second candidate just let people mute things.
But muting is a blunt instrument aimed at a volume problem, and people reach
for it exactly once, permanently.
That leaves the delivery layer, which is the only place the whole set is
visible. And it can answer the question without knowing anything sensitive:
bundling three items needs a count and three titles. It never needs to know
that one of them is about a water bowl.
So we wrote the boundary down as a rule. The delivery layer decides how many
interruptions a set of approved messages costs. It never decides whether a
message should exist.
THE FIRST FORK A DEFECT AND A COURTESY LOOK IDENTICAL
Before writing a line of bundling logic, we had to separate two situations
that produce the same symptom.
A caller stuck in a loop emits forty notifications. A genuinely popular post
earns its author forty notifications. Both are "forty notifications in a
minute." One is a bug and one is the product working.
We handle them with opposite mechanisms, and that distinction is the
load-bearing part of the design:
- A burst is a defect. Excess notifications are dropped, and every drop
writes one row into the delivery trail explaining why. It is deliberately
not collapsed into something pleasant the whole reason that layer exists
is to surface the bug behind it. - Volume is a courtesy problem. Legitimate notifications are all kept, all
recorded, and merged into fewer interruptions.
Two consequences follow immediately. First, the burst cap has to be sized
above realistic fan-out. A cap that trips during ordinary popularity drops
real notifications and presents to users as an outage worse than having no
cap at all. Second, merging is what makes that generous ceiling livable: the
cap stops a runaway loop, and merging handles everything below it.
Critical safety alerts get a higher ceiling, never an exemption. A looping
safety alert is the most dangerous case, not the exempt one it rides a
transport designed to bypass every downstream limit, so nothing else would
stop it. Its allowance is counted separately, so a flood of ordinary
notifications can't consume it, and a breach logs at error level. It is
simultaneously a user-facing incident and a bug somebody has to fix.

Collapsing a burst into a tidy bundle would hide the exact bug the layer
exists to surface. Merging is a courtesy; dropping is a diagnosis.
THE APPROACH WAITING IS A PER-TIER DECISION, AND IT'S BOUNDED
Merging means waiting, and waiting is the one thing you cannot do uniformly.
We tie the wait to the message's priority tier, with two rules that never
bend.
Urgent tiers never wait. A critical alert sends immediately and it also
flushes anything already pending for that person first, so the urgent push
doesn't land before the summary of what preceded it. High priority never waits
either: an urgent message must not inherit a leisurely one's window.
Ordinary tiers wait, but with a ceiling. Normal-priority messages get a short
debounce a few seconds to see whether a companion arrives. Low-priority
messages get a longer window. Both carry a hard cap measured from the first
item in the bundle, never from the most recent arrival.
That last detail is the classic mistake. Reset the timer on every arrival and
a steady trickle extends the window forever , the first message never leaves.
Measured from the first item, the bundle has a guaranteed exit.
A bundle also flushes on a maximum item count and when it does, it flushes
including the arriving item rather than truncating. Small decision,
disproportionate payoff, covered next.

Every window is bounded from the first item, so a trickle of arrivals can
never postpone a bundle indefinitely.
THE HARD PART MERGING HONESTLY
Bundling is easy. Bundling without lying is where the design actually lives.
Four constraints, none negotiable.
The count must be true. A push saying "3 updates" when nine arrived is a lie
the user catches the second they open the app. The count is the number of
items the bundle actually stands for and nothing else which is precisely why
the cap flushes rather than truncates.
The individual record is never merged. Each message gets its own entry in the
in-app inbox immediately, at the moment it happened. This is what makes a
merged push honest: every item stays individually visible and the count is
checkable. It also keeps the durable record from misreporting when something
occurred a merged inbox would stamp five separate events with the time of
the flush.
The bundle is a push, not a record. Adding a summary row beside its own
members would double-report the same events to the one surface meant to be the
truth.
A bundle inherits the highest tier it contains. Merging may cost an
interruption. It must never downgrade one.
There's a fifth that's easy to miss: a bundle cannot use any member's deep
link. Tapping a push that announced five things and landing on one of them is
worse than landing on the list. Bundles open the inbox.
Finally, each merged member records why it wasn't its own push, referencing
the bundle it joined. "Why did I get one notification instead of three" needs
an answer, or the feature is indistinguishable from a delivery failure.

The record stays one row per event, so the number on the push is something a
user can verify in two taps.
WHAT WE GOT WRONG ON THE WAY
Two failures worth naming, because both were completely silent.
A flush timer is not a loop. Waiting invites an in-process poller, and a
poller is state living on one instance it dies with a deploy and duplicates
when you scale out. The wait is scheduled on the platform's existing scheduler
instead, and the flush is idempotent: two timers can fire for the same bundle,
and whichever arrives second finds an empty bucket and returns. That is
exactly right when the debounce already sent because nothing further arrived.
A gating setting read from the wrong place is a feature that never happens.
Our first cut read the merge windows from a cached configuration snapshot.
That snapshot holds declared defaults it cannot see what an individual
tenant configured. Every tenant would have been pinned to the default window
forever, and merging that silently never happens looks exactly like merging
switched off on purpose. No error, no log, no metric moves; the only observer
is a user who expected fewer buzzes. Configuration that gates behaviour has to
be read through the path that actually consults tenant state, and a test now
pins that the shipped default produces the shipped behaviour.
RESULTS SHIPPED SWITCHED OFF, ON PURPOSE
Merging is in production with every window defaulting to zero, which means
nothing merges yet.
That's sequencing, not timidity. A bundle has to be rendered by a multi-item
template on the push vendor's side, and until that template exists a bundle
has nowhere to land. Turning merging on is a configuration change per tenant,
and a test asserts the shipped defaults change behaviour for nobody.
What is already live and doing work:
- Burst defence, sized above legitimate fan-out, with every drop recorded
and queryable. - Per-item records written at the moment of the event, independent of any
bundling. - The tier contract critical and high never wait, and an urgent send
flushes what preceded it. - Idempotent, scheduler-driven flushes, with no instance-local timer
anywhere in the fleet.
Remaining: the vendor template, then a per-tenant rollout, in that order.
KEY TAKEAWAYS
- Volume belongs to the delivery layer. No individual producer can see the
whole set, and user preferences are a blunt instrument people pull exactly
once. - Separate the defect from the courtesy. A burst is a bug drop it and
record it. Legitimate volume is a comfort problem keep everything and
merge it. - Bound the wait from the first item, never the last. Otherwise a trickle
extends the window forever. - Flush; never truncate. A count a user can check has to be true.
- Merge the interruption, not the record. The durable log stays one row per
event, stamped when the event happened. - Design against silence. A merge that never runs, a bundle that
under-counts, a timer that dies with a deploy — none of these raise
anything. Each one needs a test that asserts the shipped behaviour, not
the intended one.
Hoomanely builds technology for pet parents — connected devices, health
signals, and AI that turns all of it into guidance a household can act on. Our
mission is straightforward: help people give their animals longer, healthier,
better-understood lives.
Notifications are that product's voice, which makes restraint part of
correctness rather than a nicety. Someone who mutes us over nine buzzes about
likes will also miss the one that says their dog hasn't touched water since
yesterday morning. The cost of over-notifying isn't annoyance; it's a channel
that no longer works when it matters.
An interruption budget isn't polish. It is how we keep the channel that
carries a real alert worth listening to.