Web push errors, and what to do about each
Every status a web push service returns, from 201 to 410 Gone, 403 for VAPID key problems, 413 payload too large and 429, with what to delete, fix or retry.
A web push goes from your server to a push service run by the browser maker: FCM for Chrome, Edge and other Chromium browsers, Mozilla’s autopush for Firefox, Apple’s service for Safari. The URL in the subscription’s endpoint tells you which. They all follow the same protocol (RFC 8030, with VAPID in RFC 8292), so they answer with the same status codes. Here is each one and what to do.
Every web push status code
| Status | Meaning | What to do | Retry |
|---|---|---|---|
201 Created |
Accepted by the push service | Nothing. It is not proof of display | |
400 Bad Request |
A header is wrong: TTL missing, a bad Urgency, a Topic over 32 characters, bad encryption headers |
Fix the request | No |
401 Unauthorized |
No VAPID Authorization header |
Sign every request | No |
403 Forbidden |
VAPID is invalid: wrong key pair, bad signature, exp out of range, wrong aud |
See below | No |
404 Not Found |
The subscription has expired | Delete the subscription | No |
410 Gone |
The subscription was removed (unsubscribed, permission revoked) | Delete the subscription | No |
413 Payload Too Large |
The encrypted body is over the service’s limit | Send less | No |
429 Too Many Requests |
Rate limited | Wait for Retry-After, then retry |
Yes |
5xx |
The push service failed | Back off and retry | Yes |
404 and 410: delete the subscription
The RFC uses 404 for an expired subscription; in practice push services also answer 410 when the user unsubscribed or blocked notifications. Treat both the same way: remove the row and stop sending. A sender that keeps posting to dead endpoints wastes requests and, at volume, gets rate limited.
const res = await webpush.sendNotification(sub, payload).catch((e) => e);
if (res.statusCode === 404 || res.statusCode === 410) await db.subscriptions.delete(sub.endpoint);
To see a 410 yourself, call registration.pushManager.getSubscription() in the browser, then subscription.unsubscribe(), and send to the old endpoint.
403 Forbidden: VAPID problems
A subscription is tied to the public key the page passed as applicationServerKey in pushManager.subscribe(). Every push to it must be signed with the matching private key. The usual ways this breaks:
- The keys changed. Someone ran
web-push generate-vapid-keysagain, or staging and production have different keys and share a database. Every old subscription now returns 403. Keep one key pair per site for ever, or resubscribe every user. - The JWT’s
expis wrong. It must be in the future and at most 24 hours ahead. A server clock that is off, or anexpin milliseconds instead of seconds, fails. audis wrong. It must be the origin of the endpoint, such ashttps://fcm.googleapis.com, not your site and not the full endpoint URL.subis missing or malformed. It must be amailto:address or anhttps:URL. Apple’s service is strict about it.
The header looks like this in the current scheme:
Authorization: vapid t=<JWT signed ES256>, k=<your public key, base64url>
TTL: 86400
Content-Encoding: aes128gcm
400: the headers
TTLis required. It is how many seconds the service keeps the message for an offline device;0means deliver now or drop. Services may shorten it and tell you in the response’sTTLheader.Urgencyisvery-low,low,normalorhigh. Usehighfor messages the user is waiting for.Topiclets a newer message replace a waiting one with the same topic. At most 32 URL-safe base64 characters.- The body must be encrypted with the subscription’s
p256dhandauthkeys (aes128gcm). Sending plain JSON fails.
413: payload size
Push services must accept an encrypted body of at least 4,096 bytes. Encryption adds a header and padding, so keep your JSON under about 3,900 bytes. For more, send an id and let the service worker fetch the rest:
self.addEventListener("push", (e) => {
const { id, title } = e.data.json();
e.waitUntil(self.registration.showNotification(title, { data: { id } }));
});
201, and still nothing on screen
The push service accepted it; the rest happens in the browser:
- The service worker has no
pushlistener, or does not callshowNotification()insideevent.waitUntil(). Chrome then shows its own “This site has been updated in the background” notice. - The browser was closed past the
TTL, or the computer was asleep. - On iPhone and iPad, web push only works for a site added to the Home Screen. See web push on iOS.
- The user turned notifications off for the browser in the operating system’s settings, or Focus or Do Not Disturb is on.
With SignOps
SignOps Push makes the VAPID keys for your app and keeps them, so they never change under your subscriptions. It encrypts each message, signs it per push service, removes a subscription on 404 or 410 and posts device.removed to your webhook, retries 429 and 5xx, and records any other answer as the push’s failure reason in the dashboard. Setup is in the web push guide, and the web push tester sends a push to this browser with no account.
Sources: IETF RFC 8030: Generic Event Delivery Using HTTP Push, IETF RFC 8292: VAPID, web.dev: The Web Push Protocol.