Declarative Web Push vs the Service Worker Push Event

Safari can render a notification straight from a JSON payload without ever starting your service worker. The classic push event still exists, still works, and still has capabilities the declarative path cannot reach — so the real question is not which one to pick, but how to send both.

The comparison at a glance

Axis Declarative Web Push Service worker push event
Who renders the notification The browser, from the decrypted JSON Your push handler, via showNotification()
Service worker started No Yes, cold-started for every message
Availability Safari 18.4 and later on Apple platforms Every engine that supports Web Push
Payload JSON with a top-level web_push member set to 8030 Anything you can parse; usually JSON
Shown if your code is broken Yes — the browser owns display No — a throwing handler shows a generic system notification or nothing useful
Per-user templating at display time No, the copy must be final when you send Yes, the worker can rewrite anything
Display-time analytics Not available — no event fires Available — log inside the push handler
Client-side logic (dedupe, quiet hours, caps) Not available Available
Badge app_badge member, set declaratively navigator.setAppBadge() from the worker
Click destination navigate URL in the payload notificationclick plus clients.openWindow()
Battery and latency Lower — no worker boot Higher — worker boot per message

The short version: declarative buys reliability and removes a cold start; the service worker buys logic and instrumentation. Everything below is the detail behind those two sentences, in the context of the wider Safari and iOS Web Push guide.

Two paths from one encrypted record

Both paths start identically. Your server encrypts a payload with aes128gcm per RFC 8291, keeps the record under the 4 KB ceiling, signs a VAPID JWT, and POSTs to the subscription’s web.push.apple.com endpoint. Nothing about the transport changes. The divergence happens after the device decrypts the record and looks at the plaintext.

Declarative and service worker rendering paths A push arriving from web.push.apple.com either takes the declarative path, where the payload is parsed as JSON and the system renders the notification without starting a worker, or the classic path, where the worker is cold started, the push event is dispatched and your code calls showNotification. Push arrives, decrypted from web.push.apple.com Declarative Web Push Plaintext parsed as JSON web_push member equals 8030 System renders the card worker never starts Tap follows the navigate URL app_badge applied from the payload Service worker push Worker cold-started push event dispatched Your code decides the copy showNotification() is mandatory Log a display event then handle notificationclick Safari takes the right-hand path whenever the plaintext is not valid declarative JSON.
One signed, encrypted request; two possible rendering paths on the device, chosen by the shape of the decrypted plaintext.

Because the choice is made from the payload rather than from a subscription flag, you do not need separate subscriptions, separate keys or separate endpoints. The same stored endpoint, p256dh and auth triple serves both, and the same encryption pipeline described in Push API payload encryption applies unchanged.

The declarative payload shape

The plaintext must be a JSON object with a top-level web_push member whose value is the number 8030 — a nod to the RFC that defines the transport, and a version marker that lets the format evolve. Alongside it sits a notification object holding the rendered content.

{
  "web_push": 8030,
  "notification": {
    "title": "Invoice 4021 was paid",
    "lang": "en-GB",
    "dir": "auto",
    "body": "Ada Lovelace settled £1,240.00. Tap to open the payment record.",
    "navigate": "https://ledger.example.com/invoices/4021",
    "app_badge": "3",
    "mutable": true
  }
}

title and navigate are the two members that carry hard requirements: a declarative payload without them is not a valid declarative payload, and Safari falls back to the classic path. navigate is what makes the notification actionable without any of your code running — the browser opens that URL when the user taps, whether or not a service worker is available to receive notificationclick.

app_badge sets the number on the app icon at display time, which is worth more than it sounds. In the classic path, the badge is only correct if the worker boots and runs navigator.setAppBadge(); declaratively, it is applied by the system as part of rendering. Send an absolute count rather than a delta, because pushes can be coalesced by Topic and a client-side counter drifts.

mutable is the interoperability lever, covered in the next section. lang and dir do the same job they do in showNotification() — set dir to auto and you get correct bidirectional rendering for user-generated content without guessing.

Everything else you might reach for is absent. There is no actions array, which costs nothing on Apple platforms because WebKit ignores action buttons anyway, and no image, icon or requireInteraction, which WebKit also ignores. The declarative format is close to an honest description of what Safari was ever going to render.

