Web Push & Subscription Lifecycle: Engineering Reference
This reference maps the complete Web Push protocol stack end to end — from the signed HTTP request your application server emits to the showNotification() call that fires inside a background service worker. It is the entry point to every technical guide in this area and the conceptual baseline the Notification Engagement & Campaign Optimization guide builds engagement strategy on top of.
1. Web Push Protocol Stack & Architecture
The modern web push architecture operates across three distinct layers: the client browser, the vendor push service, and your application server. Communication flows over HTTP/2 to leverage multiplexed streams and header compression, governed by RFC 8030.
Your application server initiates delivery by sending a signed request to the browser vendor’s push service. The service validates the request, queues the payload subject to its TTL, and routes it to the target device. The same wire protocol is implemented by every engine, which is why a single dispatch pipeline can target Chromium, Firefox, and Safari simultaneously — but their delivery guarantees and rendering behaviors diverge enough that you should treat the cross-browser notification quirks as first-class engineering concerns rather than edge cases.
The browser receives the payload in the background via a registered service worker. The worker decrypts the message and triggers the Notification API. This decoupled pipeline ensures delivery even when your web application is closed or inactive. For a version-by-version capability map of which engines support which features, consult the browser compatibility reference before committing to any payload shape.
The separation of concerns is deliberate. Your application server never holds a persistent connection to the user’s device; the push service owns that connection and the message queue behind it. This is why a notification can arrive minutes after your server dispatched it, why TTL semantics matter, and why delivery is best-effort rather than guaranteed. Each layer has a distinct trust boundary: the push service can read routing metadata (the endpoint URL) but never the payload, because RFC 8291 encrypts the body end to end between your server and the browser. Understanding which layer owns which responsibility is the difference between debugging a delivery problem in minutes and chasing it across three systems for a day. When a message fails, the HTTP status the push service returns tells you exactly which boundary rejected it — a discipline formalized in the error taxonomy later in this reference.
2. Service Worker Registration & Lifecycle Management
Service workers act as the execution environment for push events. Registration must occur on a secure origin (https://) and within a defined scope. Improper scope configuration can isolate workers from intended routes, breaking push routing.
// Client-side: Registration & Permission Orchestration
async function initializePush(vapidPublicKey) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== 'granted') return;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
});
await sendSubscriptionToServer(subscription);
}
The worker progresses through a fixed lifecycle — installing, installed/waiting, activating, activated/controlling — and push events only route to an activated worker that controls the page. The scope passed at registration cannot be broader than the script’s own path, so a worker served from /sw.js can control the whole origin while one served from /app/sw.js cannot reach /. Misjudging this is the most common reason a subscription is created successfully but no push event ever fires.
Update cadence requires careful orchestration. Browsers check for new service worker scripts on navigation, but background updates rely on the 24-hour automatic check. A freshly fetched script enters the waiting state and does not take control until every controlled tab closes, which means a naive deploy can leave half your users running yesterday’s routing logic for days. Using skipWaiting() and clients.claim() enables immediate activation without stale caching, but invoking them without warning the user can reload a page mid-interaction. For detailed lifecycle orchestration, consult Service Worker Registration Patterns to avoid premature termination during long-running tasks, and the guide to handling service worker updates without breaking push when you ship a new worker to an installed base.
If pushManager.subscribe() rejects with a NotAllowedError, the cause is almost always permission or gesture state rather than your key — the dedicated walkthrough on why pushManager.subscribe fails with NotAllowedError covers each trigger and its fix.
3. VAPID Authentication & Key Management
Voluntary Application Server Identification (VAPID) replaces legacy GCM keys with a standardized JWT-based authentication layer defined in RFC 8292. The protocol binds your application identity to a cryptographic ECDSA P-256 key pair, preventing unauthorized push injection.
// Server-side: Node.js VAPID JWT Generation (ES256)
const webpush = require('web-push');
// Generate once and store securely:
// const keys = webpush.generateVAPIDKeys();
function configurePush() {
webpush.setVapidDetails(
'mailto:security@yourdomain.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
}
async function sendPushNotification(subscription, payload) {
return webpush.sendNotification(subscription, JSON.stringify(payload), {
TTL: 86400,
urgency: 'high'
});
}
Each push request carries an Authorization: WebPush <jwt> header whose JWT is signed with the private key and carries three claims: aud (the push service origin, derived from the endpoint), exp (an expiry no more than 24 hours out), and sub (a mailto: or https: contact the push service can reach if your traffic misbehaves). The push service verifies the signature against the public key the subscription was created with, which is why the public key is not a secret but is still load-bearing — change it and every existing subscription stops authenticating.
The VAPID public key must never appear hardcoded in server-side code or client bundles — always reference process.env.VAPID_PUBLIC_KEY and process.env.VAPID_PRIVATE_KEY and inject both from a secrets manager. Implement automated key rotation pipelines that gracefully migrate active subscriptions before revoking legacy keys, because hardcoded credentials expose your infrastructure to replay attacks and vendor revocation. Clock drift is a frequent and confusing failure mode here: if a dispatch node’s clock is more than five minutes off, the push service rejects the JWT as expired even though everything else is correct, so keep every node NTP-synced. For operational security workflows, review VAPID Key Generation & Rotation, and where you need to swap keys on a live install base see rotating VAPID keys without losing subscribers.
4. End-to-End Payload Encryption
Push payloads require end-to-end encryption before transmission. The browser generates an ephemeral P-256 key pair during subscription. The server performs an ECDH key exchange with the client’s public key to derive a shared secret, then encrypts the payload with AES-128-GCM per RFC 8291.
In practice, use an audited library to handle this correctly:
// Server-side: RFC 8291 aes128gcm encryption via web-push
const webpush = require('web-push');
async function encryptAndSend(subscription, payload) {
const options = {
vapidDetails: {
subject: 'mailto:ops@yourdomain.com',
publicKey: process.env.VAPID_PUBLIC_KEY,
privateKey: process.env.VAPID_PRIVATE_KEY
},
TTL: 86400
};
// web-push handles ECDH key exchange, HKDF derivation, and aes128gcm encryption
return webpush.sendNotification(subscription, JSON.stringify(payload), options);
}
The resulting ciphertext uses the aes128gcm content-encoding standard (RFC 8291), and the total encrypted record must stay within the push service’s 4 KB payload size limit. A fresh ephemeral server keypair is generated per message, which gives each notification forward secrecy: compromising one message’s keys reveals nothing about any other. The 16-byte auth secret from the subscription feeds HKDF-SHA-256 as salt, and the derived content-encryption key and nonce are never reused, so a correctly implemented sender never repeats a nonce under the same key — a property that matters because GCM catastrophically loses confidentiality on nonce reuse. Browsers automatically decrypt using their private key and the auth secret exchanged during subscription. Always validate and sanitize payload data before encryption to prevent injection vectors. For the full ECDH/HKDF/AES-GCM workflow see Push API Payload Encryption; when the browser throws on the receiving end, the aes128gcm encoding & decryption errors guide isolates each failure mode.
5. Cross-Browser Notification Quirks & Fallbacks
Vendor implementations diverge significantly in permission models, UI rendering, and delivery guarantees. Chromium relies on FCM, WebKit routes through APNs on Apple devices (iOS 16.4+ and macOS Ventura+ support Web Push natively), and Firefox maintains its own push infrastructure (Mozilla Autopush).
Safari enforces strict user-initiated prompts and limits background push delivery. Chromium caps encrypted payloads at 4 KB. Firefox implements its own push service distinct from FCM. These inconsistencies require graceful degradation strategies.
Always implement feature detection and fallback routing. When native push fails, queue messages for in-app delivery or email fallback. For platform-specific behavior matrices, see Cross-Browser Notification Quirks to architect resilient routing logic, and the focused diagnosis of why Chrome shows a blank push notification when a delivered message renders empty.
6. Subscription State Management & Renewal
Subscription endpoints are ephemeral. Browser updates, OS migrations, and privacy clearances trigger automatic endpoint rotation. Failing to capture these changes results in inflated bounce rates and silent delivery failures.
-- Data Layer: Relational Schema for Subscription Tracking
CREATE TABLE push_subscriptions (
id UUID PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
p256dh_key TEXT NOT NULL,
auth_secret TEXT NOT NULL,
user_id UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT NOW(),
last_renewed_at TIMESTAMP,
delivery_status VARCHAR(20) DEFAULT 'active',
consent_log JSONB -- GDPR/CCPA audit trail
);
The pushsubscriptionchange event fires automatically inside the service worker when the browser detects endpoint drift. Your service worker must capture this event, extract the new subscription, and synchronize it with your backend. Implement a state machine that retries synchronization on network failure and quarantines expired endpoints. When the push service returns 410 Gone, the endpoint is dead and must be purged; the retry logic & backoff strategies guide in the delivery area covers how to distinguish permanent failures from transient ones.
Treat the three fields endpoint, p256dh, and auth as an atomic unit — they are useless apart, and a partial write produces a record that authenticates but never decrypts. Store the consent metadata alongside them in the same transaction so an audit can always answer “when and how did this user opt in” without joining across systems. Bounce rate is the single most useful health metric for this table: a slow climb usually signals you are failing to capture pushsubscriptionchange, while a sudden spike usually means a VAPID public key changed underneath an installed base.
Delivery semantics: TTL and urgency
Two request headers shape what the push service does with a queued message. TTL (in seconds) tells the service how long to retain an undelivered message before discarding it: TTL: 0 means deliver-now-or-drop, suitable for ephemeral alerts, while TTL: 86400 asks the service to hold the message for a full day until the device reconnects. The Urgency header (very-low, low, normal, high) lets battery-conscious devices defer low-priority messages until the screen wakes. Choosing these deliberately is the boundary between this protocol reference and the delivery-side concerns covered in setting optimal TTL values for time-sensitive alerts.
7. Payload Size Constraints & Optimization
Network efficiency directly impacts delivery latency and mobile battery consumption. Push services impose strict limits on the encrypted ciphertext: the 4 KB payload size limit applies to the full aes128gcm record for Chrome, and Firefox is slightly stricter due to encoding differences. Exceeding these thresholds results in immediate message rejection with a 413 Payload Too Large response.
Minimize payloads by transmitting only identifiers and action metadata. Fetch heavy content client-side after the notification triggers. Chunking large datasets across multiple push events is unsupported; design payloads to be atomic and self-contained — the per-engine numbers live in maximum payload size limits for Chrome vs Firefox.
// Service worker: defensive push handler
self.addEventListener('push', (event) => {
const processPush = async () => {
const data = event.data?.json() ?? { title: 'Update', body: 'New notification' };
await self.registration.showNotification(data.title, {
body: data.body,
icon: '/icons/push-192.png',
tag: data.id || 'default',
requireInteraction: data.urgent ?? false
});
};
event.waitUntil(processPush());
});
Monitor delivery metrics using vendor dashboards and custom telemetry, and optimize retry logic to respect exponential backoff so you never amplify a transient outage into a thundering herd.
8. Error Taxonomy
Every layer of the stack surfaces failure as an HTTP status code from the push service. Treat these as a routing table: each status maps to a specific cause and a specific remediation, and conflating them is the most common reason delivery pipelines silently degrade.
| Status | Cause | Resolution |
|---|---|---|
400 Bad Request |
Malformed headers, missing TTL, or invalid encryption record framing |
Validate header set and aes128gcm record structure before dispatch |
401 Unauthorized |
Invalid, expired, or wrongly-signed VAPID JWT | Re-sign with the correct private key; check exp and NTP clock drift |
403 Forbidden |
Public key mismatch between subscription and JWT, or revoked endpoint | Confirm applicationServerKey matches the signing key; see VAPID rotation |
404 Not Found |
Endpoint URL no longer recognized by the push service | Treat as terminal; delete the subscription record |
410 Gone |
Subscription expired or user unsubscribed | Purge endpoint; rely on pushsubscriptionchange for renewal |
413 Payload Too Large |
Encrypted record exceeds the 4 KB limit | Minimize payload to identifiers; fetch content client-side |
429 Too Many Requests |
Per-origin rate limit exceeded at the push service | Honor Retry-After; apply exponential backoff |
9. Privacy-First Architecture & Standards Evolution
The W3C Web Push specification evolves alongside Privacy Sandbox initiatives that restrict cross-site tracking and limit persistent identifiers. Architect your push infrastructure to rely on first-party context and explicit user consent.
Enforce HTTPS-only contexts for all registration and delivery endpoints — service workers refuse to register on insecure origins, so this is enforced by the platform, but your delivery and subscription-sync endpoints must hold the same line. Implement strict Content-Security-Policy headers to restrict service worker script sources; a worker-src 'self' directive prevents an injected script from registering a rogue worker that could hijack push events. Maintain explicit opt-in logs — capturing timestamp, consent language version, and a hashed identifier — to satisfy GDPR and CCPA compliance requirements, and make sure an unsubscribe is logged with the same fidelity as the opt-in, because a data-subject access request will ask for both. Never place personally identifying data in the encrypted payload itself; send an opaque, revocable identifier and resolve it client-side after the notification opens, so a leaked endpoint never exposes user data. Monitor the Push API specification and browser release notes to plan ahead for deprecations and new capability additions, since engine behavior shifts release to release in ways the browser compatibility reference tracks.
Once the protocol layer is solid, the work shifts from “does it deliver” to “does it move the metric,” which is where the Notification Engagement & Campaign Optimization guide picks up: segmentation, send-time optimization, and click-through measurement all assume the reliable, privacy-respecting foundation this reference describes.
Related
- Cross-Browser Notification Quirks — engine-level divergences in rendering, permission, and delivery, with normalization patterns.
- Push API Payload Encryption — the RFC 8291 ECDH/HKDF/AES-128-GCM workflow and the 4 KB ceiling.
- Service Worker Registration Patterns — scope, lifecycle, and update orchestration for reliable wake-up.
- VAPID Key Generation & Rotation — ECDSA P-256 key lifecycle and zero-downtime rotation.
- Browser Compatibility Reference — the version-by-version capability matrix across all engines.
- Notification Engagement & Campaign Optimization — once delivery is solid, the engagement guide turns it into measurable outcomes.
Back to Web Push Notifications home
FAQ
Which RFCs define the Web Push protocol?
Three work together: RFC 8030 defines the HTTP-based push delivery protocol and the push service interaction, RFC 8291 defines message encryption (aes128gcm, ECDH over P-256, HKDF-SHA-256), and RFC 8292 defines VAPID, the voluntary application server identification scheme that signs delivery requests with an ECDSA P-256 JWT.
Do Chrome, Firefox, and Safari use the same push service?
No. Chromium-based browsers relay through Firebase Cloud Messaging (FCM), Firefox uses Mozilla Autopush, and Safari routes through Apple Push Notification service (APNs) on iOS 16.4+ and macOS Ventura+. They implement the same RFC 8030/8291 wire protocol, so one dispatch pipeline can target all three, but their delivery guarantees and rendering differ. The browser compatibility reference tracks the differences by version.
What is the maximum size of a web push payload?
The encrypted aes128gcm record must fit within roughly 4 KB. Chrome enforces the 4 KB ceiling; Firefox is slightly stricter because of encoding overhead. Exceeding it returns 413 Payload Too Large. Keep plaintext JSON under about 3 KB to leave room for the ~38 bytes of fixed encryption overhead plus padding — see maximum payload size limits for Chrome vs Firefox.
Why does a push arrive but show no notification?
Under userVisibleOnly: true, every push event must result in a visible notification or Chromium penalizes the origin. A blank notification usually means the payload was malformed, showNotification() threw, or the worker terminated before event.waitUntil() resolved. The walkthrough on why Chrome shows a blank push notification covers each root cause.
What happens to subscriptions when I rotate the VAPID key pair?
A subscription is cryptographically bound to the VAPID public key used at subscription time. Changing the public key invalidates existing subscriptions unless you keep serving the old public key during a transition window and re-subscribe users gradually. See rotating VAPID keys without losing subscribers for the zero-downtime procedure.
How should I handle a 410 Gone response?
Treat 410 Gone as terminal: the endpoint is dead and the subscription record should be purged immediately to keep bounce rates honest. Renewal happens client-side when the browser fires pushsubscriptionchange. Distinguish it from transient failures like 429 or 503, which should be retried with exponential backoff per the retry logic & backoff strategies guide.