The Sorry State of Implementing Web Push Messages in 2024

Are You Prepared to Go Insane?

This article summarises what I learned from trying to implement the most trivial web push notification system possible, to let subscribers know when a new strip of my S.O.N.A.I.S webcomic has been published.

This is not a neatly structured guide, rather a semi-random collection of facts about implementing notifications using Web Push. You should look elsewhere for tutorials, but be aware that those tutorials will usually leave out the small but important details. This webpage is meant to provide many of those details.

If I have to summarise my experiences in a single meme, it will be this one, borrowed from the StackOverflow answer mentioned below:

One Does Not Simply Implement Push Notifications

This text was written after toiling for a whole month on creating a vaguely usable Web Push implementation, repeatedly moving from one frustration to another. Defying Sauron is peanuts in comparison. As a result, this article is full of sarcasm, bile, and some minor strong language. If you cannot handle that, then don't read it.

Introduction

My use case is very simple, almost trivial: I draw a web comic ‘S.O.N.A.I.S.’ that normally updates twice a week, but there may be extra strips sometimes, or I may want to take a break. I thought it would be convenient for whomever wants to read this comic, to get push notifications when a new strip is available. As far as use cases for push messages are concerned, this is about the simplest possible.

My goal: people can hit a subscribe button, there is nothing user-specific, subscriptions are not linked to logins, everyone is treated the same. Every subscriber will receive the exact same notification when a script on the server publishes a message to a single fixed topic. The same style of notification should always appear, even when the user is watching the main comic page, because I want a simple consistent user experience. When a user clicks/touches/punches the notification, always the same webpage opens. I would prefer the page to open in the user's browser of choice, but begrudgingly I would also find it acceptable if the page would open in an embedded browser inside a dedicated app-like thing. I don't even try to reload the page if they already have it open, because I consider it evil to touch someone's already open browser tabs, hence I always open a new window/tab.

As you can see, my goals do not consist of anything special, anything fancy. How hard could it be? As it proves, hard. Way too hard.

My general conclusion: implementing web push messages at this time is a surefire road to insanity. It took me an entire month to end up with an implementation that mostly works and which has no vague copy-pasted Cargo Cult I don't understand. Yet, still this implementation sometimes misbehaves for unknown reasons. On some devices, it works flawlessly. On other devices, it self-destructs very often and needs to be reanimated by the user for it to work again for a few more push messages, after which it will again break. I have no clue why, debugging this problem is as good as impossible, and the problem cannot be within my own code because it keeps working on other devices. 🤯

I have little confidence in the system I created, and I had to slap all kinds of disclaimers on the subscription page after verifying that the remaining issues seem fundamentally impossible to fix. The service worker has only 22 kBytes of code, but each line has been sculpted and crafted over the course of a month, making this amongst the most expensive code I have ever written. This was not a satisfying endeavour at all. Luckily it's just a notification system for a comic I draw purely as a hobby. I wouldn't want to use this for anything truly serious—I wouldn't want to have to deal with infuriated customers.

Alternate conclusion: I do not want to do any job that involves writing web applications beyond the very sporadical tiny simple thing. I value my sanity too much.

There are proposals to make web push notifications less insane, but for the time being, a lot of hoop-jumping is the norm. Implementing Web Push is like navigating a minefield: something will explode at the least expected moments. And, people have done their utmost best to coerce you into walking straight through this minefield with no opportunity to evade it.

One could try to take cover behind some pre-made framework like Angular, but at some point your push messages will break, and it will be very helpful if your knowledge goes further than a bit of Cargo Cult where you mimicked commands from a YouTube video and deployed magical config files. Also, no framework will protect against the inconsistent ways in which different vendors have implemented service workers, PWAs, push messages and notifications, which leads to certain annoying problems for which no satisfactory solution can possibly be implemented, not even in the best of frameworks or libraries. Arguably the worst offender is Apple, which first demands that you wrap your web app inside a PWA, and then provides a bug-ridden PWA implementation which they already have tried to kill at least once. Apple probably sees PWAs as a nuisance that bypasses their app store, and they only provided an implementation because of peer pressure. Their PWA and Web Push implementation is rife with bugs and annoying limitations, and their motivation to fix these issues is very low.