Encryption and size rules are unchanged: the plaintext is encrypted with aes128gcm per RFC 8291 and the resulting record must fit the 4 KB payload size limit. Because declarative payloads carry fully rendered, localised copy rather than an identifier, they are meaningfully larger than the identifier-only payloads most senders use. Budget for it, and see web push payload too large 413 error if you start bumping the ceiling.

When Safari falls back to the service worker

The fallback rules are what make this safe to adopt incrementally. Safari inspects the decrypted plaintext and drops to the classic push event whenever the declarative contract is not met — so a sender that has never heard of Declarative Web Push keeps working exactly as before, and a sender that adopts it keeps working on every non-Safari engine.

How Safari chooses between declarative display and the push event If the web_push member does not equal 8030 or the required members are missing, the classic push event fires. If mutable is not true, the notification is displayed directly and the worker stays asleep. If mutable is true and the worker calls showNotification in time, that version wins; otherwise the declarative notification is shown as sent. web_push member equals 8030? and title plus navigate both present no Classic path push event fires in the service worker notification.mutable is true? opt in to editing before display no Displayed directly worker stays asleep, no push event Worker calls showNotification() inside waitUntil, before it settles yes Your version wins declarative copy is replaced Worker throws, stalls or does nothing The declarative notification is shown as sent — a safety net a classic handler cannot give you.
The fallback ladder: invalid declarative JSON drops to the push event, a non-mutable payload displays directly, and a mutable payload lets your worker win only if it actually renders in time.

That last row is the reason to adopt declarative even if you have no interest in the format itself. In the classic path, a worker that throws during push — a bad JSON.parse, a missing IndexedDB store, an unhandled rejection — produces either nothing or a generic system-supplied notification. In the declarative path with mutable: true, the same bug produces the notification you sent. You keep every capability of the push event and gain a floor underneath it.

What you lose, and what you gain

The trade is genuinely two-sided, and the axes that matter depend on what your notifications actually do. Transactional alerts with server-rendered copy lose almost nothing declaratively. A feed digest that assembles copy from local state loses a great deal.

Trade-off matrix between declarative and service worker push Five axes compared. Declarative always displays, has no cold start and limits templating and display analytics, and is Safari-only today. The service worker path renders nothing when it fails, boots per push, offers full client logic and display logging, and works on every engine. Declarative Service worker If your code fails Still displayed Generic card or nothing Cold-start cost None Worker boot per message Templating and logic Server-side only Full client logic Display-time analytics No event to log Log inside the push event Portability today Safari 18.4 and later Every push engine Send both: a declarative body with mutable true, plus a worker that upgrades it when it can.
Five axes, two columns. Declarative wins on reliability and cost; the service worker wins on logic and instrumentation — which is why the answer is usually both.

The analytics loss deserves the most scrutiny, because it is easy to miss until a dashboard goes flat. Teams that measure a “displayed” rate do so by logging a beacon from inside the push handler. A non-mutable declarative notification never runs that code, so displayed counts collapse to zero while clicks keep arriving — a discrepancy that looks like a tracking bug and is not. Either set mutable: true so the handler still fires, or accept that on Safari your funnel starts at the click. The measurement design behind that decision is covered in tracking push delivery vs display rates.

Sending both from one pipeline

The practical pattern is a single payload builder that emits a declarative body with mutable: true, plus a service worker that treats the same JSON as its input. Safari 18.4 and later reads it declaratively and wakes your worker to refine it; every other engine ignores the web_push member and parses the same object in the push handler.

// Server: one payload object satisfies both paths.
const webpush = require('web-push');

