Why Didn't My Web Push Notification Arrive?

A web push notification that never arrives almost always fails at one of four points: the browser never actually held permission, the subscription expired or was revoked, the device deliberately delayed or dropped the wake-up, or the push service rejected your send. Check them in that order. The first two are visible from the browser console in about thirty seconds, and between them they explain most missing notifications — long before you start reading server logs.

This is the debugging companion to how web push actually works. If the pieces are new to you, read that first; this page assumes you already have a service worker and a server that sends.

What has to be true for a push to arrive?

Four separate things must all hold at the same moment:

  1. The site holds Notification.permission === "granted" in that browser, on that device.
  2. A live PushSubscription exists, and your server holds the same endpoint the browser holds.
  3. Your server's request to the push service is accepted (a 201, not a 400, 404, 410, or 413).
  4. The device wakes the browser, the service worker runs, and its push handler calls showNotification().

Any one of those failing produces the same user-visible symptom: silence. That is why guessing is expensive and a fixed order is cheap.

Step 1 — is permission actually granted?

Open the site in the browser that is missing notifications and run this in the console:

console.log('permission:', Notification.permission);

const reg = await navigator.serviceWorker.getRegistration();
console.log('service worker:', reg && reg.active ? 'active' : 'MISSING');

const sub = reg && await reg.pushManager.getSubscription();
console.log('subscription:', sub ? sub.endpoint : 'NONE');

Three lines, three answers. permission: default means the user never chose — your prompt was dismissed, or never fired. permission: denied means the browser will not ask again; the user has to re-enable notifications in site settings, and no amount of JavaScript can reverse it.

Permission also resets quietly. Clearing site data, using a private window, or a browser's automatic cleanup of unused-site data all wipe it. A user who granted permission in March and cleared their browser in July is now default and has no idea.

The iPhone rule that catches everyone

On iOS and iPadOS, a website can only receive web push if the user has added it to the Home Screen first. In an ordinary Safari tab there is no push, no prompt, and no error worth reading — the API is simply not there. This is the single most common "it works on Android but not my iPhone" report, and it is not a bug in your code.

Feature-detect rather than assume, and tell iPhone users what to do instead of showing a prompt that cannot work:

const supported = 'serviceWorker' in navigator && 'PushManager' in window;
const standalone = window.matchMedia('(display-mode: standalone)').matches;

if (!supported && !standalone) {
  // Likely an iOS browser tab: show "Add to Home Screen" guidance, not a prompt.
}

Step 2 — does the subscription still exist?

A PushSubscription is not permanent. Browsers rotate and retire endpoints, and the subscription dies when site data is cleared, when the app is reinstalled, or sometimes on its own. When that happens the browser fires a pushsubscriptionchange event in the service worker — and if you do not handle it, you keep sending to an endpoint nobody is listening to.

self.addEventListener('pushsubscriptionchange', (event) => {
  event.waitUntil(
    self.registration.pushManager
      .subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY })
      .then((sub) => fetch('/api/push/subscribe', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(sub)
      }))
  );
});

The server half matters just as much. When a push service answers 404 or 410, that subscription is gone for good — delete the row. Retrying it forever is how a delivery pipeline slowly fills with dead endpoints and the real failures stop being visible:

// After POSTing to the push endpoint:
if ($code === 404 || $code === 410) {
    // Gone. Prune it — never retry.
    $db->prepare('DELETE FROM push_subscriptions WHERE endpoint = ?')->execute([$endpoint]);
}

A useful sanity check: count how many subscriptions you hold per user and compare it to how many devices they actually use. If one user has eleven subscriptions, you are not pruning, and most of your sends are going nowhere.

Step 3 — did the device decide to hold it?

This is the layer people forget, because nothing in your code is wrong. The push service accepted the message, the device received it, and the notification still appeared late or not at all.

  • Android battery optimisation. Doze and per-app restrictions batch background wake-ups. A phone in a pocket overnight may hold a low-urgency message until the next time the user picks it up.
  • Low Power Mode on iOS and battery saver on Android both reduce background activity.
  • Focus modes and Do Not Disturb deliver the notification silently. It is in the shade — it just never made a sound, so the user swears it never came.
  • Desktop browsers must be running. If the browser is fully quit, there is nothing to wake. Messages queue at the push service and land when it reopens, subject to their TTL.
  • Notification settings per site can be muted at the OS level even while the browser reports granted.

