Browser push is a channel, not a provider
Web push arrives looking like a third provider next to Expo and FCM. Make it one and “stop the browser popups, keep the alerts on my phone” becomes a sentence your users cannot say, with no honest way to add it later.
Browser push and mobile push look like the same thing wearing different clothes. Same queued
row, same send worker, same frozen title and body, same at-least-once delivery. We already had
a PushProvider enum holding expo and
fcm. Adding webpush next to them is a
one line change, plus an adapter, and everything downstream keeps working.
That was the plan for about half a day. It is the wrong model, and the reason has nothing to do with transports.
Providers are ours, channels are theirs
A contact's opt-out is one row, keyed by notification type and channel:
What an opt-out actually storescontact type channel enabled c_8213 order_shipped push false
Now say out loud what a person actually wants: stop the popups on this laptop, keep the alerts
on my phone. If browser push is a provider hiding under push, there
is no row that expresses that. The only lever the recipient has is
push, and it is on or off for both. They turn it off to stop the
laptop and lose the delivery alert they cared about.
The tenant side has the same shape. A type's channel defaults are per channel, so a team that wants order updates on phones but never in a browser tab needs the two to be separately addressable there too.
You cannot split a channel later
Suppose we shipped it as a provider anyway and the request arrived six months later, which it
would have. By then thousands of contacts have a stored row saying
push: false, and nothing in it says what they meant. They meant the
thing that was buzzing them, which is whichever device they happened to be holding.
Split the channel and you pick which way to be wrong. Carry the opt-out to only one side and you start delivering to people who believe they opted out, which for a notification product is the worst available bug, because it looks exactly like ignoring them. Carry it to both and browser push stays off for a population that mostly never had a browser subscription in the first place, so the feature launches dead and nobody can tell you why.
Providers are cheap to change forever. You can add one, swap one, retire one, and no stored row means something different the next morning. A channel is part of the key of every preference anyone has ever saved. Getting it wrong is not a refactor, it is a guess applied to real people's choices.
The payload disagreed too
This part came second, and it confirmed the decision rather than driving it. A mobile push is allowed to be silent, a data-only message the app handles without showing anything. A browser push is not, and the constraint is layered:
- A browser only grants a subscription under
userVisibleOnly: true. showNotification(title, options)takes the title as a required argument.- A service worker that receives a push and shows nothing gets the browser's own “This site has been updated in the background” notice instead.
So an untitled web push is not a silent send. It is a send the browser narrates on your behalf, in copy you do not control, attributed to your product. Title is therefore required on a web push template and optional on every other channel. One payload shape spanning both would have had to make it optional and hope, and the failure would have shown up in a customer's browser rather than in a 422.
Web push also carries icon, badge and
url, which no other channel has. Three columns that only one member
of a shared enum ever populates is the usual smell that the enum has two things in it.
Where they do share, and how that stays honest
The two channels read one list. A contact has push subscriptions, and the channel each one belongs to is decided by its provider:
The only coupling between the two ideasdef is_web_push(provider): # Whether this provider reaches a browser rather than a phone. return provider in WEB_PUSH_PROVIDERS
Web push takes the browser endpoints, mobile push takes the rest, and that is the only place in the system where a channel and a provider know about each other. Everything else stays shared: one outbox table, one send worker, one dead letter path. The delivery log tells the two apart by the row's provider rather than by having its own copy of the rule.
Being one thing in the plumbing and two things in the preferences is fine. The reverse is what you cannot fix.
A web push destination is not a token
Mobile push gives you an opaque token. Web push gives you an endpoint URL plus two keys,
auth and p256dh, generated by and for
the subscriber's browser. We kept the endpoint in the existing value field so identity, dedupe
and the pruning path did not change, and hung the keys beside it. Two decisions there are worth
stealing.
The keys sit outside identity. A browser can hand back the same endpoint with rotated keys, and that is the same destination with new keys, not a second device. So registering again replaces the record instead of ignoring it as a duplicate. Ignore it and you keep keys that can no longer encrypt for that subscriber, forever, silently, because a push nobody can decrypt looks identical to a push nobody read.
The "keys are required" rule lives at registration, not in the constructor. The constructor was the first version and it was wrong three times over. Three places build a subscription with no keys on purpose: the mapper rehydrating a stored row, the delete route naming one to remove, and the workers pruning a dead one. Worse, browser endpoints had been an accepted provider before the keys existed, so rows written under the old rules were already in production, and a constructor check made those contacts fail to load at all. Not their web push. Every channel, for those contacts.
What the extra channel actually cost
Inside the templates module, very little: a payload class, a codec entry in the mapper, a router from the existing factory, and one line in the fan-out dispatch table. The cost lands wherever the channel vocabulary is written down outside that module.
- A new member of the delivery-channel enum the logs are keyed by.
- A provider filter on the delivery-log query, because the two channels share one outbox.
- Every array in the console that lists channels, since a missing one renders as a silently absent column.
- Its own dead letter alarm. Channel is a metric dimension, and the existing alarm filters on the mobile channel, so it would never have fired for browser push. A new channel without its own alarm is a queue that fails in silence.
That last one is the one to write down somewhere. Adding a channel is mostly cheap now, and the expensive part is not code, it is the handful of places that enumerate channels to watch them.
The question to ask
Whatever your recipients' opt-outs are keyed by is the thing to get right before launch. Everything on the far side of that key can be rewritten on a quiet afternoon.
So when the next transport shows up, ask one question about it. Can the recipient tell the difference? If someone can say yes to that one and no to this one and mean it, they are two channels, however similar the code looks. If they cannot, it is a provider, and you can move it whenever you want.
Browser push, mobile push, email and in-app from one API
Separate channels with separate opt-outs, per-tenant branding, and templates your team can edit without a deploy. Free tier, no card, and a self-host option so the exit stays open.