Web Push Browser Compatibility Reference
This reference maps Web Push support across Chromium (Chrome, Edge), Gecko (Firefox), and WebKit (Safari) so you can decide which features are safe to ship and which need feature detection. It covers minimum versions, per-feature support, the aes128gcm payload contract, and the vendor caveats that break delivery in production.
Web Push is a standard, but a fragmented one. The transport (RFC 8030), encryption (RFC 8291), and VAPID authentication (RFC 8292) are stable, yet each vendor layers its own permission model, payload validation, and UI rendering on top. The matrices below treat the spec as the baseline and call out where each engine diverges.
How Web Push support breaks down by vendor
Three independent push services back the four major browsers. There is no shared infrastructure: a subscription endpoint is opaque, vendor-specific, and bound to both the browser and the applicationServerKey it was created with.
- Chrome and Edge (Chromium) route through Firebase Cloud Messaging (FCM). They share the same engine, so feature parity is near-total. Edge inherits Chromium behavior including the 4 KB ciphertext ceiling.
- Firefox (Gecko) uses Mozilla’s autopush service, entirely independent of FCM. Its payload validation is stricter and its feature timeline differs from Chromium’s.
- Safari (WebKit) routes through Apple Push Notification service (APNs). It is the most constrained: Web Push arrived only in Safari 16 on macOS Ventura and Safari 16.4 on iOS/iPadOS, and on iOS it requires the site to be installed to the Home Screen as a PWA.
Because endpoints are opaque, your server treats all three identically at send time — the same signed request with aes128gcm encryption — and the vendor differences surface as different status codes, size limits, and rendering quirks. The cross-browser notification quirks reference covers the runtime rendering divergences; this page focuses on the version and feature matrix you need before writing a line of code.
Version support matrix
The minimum versions below are the first stable releases where the Push API, Notifications API, and service worker push delivery worked together end to end. Modern evergreen browsers are far past these, but the iOS constraints remain the dominant gotcha.
| Browser / Engine | Push API support since | Push service | Platform constraints |
|---|---|---|---|
| Chrome (Chromium) | Chrome 42 (2015), VAPID since Chrome 52 | FCM | Desktop + Android; no iOS engine (uses WebKit there) |
| Edge (Chromium) | Edge 17 (2018, Chromium-based) | FCM | Desktop + Android; matches Chrome behavior |
| Firefox (Gecko) | Firefox 44 (2016) | Mozilla autopush | Desktop + Android; not available on iOS |
| Safari (WebKit) — macOS | Safari 16 / macOS Ventura 13.0 (2022) | APNs | macOS only; standard Web Push API |
| Safari (WebKit) — iOS/iPadOS | Safari 16.4 / iOS 16.4 (2023) | APNs | Requires install to Home Screen (PWA); no push from a normal tab |
| Opera / Samsung Internet | Tracks Chromium baseline | FCM | Inherit Chromium feature set and limits |
On iOS, a regular Safari tab cannot subscribe at all. navigator.serviceWorker exists, but Notification.requestPermission() and pushManager.subscribe() only function once the user adds the site to the Home Screen and launches it in standalone mode. Detecting window.navigator.standalone (or the display-mode: standalone media query) before offering a prompt avoids a guaranteed failure. This interacts directly with service worker registration patterns, since the worker must already be registered inside the installed PWA context.
Feature support matrix
The transport and encryption are uniform, but the Notification rendering options diverge sharply. Use this table to decide what to send and what to feature-detect at display time inside the service worker.
| Feature | Chrome / Edge | Firefox | Safari (macOS) | Safari (iOS PWA) |
|---|---|---|---|---|
| Push API + service worker push | Yes | Yes | Yes | Yes (installed PWA only) |
| VAPID (RFC 8292) auth | Yes | Yes | Yes | Yes |
aes128gcm content-encoding (RFC 8291) |
Yes | Yes | Yes | Yes |
| Max encrypted payload | ~4 KB (4096 B) | ~4 KB (stricter validation) | ~4 KB | ~4 KB |
Notification actions (buttons) |
Yes | Firefox 152+ (2025) | No | No |
image (large hero image) |
Yes | No | No | No |
badge (monochrome status icon) |
Yes (Android) | Partial | No | App icon badge only |
requireInteraction |
Yes | Yes | Ignored (system-managed) | Ignored |
silent notification |
Yes (budget-limited) | Yes | No | No |
Declarative Web Push (Notification from header) |
Experimental / origin-trial era | No | Safari 18.x adopting | Safari 18.x adopting |
Silent push (userVisibleOnly: false) |
Restricted (push budget) | Restricted | No | No |
Two rows deserve emphasis. First, actions are not portable: Safari ignores them entirely and Firefox only added them in version 152, so any flow that depends on a button must degrade to a plain body and a notificationclick default action — the version thresholds and the runtime Notification.maxActions probe are broken down in which browsers support web push notification actions. Second, silent push is effectively unavailable in the cross-browser sense — every vendor expects a user-visible notification per push, and Chromium enforces this through a push budget that can suppress notifications if you abuse userVisibleOnly: false.
Declarative Web Push
Declarative Web Push lets the push service render a notification from a JSON Notification field in the message without waking your service worker’s push handler. WebKit has driven this proposal and Safari 18.x began adopting it; Chromium experimented with it during an origin-trial period. It is not yet a safe baseline — always ship a push event handler that calls showNotification() as the portable path, and treat declarative rendering as progressive enhancement.
Per-vendor caveats
Chrome and Edge (Chromium / FCM)
Chromium is the most permissive engine but enforces a 4 KB ciphertext limit and a notification-display budget. If you repeatedly send pushes without showing a user-visible notification, the browser revokes your ability to send silent pushes for that origin. Subscriptions can also be revoked when the user clears site data, returning 410 Gone on the next send. Edge behaves identically because it is Chromium under the hood.
Firefox (Gecko / Mozilla autopush)
Firefox validates payloads more strictly than Chromium and historically rejected malformed aes128gcm records that Chrome tolerated. It added notification actions in Firefox 152 (2025); earlier versions silently drop the actions array rather than erroring. Firefox does not render the large image option, so a media-rich design must fall back to icon only.
Safari and WebKit (APNs)
Safari is the strictest target. On iOS the site must be installed as a PWA before any push subscription is possible, and the permission prompt requires a genuine user gesture. Safari ignores requireInteraction, silent, image, and actions, and the system fully manages dismissal. Because the relay is APNs, the same opaque-endpoint model applies, but Apple historically enforced shorter title rendering and tighter UI than Chromium. Always test on a physical device — the iOS Simulator does not deliver real pushes.
A subtle Safari trait is that the standards-based Web Push API on macOS Ventura and iOS 16.4+ is distinct from the older Safari Push Notifications mechanism that used Apple Developer certificates and a pushPackage. The modern path uses VAPID and standard pushManager.subscribe() exactly like Chromium and Firefox, so you do not need an Apple Developer account for it. If you find older documentation referencing certificate-based safari.pushNotification, it predates the standards alignment and does not apply to the Web Push covered in this matrix.
Reading status codes per vendor
Because the three push services validate independently, the same client bug surfaces as different HTTP responses. A 410 Gone means the subscription is dead and should be deleted regardless of vendor. A 404 Not Found is also a permanently invalid endpoint. A 413 Payload Too Large means you exceeded the 4 KB ciphertext ceiling — Firefox tends to return this sooner than Chromium because of its stricter validation. A 429 Too Many Requests is rate limiting from the push service, and a 403 Forbidden almost always means a VAPID signature mismatch — typically a key that no longer matches the subscription’s applicationServerKey. Treating these uniformly in your sender, with 410/404 triggering subscription cleanup, keeps your subscriber list healthy across all three vendors.
Feature detection and verification
Never assume support from a user-agent string alone for capability gating; detect the actual APIs and, for display-time options, probe what the Notification surface accepts. The snippet below gates subscription on real support, including the iOS standalone requirement.
// Client-side capability gate before offering a push opt-in
function getPushSupport() {
const hasSW = 'serviceWorker' in navigator;
const hasPush = 'PushManager' in window;
const hasNotification = 'Notification' in window;
// iOS only allows push from an installed (standalone) PWA
const isIOS = /iP(hone|ad|od)/.test(navigator.platform) ||
(navigator.userAgent.includes('Mac') && 'ontouchend' in document);
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
return {
supported: hasSW && hasPush && hasNotification && (!isIOS || isStandalone),
needsInstall: isIOS && !isStandalone,
};
}
// Detect which NotificationOptions the current engine honors
function supportsActions() {
try {
// maxActions is 0 on engines without action-button support
return 'maxActions' in Notification && Notification.maxActions > 0;
} catch {
return false;
}
}
const support = getPushSupport();
if (support.needsInstall) {
// Prompt the user to "Add to Home Screen" instead of requesting permission
} else if (support.supported) {
// Safe to call Notification.requestPermission() on a user gesture
}
On the server, the request is identical across vendors: a VAPID-signed aes128gcm payload under 4 KB. Always source the keys from the environment — never hardcode the VAPID public key server-side. See VAPID key generation and rotation for the full key lifecycle.
// Server-side: one signed request works for all three push services
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:ops@yourdomain.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
async function send(subscription, payload) {
const body = JSON.stringify(payload);
if (Buffer.byteLength(body) > 3500) {
// Leave headroom under the 4 KB ciphertext ceiling after encryption overhead
throw new Error('Payload too large; send an identifier and fetch the rest client-side');
}
// web-push performs ECDH + HKDF + aes128gcm encryption per RFC 8291
return webpush.sendNotification(subscription, body, { TTL: 86400 });
}
The 4 KB figure is the encrypted ciphertext limit, not your JSON size — RFC 8291 encryption adds overhead, so budgeting roughly 3.5 KB of plaintext is the safe rule. The mechanics of that encoding and how to debug it live in the Push API payload encryption reference.
A portable design baseline
Given the matrix above, a payload and handler design that works everywhere narrows to a small, conservative core. Send a title and body, a single same-origin icon at 192×192, an optional badge for Android, and a tag for coalescing. Treat actions, image, requireInteraction, and silent as enhancements applied only when feature detection confirms support, and never depend on silent push or declarative push as a baseline. On the client, gate the opt-in behind real capability detection and, on iOS, behind standalone-mode detection. On the server, enforce the 4 KB ceiling at send time, source VAPID keys from the environment, and treat 410/404 as cleanup signals. This baseline is dull by design — it is the intersection of what Chrome, Edge, Firefox, and Safari all honor, and it is what keeps a single send pipeline reaching every supported browser without per-vendor branching at the transport layer.
Capability detection across embedded and legacy contexts
The matrices above assume a first-class browser window. A large share of real mobile traffic is not one. In-app browsers — the web views embedded in social, messaging, and email apps — render with the platform engine but run under a different set of rules, and they are where push opt-ins quietly evaporate without ever registering as a denial.
Android WebView has no Push API at all: PushManager is absent from window, so an honest capability gate simply does not render the opt-in. Chrome Custom Tabs are a genuine Chrome instance and do support push, but they share the origin’s storage and permission state with standalone Chrome, so a subscription created inside an app’s custom tab reappears in the user’s normal browser — surprising for teams that assume the two are separate profiles, and a common source of “one person, one endpoint, two entry points” confusion in the subscriber table. On iOS every browser and embedded view is WebKit, and SFSafariViewController-style views cannot subscribe under any circumstances; worse, an embedded view cannot install the site to the Home Screen, so the one remedy iOS offers is unreachable from inside it. The only correct response is to detect the embedded context, suppress the prompt, and offer an “open in your browser” affordance instead.
This is exactly why the gate must be built from capability probes rather than from the user-agent string. Every engine that matters misrepresents itself in the UA: Edge advertises both Chrome and Safari, Samsung Internet advertises Chrome, Chrome on iOS advertises CriOS while running WebKit, and the wv token that once marked an Android WebView is inconsistently present. Privacy features freeze, truncate, or randomise the string outright, and navigator.userAgentData is a Chromium-only API — so the one structured alternative is unavailable in precisely the engines you most need to distinguish. A probe, by contrast, is a direct question to the runtime you are actually executing in.
| Probe | What it proves | What it does not prove |
|---|---|---|
'serviceWorker' in navigator |
The registration API exists on a secure origin | That registration will succeed at your scope |
'PushManager' in window |
The Push API is compiled in | That subscribe() will be permitted |
Notification.permission |
The current origin-scoped grant state | That a prompt is still available to you |
Notification.maxActions > 0 |
The engine renders action buttons | How many the platform will actually display |
matchMedia('(display-mode: standalone)') |
The page runs as an installed app | That the user installed it deliberately |
navigator.permissions.query({name:'notifications'}) |
Observable permission state, with change events | Any ability to re-open a dismissed dialog |
The legacy engine floor
Below the versions in the table, push either does not exist or works through a mechanism that has since been removed. Chrome 42 through 51 could subscribe only with an FCM sender ID declared as gcm_sender_id in the web app manifest; VAPID arrived in Chrome 52 and the sender-ID path is long gone, so those builds should be treated as unsupported rather than as a compatibility target. EdgeHTML-era Edge implemented an incomplete push surface against Microsoft’s own notification service and is out of support entirely; every Edge build you will meet in traffic today is Chromium. Firefox below 44 has no Push API. There is no polyfill for any of this — push requires a browser-resident service worker and a vendor relay, neither of which JavaScript can synthesise — so the correct handling of an old engine is a clean, silent fallback to email or in-app messaging rather than a degraded prompt.
Verifying support on a real device
Every claim in this reference is checkable in about a minute per browser. Run the probe first, because it costs nothing and it catches the majority of surprises:
// Paste into the console of the target browser for a one-line support report.
console.table({
serviceWorker: 'serviceWorker' in navigator,
pushManager: 'PushManager' in window,
notification: 'Notification' in window,
permission: window.Notification?.permission ?? 'n/a',
maxActions: window.Notification?.maxActions ?? 0,
standalone: matchMedia('(display-mode: standalone)').matches
});
On Chrome or Edge desktop every boolean is true and maxActions reports 2. On Firefox before 152, maxActions reports 0 while everything else is true. On a Safari tab on iOS the first three are true, standalone is false, and a subscribe() call still fails — which is the whole reason the standalone check exists. Inside an Android WebView, pushManager is false.
Next, confirm which service is actually behind a live subscription. The endpoint host is the ground truth, and it is the fastest way to catch a test that is silently running against the wrong profile:
# Print the push service host for a subscription endpoint you captured client-side.
node -e 'console.log(new URL(process.argv[1]).host)' "$PUSH_ENDPOINT"
Expect exactly one of three answers:
fcm.googleapis.com # Chrome, Edge, Opera, Samsung Internet
updates.push.services.mozilla.com # Firefox
web.push.apple.com # Safari on macOS, installed iOS PWA
Then exercise delivery from the browser’s own tooling. Chrome’s DevTools Application → Service Workers pane has a Push button that dispatches a synthetic payload straight to your push handler, which isolates handler bugs from transport bugs. Firefox exposes the registered worker at about:debugging#/runtime/this-firefox, where Inspect opens a console scoped to the worker and a Push button does the same job. Safari on macOS needs the Develop menu enabled, then Develop → Service Workers to attach an inspector. For iOS you must use a physical device with Settings → Safari → Advanced → Web Inspector switched on, attached to a Mac — the simulator never delivers a real push record. Verify on the installed Home Screen app, not the tab, or you will be testing the one iOS context that cannot subscribe.
Divergence matrix: what breaks and where
The failures below are the ones that survive a green feature matrix, because in almost every case the unsupported option is discarded silently rather than reported.
| Symptom | Engines affected | Cause | Fix |
|---|---|---|---|
| Action buttons never render | Safari (all), Firefox < 152 | actions array dropped without error |
Probe Notification.maxActions; always ship a default click target |
| Hero image missing | Firefox, Safari | image unsupported |
Carry the visual meaning in title/body; keep icon at 192×192 |
| Notification vanishes too soon | Safari | requireInteraction ignored, system-managed dismissal |
Never depend on persistence to convey the message |
subscribe() rejects on an iPhone |
Safari iOS tab | Not an installed PWA | Detect display-mode: standalone and prompt for install instead |
| Badge count never updates | Desktop Chromium, Firefox, Safari | badge is an Android surface |
Treat badging as an Android-only enhancement |
413 Payload Too Large on Firefox only |
Firefox | Stricter record validation at the same ceiling | Budget ~3.5 KB of plaintext; see maximum payload size limits |
| Sends stop working after the user clears site data | All | Subscription revoked; next send returns 410 Gone |
Delete the row on 410 and stop sending |
| Works in Chrome, silent in an app’s in-app browser | Android WebView | No Push API in that context | Hide the opt-in; offer “open in browser” |
| One person appears twice in the subscriber table | Chromium + custom tab, or two profiles | Endpoints are per browser profile | Reconcile on identity, per deduplicating subscriptions across devices |
Two of these are worth alerting on rather than debugging after the fact. Track the subscribe() failure rate segmented by engine — a single-engine spike almost always means a version rolled out with a behaviour change, not that your code regressed everywhere. And track the distribution of endpoint hosts in new subscriptions: a sudden shift in the mix is the earliest signal that a capability gate has started rejecting a whole engine, long before the effect is visible in campaign metrics.
FAQ
Does Web Push work in Safari on iPhone?
Yes, from iOS 16.4 onward, but only after the user adds the site to the Home Screen and opens it as an installed PWA. A regular Safari tab on iOS cannot subscribe to push. Detect standalone mode before offering an opt-in.
Is the 4 KB payload limit the same in every browser?
The ~4 KB ceiling applies to the encrypted aes128gcm ciphertext and is effectively uniform across Chrome, Edge, Firefox, and Safari, though Firefox validates more strictly. Because encryption adds overhead, keep plaintext JSON under roughly 3.5 KB.
Do notification action buttons work cross-browser?
No. Chromium has supported actions for years, Firefox added them in version 152 (2025), and Safari ignores them entirely. Always provide a default notificationclick action and treat buttons as progressive enhancement.
Can I send a silent push that shows no notification?
Not reliably. Chromium permits userVisibleOnly: false only within a push budget and will suppress your pushes if you abuse it; Firefox restricts it; Safari does not support it. Design for a visible notification on every push.
Does VAPID work the same way in all browsers?
Yes. VAPID (RFC 8292) authentication is supported by Chrome, Edge, Firefox, and Safari, and a single signed request reaches all three push services. The applicationServerKey used at subscribe time is bound to the resulting subscription across every vendor.
Related
- Push API Payload Encryption — the
aes128gcm/RFC 8291 encoding behind the 4 KB ciphertext limit in this matrix. - VAPID Key Generation & Rotation — how the VAPID auth supported by every vendor is generated and rotated.
- Service Worker Registration Patterns — registering the worker that makes push delivery possible, including the iOS PWA case.
- Cross-Browser Notification Quirks — the runtime rendering and lifecycle divergences behind the feature matrix.
- Which browsers support web push notification actions — per-engine action-button support and the
maxActionsruntime probe.