Context

I decided to use Google's Firebase messaging, a.k.a. FCM, to handle the sending of messages. It has the convenient concept of ‘topics’ that matches my use case.
First, we create an app in the Firebase console. A specific instance of the ‘app’ running in someone's specific browser will then be assigned a token, which can be subscribed to a topic. When sending a message to the topic, the FCM server will then handle the fan-out of sending that message to each subscriber of the topic. It was exactly what I needed, and it all sounds good… in theory.

In Theory!

The first indications of things to come, became apparent when I started reading a guide on StackOverflow. The mere length of the answer, its lack of an ‘accepted’ state, and its concluding “One Does Not” meme which I replicated above, were not very encouraging. Still, I felt it was doable and I should be able to make it work within reasonable time. Unfortunately that SO answer proved to be just the tip of the iceberg.

I wanted to get FCM to work with minimal external dependencies. Most guides you will find, not only for push messaging but pretty much anything nowadays, will tell you to deploy utterly bloated framework X and then sprinkle it with some magic configuration, and then it will usually work and in the end you have no idea what you just did. The average web developer nowadays does not mind being dependent on external parties that create fancy libraries which hide all the complexities—I am not one of those people. I like to know how things work, and the fewer dependencies I have to import to get something to work, the happier I am. I have seen way too many things explode in the past due to those external developers creating bugs, or introducing breaking changes, or suddenly killing off the project.
Sure, if I would need to add web push to some existing product backed by a development team, I would choose for a framework right away, because going low-level would be insane in that case. I compare it to Kerbal Space Program. When I started playing it, I managed to perform a moon landing mission entirely manually, using rough calculations and wet-finger guesses, and a lot of trial-and-error and dead Kerbals. The satisfaction of finally seeing the capsule touch down after re-entry was immeasurable. But then I installed the MechJeb plug-in to automate a lot of the aspects for subsequent missions, because I wanted things to progress faster. However, all the experience I gained from my manual endeavours allowed me to notice when MechJeb was about to fail horribly, because I knew what it was trying to automate and what it was supposed to be doing and what not.
This very website is a hobby project that I want to keep pure and simple, and the push message use case seemed basic enough that it should be feasible to implement at a low level, with as added bonus that I would also learn how the whole system works and be more confident when delegating things to a framework. In the end, someone, for instance whoever has to implement those fancy frameworks, has to know the dirty details, and boy are they dirty indeed. I thought it could be useful to collect the dirty details I discovered on this journey.

I will now list a big steaming pile of pitfalls I have encountered, starting with things specific to FCM, then moving on to more general facts, and as grand finale, some iOS-specific things.

Quirks related to using Google's FCM as a messaging back-end

On Android devices, you can see what is actually going on with FCM, by invoking the diagnostics page. On phones, this can be done by dialling *#*#426#*#*, although this will likely only work in phone apps distributed by Google.
On Android devices that have no phone hardware and on which no official phone app can be installed, you will need to enable developer mode and connect through ADB. Then this command can be run from the debugging host machine to invoke the same diagnostics tool:
adb shell am start -n com.google.android.gms/.gcm.GcmDiagnostics

Quirks of web push notification implementation in front-end JavaScript, and PWAs in general

iOS-specific quirks of web push—Here Be Dragons

Now comes the real fun part. Oh yes, it gets even worse.
FCM, or any kind of push messaging for that matter, inside Safari/iOS is a Royal Pain. Yes, even more pain than all the above. But given the market share of this platform, you will want to support it of course, so let's bring on the pain.

Bring It On

  1. Requesting notification permission in Safari is also bound by the transient activation requirement, which Safari has implemented in its own specific way. The notification permission must be requested from within a user gesture handler (click, touch). This is something I actually like, I wish all browsers would enforce this to get rid of the stupid pop-ups when loading websites. (Although as someone who has lived on this planet for many decades, I can guarantee you it won't help much if at all: marketing parasites will then demand that developers implement glass screen pop-ups to beg for notification access, and the situation will get worse for everyone, as it has with the goddamn cookie consent nonsense.)
    It seems that at some point in the past, Safari had separate permissions for notifications and push messages. This no longer seems to be the case, I believe everything has now been lumped together in a single notification permission. Maybe on old iOS versions it is like this, but web push won't work on those anyhow.
  2. Invoking FCM's getToken from within a service worker requires the worker registration to have a PushManager. This is only supported in fairly recent versions of Safari, see the MDN info page.
    You could work around this by doing all getToken calls inside front-end code, but believe me, you don't want to, because it greatly complicates things. Respect your own sanity, and simply tell users of obsolete iOS devices that they will need to pay more Apple tax for a new device.
  3. But wait, it gets worse! On iOS, one can only request/get notification permission from within a PWA environment! (PWA = Progressive Web Application, basically a bookmark-on-steroids that can be installed as an icon on the home screen.) Forget about simply doing it in a regular browser tab. So, not only must permission be requested from a user gesture handler, it must also be from inside a PWA.
    Don't try to detect the browser/environment from user-agent strings, it is hopeless and futile. User-agent strings are DEAD. Do not write scripts that make hard decisions based on the user-agent string, it is a surefire way to shoot yourself in the foot. Just use the absence of PushManager in window as an indicator to show a prompt to users that if they are using an iOS device, they need to install the PWA. Don't try to guess whether the user is on an iOS device, most of them will know it themselves. The ones that don't, should not even attempt to dive into the Web Push cesspool.
    What makes this even more fun, is that Apple seems to hate PWAs, likely because it's a way to bypass their app store. Apple has almost killed PWAs again in Safari at some point, but luckily there was enough protest to revert this decision. However, it does indicate that Apple will have very low incentive to fix bugs in their PWA implementation, and of course there are bugs.
  4. And then it gets even worse! In MacOS Safari, one cannot deploy any PWAs at all. Hence, it does support PushManager inside Service workers in plain browser tabs, but only starting from Ventura. However, there is a big fat bug that will cause the service worker to have no valid PushManager at semi-random moments, for instance when it is first created, even if notification permissions have already been granted.
    Even if there is a pushManager property in the serviceWorkerRegistration object, it may be undefined. This breaks getToken().
    To work around this bug, the user must ‘reboot’ Safari, in other words close and reopen it after the SW has been created, and then it usually has the pushManager. Yes, really. This is how Apple writes software these days. The SW may again lose its pushManager at certain moments, like when a push message is received while Safari is closed (yes, somehow the service worker still runs even if Safari is closed, I guess it is never truly closed).
    <RANT> If you are extremely lucky, this bug is fixed by the time you're reading this, but my experience with Apple products since 1990 tells me you should not get your hopes up high. Apple bugs have a tendency to accumulate and never get fixed until they ditch the whole product and replace it with something else, and then this will again go through a similar process of gradually decaying into an unusable state. Just look at the iTunes/Music app on MacOS, it is a sad affair these days. </RANT>
  5. For FCM v10.10 compat to work at all in Safari, you must use the scripts from www.gstatic.com. Do not use the minified scripts from cdnjs.cloudflare.com, they are entirely broken in Safari, both in MacOS and iOS. You will get an exception “TypeError: t is not a function” when trying to invoke getToken.
  6. So, now you've jumped through the hoops of adding a manifest.json to your page/web app to make it deployable as a PWA, you have probably spent some days on learning how to handle caching in a PWA (you should), and instructed iOS users to install the PWA and then hit a button inside it to enable the notifications. Depending on what you actually want to do, things may now again get worse when it comes to iOS Safari, to which all the below observations apply:
    • The next fun thing you will discover, is that a PWA in iOS is almost entirely confined, sandboxed to its own environment. Unlike in Chrome and the like, one cannot access the PWA's service worker from a regular browser tab even if it is within scope. It would be useful to know when the user has deployed the PWA, to avoid that they deploy it twice, but only a few browsers currently have a half-assed implementation of getInstalledRelatedApps, and Safari is not one of them.
      Some things are cloned from the regular Safari browser environment into the PWA's sandboxed environment, but only at the moment the PWA is installed. Cookies in the PWA will be a clone of the cookies at the moment of PWA deployment, but will not be kept in sync with the regular Safari app afterwards!
      (I did not test whether LocalStorage or IndexedDB are cloned in a similar way, but I suspect they might be, you should verify this if it matters.)
    • Opening URLs from within the PWA (through links, in both Chrome and iOS Safari, or also clients.openWindow in iOS only) will show them inside an embedded browser thing inside the PWA, if they are not in the PWA's scope as defined in its manifest. (Do not confuse the PWA scope with the service worker scope, it is not the same.) If you forgot to properly define this scope, your PWA's UI pages may be replaced with other pages if the user follows links in the UI or opens a notification. Even if it is possible to navigate back to the UI page, at the least the user will be utterly confused and frustrated. You do not want this, hence make sure to properly set up the PWA's scope! Only pages related to the PWA's UI must be within the scope defined in the manifest.
    • However, in iOS Safari, there is a subtle discrepancy between hyperlinks followed from within the PWA pages, and pages opened through clients.openWindow() in the service worker, for instance inside the worker's notificationClick handler:
      1. Pages opened in the PWA's embedded browser as links from within the PWA itself, will have access to the PWA's service worker even if the page is not in the worker's scope. They can obtain the SW's registration by passing its scope as argument to getRegistration().
      2. Despite the fact that pages opened through clients.openWindow() open in the same PWA-embedded browser, they cannot obtain the service worker registration in the same way as described above. Why Apple, why? I expect that if the opened page is within the scope of the SW, it might have access to the SW, but I have not tested this, so don't trust my word for it.
  7. Also, (currently) FCM does not work in the Apple XCode iOS simulator.
    getToken() will fail with something like:
    FirebaseError: Messaging: A problem occurred while subscribing the user to FCM: Request contains an invalid argument. (messaging/token-subscribe-failed).
    It's probably because the fake device has weird deviating identifiers which confuse FCM. Perhaps there is a way to simulate a push to your service worker inside the simulator, but I have not found any obvious way. You can already test some things in the simulator, but for a whole end-to-end test, you will need a real iOS device.
  8. One cannot do invisible push messages in Safari. Do not try it: if your service worker does not show a notification within due time upon receiving a push, its notification privilege will be revoked. Maybe not immediately, but certainly after a few occurrences. Invoking showNotification must get the highest priority, do it as quickly as possible and before anything else that has any risk of failing. Also invoke it even if the incoming message is not as expected. It is arguably better to show a notification “something went wrong,” than ending up in the situation where all future notifications are silently broken because Safari decided to revoke the app's permissions.
    Even on other platforms this is the recommended approach, because they may also do things that confuse the user when a push does not result in a notification, for instance Chrome will show the cryptic message: “this site has been updated in the background.”
  9. Safari (both in iOS and MacOS) does not honour the standard of replacing existing notifications that have the same tag as the old one. Notifications will keep piling up.
    This would not be a big deal, if FCM would not have bugs that can cause each sent push message to consistently result in multiple pushes being received by the client (see above). And again, rocks and hard places: you cannot filter out these duplicate pushes, because your service worker must show a notification upon every received push, or its permissions will be revoked.
    You might think: let's check for existing notifications by using getNotifications, and invoke close() calls on them. Denied! First of all, only iOS 17 or newer allows to obtain the list of notifications, but it is of no use: iOS will not honour any close calls on the objects obtained as such. So, the only thing you can do, is apologise in advance to your iOS users about the fact that they are likely to start seeing duplicate push messages after a while. Isn't all this just grand? It's as if it has been designed by a total sadist.
    Some brave soul has opened a WebKit bug about this, but I can almost guarantee that unless there is a change in management at Apple, it will never be fixed. Allowing an app to perform close() on notifications, would allow to approximate invisible push messages, which as shown above, Apple considers the spawn of the devil.
  10. There is one upside about Safari: it does not need to be open for push messages to be displayed. But of course this also has downsides:
    • As mentioned above, mobile devices try to limit the runtime of service workers. Safari seems really stringent in this aspect when launching a worker in this ‘cold’ state to let it handle a push, and will stop it very quickly if there is no ongoing waitUntil anymore, so make sure you use this where needed.
    • On iOS, there is yet another magnificent Apple bug, present since 16.4 and still present in 17.4.1. If your PWA is not open at the time a push is received, and the user opens the notification, at first it will all seem fine: the PWA will be launched on-the-fly and the webpage linked from your notification will open in the PWA's embedded browser as expected.
      However, when the user then tries to go back to the PWA's main UI page by using the ‘Done’ button, they will get an empty blank page. The only way to fix this, is for the user to force close the whole PWA and re-launch it. There is no straightforward workaround, believe me, I have tried everything that did not require wrapping inordinate amounts of duct tape around the whole design.
      You can repair the PWA in the notificationclick handler by performing a clients.openWindow with its start page, but then you cannot open the page that was supposed to be opened by the notification, because by design it is forbidden to perform more than one openWindow call from a notificationclick handler. You would either need to somehow trigger opening the PWA page when ‘Done’ is pushed (if that is possible at all), or you could also fix the PWA page as described above, and then trigger a banner/toast in it, to prompt the user to open the page that was actually supposed to open. You cannot do this automatically with window.open(), because this also can only be performed ONCE from a user gesture handler.
      This is of course all a major hassle, and adds even more fragile logic to your whole app and degrades the user experience. Simpler is to advise your users to keep your app open at all times to avoid this bug, or when they do end up in this situation, explain them how they can perform the weird awkward gestures that allow to close apps.
      And again, don't expect this bug to get fixed within the rest of your lifetime. In fact nobody has filed this bug yet in WebKit AFAIK. If you feel lucky, you could help out poor developers like me by doing it, but make sure you have a clear-cut reproduction scenario handy.

Conclusion

If I have to condense all the above into one sentence, then it would be: “Web Push in its current state is an abomination, avoid it if you can.” I have littered this page with memes, which seems fitting because Web Push is sort of a meme in itself.

If you cannot avoid having to implement something using Web Push, then even though pretty much every aforementioned point is worth looking at, the most important take home messages are:

Turles

The sad thing is that it's not just Web Push which gives me an impression of being a hot mess. My general sentiment is that a lot of software nowadays is sliding down this slippery slope of becoming way too complicated, scattered across multiple vendors who have their own different interpretations of a standard. As a result, implementing something is like navigating a minefield in which the mines spontaneously change places at random moments. Nobody except an ever shrinking elite of geniuses has an over-arching vision, no mere mortal can explain all the intricacies of how certain projects work, or randomly fail to work. It's all frameworks stacked on top of frameworks—goddamn turtles all the way down.

Programming these days is all too often becoming like wizardry. Follow someone's magic book of magical formulas written in JSON and YAML, and utter some incantations in the latest trendy programming language. Once something nears maturity, deprecate it and replace it with something new that is again full of fresh bugs and mysteries. I am not surprised that nearly brand new airplanes basically fall apart mid-flight. It's not just Boeing, it's a sign of an underlying problem that permeates society as a whole. Instead of fixing a flat tyre, we reinvent the whole damn wheel every time and ignore all the associated costs of doing this.
Everything is full of bugs that are often hard to reproduce because they're caused by race conditions and async routines that are a total pain to debug. Often it are not the most correct tutorials and example code that are being replicated everywhere, it are the ones that were published first. And as someone who has done a master in A.I., if you believe A.I. will make it all easier and better, let me tell you that this is an utterly naïve idea, in fact things might get worse. We are making A.I. constructs spit out code that is generated using models trained on all this dodgy code written by humans who take shortcuts all the time. I already see this with ChatGPT and friends, it is very apt at producing total garbage with an air of utmost overconfidence, which I guess is why many are so impressed by it—it faithfully mimics typical human behaviour, but only superficially. It lacks the deeper understanding.

If you want to try out my attempt at producing a workable web push notification system, and read my own idiosyncratic web comic as a side effect, head over to S.O.N.A.I.S.

©2024/05-10 Alexander Thomas