Skip to content

fix(push): make iOS background delivery actually work - #728

Merged
agegr merged 3 commits into
agegr:mainfrom
nannant666:fix/ios-web-push-delivery
Sep 11, 2026
Merged

agegr merged 3 commits into
agegr:mainfrom
nannant666:fix/ios-web-push-delivery

Conversation

@nannant666

Copy link
Copy Markdown
Contributor

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.com endpoints) found three independent causes, all server/SW-side — the client subscription was never the problem:

1. Every send was rejected by Apple: 403 BadJwtToken

sendNotification used subject: "mailto:pi-web@localhost". Apple's web push service requires the VAPID subject to be a syntactically valid mailto: or https: URL; pi-web@localhost is 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:

A1 subject=mailto:pi-web@localhost  → ❌ 403 BadJwtToken
A2 subject=https://github.com/agegr/pi-web → ✅ 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.js skipped showNotification whenever 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-tag notifications 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 Urgency and no explicit TTL. Apple's docs: to attempt immediate delivery, specify Urgency: high; omitted defaults to normal, which iOS may defer to a later device-activation window. Sends now use urgency: "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 reuses setupPushSubscription() — 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 (pushsubscriptionchange is not fired).

Testing

  • node --test: push/sw suites 15/15 (updated SW tests assert the always-show behavior; new tests assert PUSH_OPTIONS immediacy and that the default subject is a valid URL that doesn't end in @localhost)
  • npm run lint clean, tsc --noEmit clean
  • Full suite: 931/932 — the single failure (lib/worktree.test.mjs "main and linked worktrees share one canonical project root") also fails on unmodified main in this environment (git-environment dependent), unrelated to this change
  • End-to-end on a production deployment (patched build of 0.8.11 + real iPhone home-screen PWA, iOS 18.x): server sends now return 201; lock-screen notifications arrive while the app is frozen in background; the settings control successfully (re)registers the subscription after a home-screen reinstall

Notes for reviewers

  • PI_WEB_PUSH_SUBJECT env var overrides the default subject for self-hosters who want their own contact URL.
  • Deliberately did not switch the payload to Declarative Web Push (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.

nannant666 and others added 3 commits September 5, 2026 23:27
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.
@agegr
agegr merged commit 3e9fcfa into agegr:main Sep 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants