Repository navigation
fix(push): make iOS background delivery actually work - #728
Merged
Merged
Conversation
Delivery investigation on a real iOS home-screen web app showed that background pushes never arrived while the same subscriptions worked once the VAPID subject was corrected. Three independent causes, all fixed here: 1. Invalid VAPID subject: Apple's push service rejects `mailto:pi-web@localhost` with 403 BadJwtToken, so every send failed before leaving the server. The subject now defaults to the project homepage (a valid https URL) and can be overridden with PI_WEB_PUSH_SUBJECT. 2. Subscription poisoning via suppressed notifications: the service worker skipped showNotification whenever an app window was visible. iOS revokes the push subscription when a push is handled without a notification, so every foreground-observed completion silently poisoned the subscription until background delivery stopped entirely (servers keep getting 201 from APNs, so nothing visible in logs). The SW now always shows the notification; same-tag notifications replace each other, so a visible window just sees it re-appear. 3. Deferred delivery: sends had no Urgency/TTL, so iOS may defer them to an arbitrary later wake-up window. Sends now use urgency=high with a 4-week TTL. Also adds a "register push" control to Settings → General. On iOS the permission prompt must run inside a user gesture and cannot be re-triggered after a home-screen re-install, and the previous code only subscribed automatically after a task completion, which could leave a freshly installed home-screen app without any subscription path. The button reuses setupPushSubscription() and lives next to the language section. Verified: node --test (push/sw suites 15/15), eslint, tsc --noEmit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On a real iOS home-screen web app (iOS 16.4+ PWA), background web pushes never arrived: notifications only appeared while the app had just been backgrounded, and stopped completely after a while.
Investigation on production subscriptions (real
web.push.apple.comendpoints) found three independent causes, all server/SW-side — the client subscription was never the problem:1. Every send was rejected by Apple: 403 BadJwtToken
sendNotificationusedsubject: "mailto:pi-web@localhost". Apple's web push service requires the VAPID subject to be a syntactically validmailto:orhttps:URL;pi-web@localhostis not resolvable/valid and Apple answers 403{"reason":"BadJwtToken"}for every request. Same subscription, same keys — changing only the subject to a valid HTTPS URL flips the response from 403 to 201:So background delivery never worked at all; users only ever saw the in-page
new Notification()path (which works while the page is alive, masking the breakage).2. The SW suppressed notifications for visible windows → iOS revokes the subscription
sw.jsskippedshowNotificationwhenever any app window was visible ("in-page path handles it"). Per Apple's docs and WebKit's Declarative Web Push post, handling a push without showing a notification revokes the push subscription — "for any reason", including "situations that are not the script author's fault".Consequence: every session completion that the user watched in the foreground poisoned the subscription a little, until background delivery died entirely. This matches the classic reports ("works for a bit, then stops; opening the PWA floods queued notifications"). Worse, iOS does not fire
pushsubscriptionchange, and APNs keeps returning 201 for revoked endpoints (see WebKit bug 319865), so the server prunes nothing and sees no error.The SW now always shows the notification. Same-
tagnotifications replace each other instead of stacking, so a visible window simply sees the completion notification re-appear — no spam, no poisoned subscription.3. No Urgency/TTL: iOS may defer delivery arbitrarily
Sends carried no
Urgencyand no explicit TTL. Apple's docs: to attempt immediate delivery, specifyUrgency: high; omitted defaults tonormal, which iOS may defer to a later device-activation window. Sends now useurgency: "high"+TTL: 2419200(4 weeks, the web-push default made explicit).Also: manual registration in Settings → General
iOS requires the
Notification.requestPermission()call to happen inside a user gesture, and after a home-screen re-install the old code had no path to re-subscribe (the automatic trigger only ran after a task completion, and never surfaced the permission prompt itself). This adds a small "register push" control to Settings → General (next to the language section) that reusessetupPushSubscription()— it re-triggers the permission prompt when needed and re-registers the subscription with the server. This also doubles as a self-repair path for silently-revoked subscriptions, which iOS never signals (pushsubscriptionchangeis not fired).Testing
node --test: push/sw suites 15/15 (updated SW tests assert the always-show behavior; new tests assertPUSH_OPTIONSimmediacy and that the default subject is a valid URL that doesn't end in@localhost)npm run lintclean,tsc --noEmitcleanlib/worktree.test.mjs"main and linked worktrees share one canonical project root") also fails on unmodifiedmainin this environment (git-environment dependent), unrelated to this changeNotes for reviewers
PI_WEB_PUSH_SUBJECTenv var overrides the default subject for self-hosters who want their own contact URL.iOS 18.4+): it is a larger format change and the SW path works once the subscription isn't poisoned — happy to follow up if desired.