webpush.setVapidDetails(
  'mailto:push-ops@yourdomain.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

function buildPayload({ title, body, url, unread, locale }) {
  return {
    web_push: 8030, // Safari 18.4+ reads this; other engines ignore it.
    notification: {
      title,
      body,
      lang: locale,
      dir: 'auto',
      navigate: url,
      app_badge: String(unread),
      mutable: true, // Keep the push event so analytics and logic still run.
    },
  };
}

async function send(subscription, fields) {
  const json = JSON.stringify(buildPayload(fields));
  if (Buffer.byteLength(json) > 3500) {
    throw new Error('Declarative payloads carry final copy — trim before the 4 KB ceiling');
  }
  return webpush.sendNotification(subscription, json, { TTL: 3600, urgency: 'high' });
}
// Service worker: read the same object, whichever path invoked us.
self.addEventListener('push', (event) => {
  event.waitUntil(
    (async () => {
      let payload = {};
      try {
        payload = event.data ? event.data.json() : {};
      } catch {
        payload = {};
      }
      const n = payload.notification || {};

      // Client-side rules the declarative path cannot express.
      if (await isWithinQuietHours()) {
        await self.registration.showNotification(n.title || 'Update', {
          body: 'Queued until morning.',
          tag: 'quiet-hours',
          data: { url: n.navigate || '/' },
        });
        return;
      }

      await self.registration.showNotification(n.title || 'Update', {
        body: n.body || '',
        lang: n.lang || 'en',
        dir: n.dir || 'auto',
        tag: n.tag || 'default',
        data: { url: n.navigate || '/' },
      });
      await fetch('/api/push/displayed', {
        method: 'POST',
        keepalive: true,
        body: JSON.stringify({ url: n.navigate }),
      });
    })()
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const target = event.notification.data?.url || '/';
  event.waitUntil(clients.openWindow(target));
});

Two details make this safe. The handler must call showNotification() on every path, including the quiet-hours branch, because Apple requires a visible notification per push regardless of which rendering path started. And the notificationclick handler should mirror the navigate URL rather than diverge from it, so the destination is identical whether or not the worker ran — window-targeting mistakes here are common enough to have their own walkthrough in notificationclick not opening the right window.

Choosing between them

Send declarative onlymutable omitted or false — when the copy is final at send time, reliability matters more than measurement, and the notification is transactional: a delivery status, a payment confirmation, a security alert. You get the shortest path from wire to screen and no dependency on your worker booting.

Send declarative with mutable: true as the default for everything else on Apple platforms. You keep display analytics, client-side frequency capping and quiet hours, and you gain a guaranteed floor if any of that logic fails.

Send classic only when you are not targeting Safari 18.4 or later, or when the notification genuinely cannot be rendered server-side — for example when the copy depends on locally cached state your server does not have. That is rarer than it sounds; most such cases are better solved by sending the state along with the message.

Whichever you choose, the subscription requirements do not change: on iPhone and iPad the site must still be installed to the Home Screen before any of this is reachable, as covered in iOS Web Push requires Add to Home Screen, and the per-engine support picture lives in the browser compatibility reference.

Gotchas and edge cases

  • web_push must be the number 8030, not the string "8030". A quoted value fails the check and silently drops you to the classic path, which looks like declarative “not working” rather than a type error.
  • Missing navigate invalidates the whole payload. It is not optional the way most NotificationOptions members are; without it Safari treats the JSON as ordinary payload data.
  • Declarative payloads are bigger. Final localised copy plus a full URL can easily double an identifier-only payload. The 4 KB aes128gcm ceiling is unchanged, so measure before you ship a long-body template.
  • A non-mutable payload gives you no delivery telemetry at all. No push event means no beacon; your only signal is the click. Decide this deliberately rather than discovering it in a dashboard.
  • mutable: true does not guarantee your worker wins. If the boot is slow, throws, or resolves without calling showNotification(), the declarative version is shown. That is the point, but it means a broken worker fails quietly instead of loudly — keep error reporting inside the handler.
  • Non-Safari engines see an unfamiliar object, not an error. Chromium and Gecko parse the JSON in the push handler like any other payload. Make sure your handler reads payload.notification.title rather than payload.title, or every non-Safari notification renders blank.

Back to Safari & iOS Web Push Integration

FAQ

Does Declarative Web Push replace the service worker push event?

No. It sits alongside it. Safari picks the declarative path only when the decrypted plaintext is a JSON object with a top-level web_push member equal to 8030 and the required title and navigate members present. Anything else dispatches the classic push event, and every non-Safari engine only ever uses the classic path.

Can I still run code when a declarative notification is displayed?

Only if you set mutable: true in the notification object. That opts the message into waking your service worker, so the push event still fires and can log a display beacon or replace the notification with showNotification(). Without mutable, no code of yours runs at display time and the notification is rendered exactly as sent.

What happens on Chrome or Firefox if I send a declarative payload?

They ignore the web_push member entirely and deliver the JSON to your push handler as ordinary payload data. As long as your handler reads the fields from payload.notification rather than the top level, one payload builder serves every engine without branching.