Two levers actually help here. Set a sensible TTL so a stale alert expires instead of arriving three hours late — an "is anyone awake?" ping is worthless tomorrow morning. And set urgency honestly: mark genuinely time-critical alerts high, and leave routine ones normal so the platform can batch them. Both are headers in the Web Push protocol, RFC 8030.

Step 4 — did your server actually send it?

The last suspect is the one that looks innocent: your own send path returning success while doing nothing useful. Common versions of this:

  • The push library is called inside a try block whose catch logs nothing.
  • The job that sends runs on a queue worker that has been dead since the last deploy.
  • The user has zero subscriptions, so the loop over their devices runs zero times and returns "sent".
  • VAPID keys were regenerated. Every existing subscription was signed against the old public key and now fails.
  • The payload exceeds the size the push service accepts (413) — encrypted payloads have a hard ceiling, so keep bodies short.

The fix is not cleverness, it is bookkeeping: record, per send, how many devices were targeted and what each push service answered. "Sent to 0 devices" and "sent to 3 devices, all 410" are completely different bugs, and a single boolean cannot tell them apart. If you are building this yourself, the developer docs show the shape of a send that is worth logging.

A quick triage table

SymptomMost likely causeFirst check
Nothing on iPhone, fine on AndroidSite not added to Home ScreenIs it running as an installed app?
Worked for months, then stoppedSubscription expired or site data clearedgetSubscription() in the console
Arrives hours lateDevice battery restrictionsSet a TTL and raise urgency
Some users get it, others never doDead endpoints never prunedLog the 404/410 responses
No prompt ever appearsPermission already deniedNotification.permission
All users stopped at onceVAPID keys changedCompare the deployed public key

How to stop guessing next time

Give yourself a one-tap test that goes through the real path — the same service worker, the same subscription, the same push service. Pingwire has a Send test notification button in account settings for exactly this reason: it proves the whole chain end to end, so when a real alert goes missing you already know whether the pipe works. If the test arrives and your alert did not, the bug is in what triggered the alert, not in push.

Second, prune on failure automatically rather than as a cleanup task you will never run. Every 404 and 410 should delete its row the moment it happens.

Third, treat permission as something that decays. Re-check it on load, and when a user who used to have push shows up as default, ask again in context rather than never. The same applies to the sign-up path — a subscribe flow that fails silently just looks like an unpopular feature, which is worth remembering if you run subscribe-by-scan QR channels where you never meet the user.

The underlying model is worth internalising: web push is a chain of four independent parties, and any of them can drop a message without telling the others. The MDN Push API reference is the standards-level detail. But in practice, checking permission, then subscription, then device behaviour, then your own send log will find it — usually at step one or two.

Frequently asked questions

Why do my web push notifications work on Android but not on iPhone?

On iOS and iPadOS, a website can only receive web push after the user adds it to the Home Screen. In a normal Safari tab the Push API is unavailable, so nothing is sent and no error is shown. Feature-detect and show Add to Home Screen guidance instead of a permission prompt.

Why did push notifications stop working after months of working fine?

The subscription almost certainly expired or was cleared. Browsers retire push endpoints, and clearing site data destroys the subscription. Handle the pushsubscriptionchange event in your service worker to re-subscribe, and delete any endpoint your push service answers with 404 or 410.

What does a 410 response from a push service mean?

410 Gone means that subscription no longer exists and will never work again. Delete it from your database immediately. Retrying it wastes requests and hides real failures behind a growing pile of dead endpoints. 404 should be treated the same way.

Why does my push notification arrive hours late?

The device held it. Android Doze, battery saver, and iOS Low Power Mode batch background wake-ups to save power. Set a TTL so stale alerts expire instead of arriving late, and mark genuinely urgent messages with high urgency so the platform delivers them promptly.

How do I tell whether the problem is my server or the browser?

Send a test notification through the same path a real alert uses. If the test arrives, the push chain is healthy and the bug is in whatever should have triggered your alert. If the test does not arrive, check Notification.permission and getSubscription() in the browser console before touching server code.

Can a notification be delivered but never seen?

Yes. Do Not Disturb and Focus modes deliver notifications silently to the notification shade, and per-site OS-level muting can silence a site even while the browser still reports permission as granted. Users reliably report these as missing notifications.

Try Pingwire

Send your first alert in under 30 seconds — one HTTP call, straight to a chat and your phone.

Create a free account Read the API docs

More from the blog