cf.completefrontend
My progress
FUNDAMENTALS · MODULE 5

Web push notifications

Messages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.

The problem: news that arrives when nobody is looking

Picture a food delivery site. A customer places an order, watches the "Preparing" badge for a minute, then closes the tab and goes back to work.

Twenty minutes later the rider is at the door. The site knows this the moment the rider taps "Arrived", but the tab is gone, so it has no way to tell the customer.

Every real-time technique in this module so far needs an open page. Polling, long polling, Server-Sent Events, and WebSockets all run inside a tab. Close the tab and the loop, the stream, or the socket closes with it. Yet the news that matters most often arrives exactly when the user is away: the order arrived, someone replied, the flight gate changed, the build failed.

Web push solves this. Your server sends a short message to a push service, a delivery server run by the browser vendor. The push service holds it until the device is reachable, then delivers it to the browser.

The browser wakes your service worker (a script that runs in the background, separate from any tab) and gives it the message. The service worker shows a system notification. The user can be in another app, or the browser window can be closed on desktop, and the message still lands.

Two APIs, not one

People say "push notifications" as one thing, but the web splits it into two separate APIs that you can use alone or together. Keeping them apart in your head clears up most of the confusion around this topic.

  • The Push API moves a message from your server to your service worker. It is about delivery: subscriptions, the push service, encryption, and the push event. It shows nothing on screen by itself.
  • The Notifications API shows a message on the operating system's notification surface (the macOS Notification Center, the Windows action center, the Android shade). It is about display: the title, body, icon, actions, and the notificationclick event. A page that is open can use it with no push at all.

Both are gated by the same permission, Notification.permission, which is "default" (not asked yet), "granted", or "denied". That shared permission is why the two APIs feel like one feature. In practice: the Push API brings the message in, the Notifications API puts it in front of the user.

Two halves. Left, tab open: your page calls the Notifications API directly and the operating system shows the notification; no push is involved. Right, tab closed: your server sends a message to the push service, the Push API delivers it to your service worker as a push event, and the service worker uses the Notifications API to show it.Tab openTab closedYour pagetab is openNotificationsOS notificationshown by the OSYour serverdecides to notifyPush servicevendor runPush APIService workerpush eventNotificationsOS notificationshown by the OS
The Push API is the delivery half and the Notifications API is the display half. An open page needs only the second; a closed one needs both, joined by the service worker.

What web push is not

Web push is not a real-time channel for an open page. Delivery takes anywhere from a few hundred milliseconds to many minutes, depending on the device's network and power state, and the push service may drop or batch messages. If the user has the page open and you need sub-second updates, keep using WebSockets or Server-Sent Events. Push is for the moments when the page is closed, or when the event matters enough to interrupt.

It is also not free attention. Every notification interrupts a person. Browsers measure how users react to your prompts and notifications, and they punish sites that annoy people, as later chapters show. The engineering is the easy half of this topic; the hard half is asking at the right moment and sending only what people asked for.

Web push is the only browser technique that reaches a user with no page open.
TechniqueNeeds an open tab?Typical latencyBest for
Polling, long pollingYesSecondsSimple status updates on an open page
Server-Sent EventsYesUnder 100 msOne-way live feeds on an open page
WebSocketsYesUnder 100 msTwo-way chat, games, collaboration
Web pushNoHundreds of ms to minutesRe-engaging a user who left

Think first

Why can't a service worker just keep a WebSocket open in the background so the server can reach it directly?

The mental model: a private mailbox at the browser's post office

Your server cannot reach a phone or a laptop directly. It doesn't know the device's IP address, the device is often behind a home router or a mobile carrier that blocks incoming connections, and it may be asleep. Something that the device already talks to has to carry the message the last mile.

That something is the push service. Think of it as a post office run by the browser's maker. When a user agrees to notifications, the browser rents a private mailbox there for your site and hands you its address.

You drop sealed letters into that mailbox. The post office knows where the user lives and delivers the letter when they are home. Only the user's browser can open the envelope.

The three parties

  • The browser runs your page and your service worker. It keeps one long-lived connection to its push service, shared by every site the user has allowed. On Android and iOS that connection belongs to the operating system's push channel, which is why push costs very little battery.
  • The push service is run by the browser or OS vendor, not by you. Chrome uses Firebase Cloud Messaging (FCM) at fcm.googleapis.com, Firefox uses Mozilla's autopush at updates.push.services.mozilla.com, Edge uses the Windows Push Notification Services at a notify.windows.com address, and Safari uses Apple's service at web.push.apple.com.
  • Your application server decides when something is worth a notification, keeps every user's subscriptions in its database, encrypts each message, signs it, and sends it to the push service over plain HTTPS.
Three zones. On the user's device: the browser, which keeps one long-lived connection to its push service, and your service worker, which the browser wakes per message. In the middle, run by the browser vendor: the push service, such as FCM for Chrome, autopush for Firefox, or Apple's service for Safari. On the right, your backend: the app server that encrypts and signs messages, and the subscriptions database. A message goes from the app server to the push service, then over the open connection to the browser, which wakes the service worker.User's deviceBrowser vendorYour backendBrowserone connectionService workerwoken per pushPush serviceFCM, autopushApp serverencrypts, signsSubscriptionsmessage path: app server, push service, browser, service workerthe push service holds it while the device is offline, up to the TTL
Your server only ever talks HTTPS to the push service. The last mile rides on a connection the device already keeps open, shared by every site the user has allowed.

You never choose the push service. The browser picks it, and the mailbox address it gives you already points at the right one. Your server code is the same for every browser: one POST to whatever URL the subscription contains, using a protocol standardized by the IETF in RFC 8030 (Generic Event Delivery Using HTTP Push).

The subscription is the mailbox address

The address is called a push subscription, and it has three parts. The endpoint is the unique URL of the mailbox at the push service. The p256dh key is the browser's public key for encryption, and the auth value is a 16-byte shared secret that also goes into the encryption.

Anyone holding the endpoint can try to post to it, so treat a subscription as a secret: store it on the server, scoped to one user and one device.

A user has one subscription per browser profile per device. Someone who uses your app on a work laptop, a home desktop, and a phone has three subscriptions, and your database should model that as a list, not a single field on the user row.

ts
// One row per device that opted in.
type PushSubscriptionRow = {
  id: string;
  userId: string;
  endpoint: string;          // unique; use it as the natural key
  p256dh: string;            // base64url, 65 bytes decoded
  auth: string;              // base64url, 16 bytes decoded
  userAgent: string;         // "Chrome on Android", for the settings page
  topics: string[];          // ["orders", "replies"], what this device wants
  createdAt: Date;
  lastSuccessAt: Date | null; // last 201 from the push service
};
Store the endpoint as a unique key, so re-subscribing the same device updates a row instead of adding a duplicate.

Where your code runs

Frontend engineers write code in three places for push, and each place has different powers. Mixing them up is the source of many bugs, such as trying to read localStorage from a service worker (it can't; use IndexedDB) or trying to show the permission prompt from the server (it can't; only a page can).

PlaceWhat it does for pushWhat it cannot do
The pageRegisters the service worker, asks permission, calls subscribe(), posts the subscription to your APIReceive pushes while closed
The service workerHandles push, shows the notification, handles notificationclick, focuses or opens tabsTouch the DOM, use localStorage, ask permission
Your serverStores subscriptions, encrypts, signs with VAPID, sends, cleans up dead subscriptionsShow anything or ask for anything

Check your understanding · Exercise 1

Which of these statements about the push service are true? Select all that apply.

How it works, step by step

Follow one real case from start to end. A user of a shop called Parcel opens order 481 and taps "Notify me when it ships". Two days later the warehouse scans the box, and a minute after that a notification appears on the user's phone, which has no Parcel tab open. Here is every step in between.

Phase 1: subscribing (once per device)

  1. The page registers a service worker, for example /sw.js, if it hasn't already. Push needs one because the worker is what the browser wakes later.
  2. The user taps the button. In that click handler the page calls Notification.requestPermission(), and the browser shows its prompt. The user picks Allow.
  3. The page calls registration.pushManager.subscribe() with userVisibleOnly: true and your public VAPID key. The browser asks its push service for a new mailbox and gets back an endpoint.
  4. The browser generates its encryption key pair and auth secret, keeps the private half, and returns a PushSubscription to the page.
  5. The page sends the subscription, as JSON, to your API, with the order ID or topic it is about. Your server saves it.
ts
// Runs inside the click handler of "Notify me when it ships".
async function enablePush(orderId: string) {
  // Ask first, while the click still counts as a user action.
  const permission = await Notification.requestPermission();
  if (permission !== "granted") return "not-granted";

  const reg = await navigator.serviceWorker.register("/sw.js"); // returns the existing one if already registered
  const existing = await reg.pushManager.getSubscription();
  const sub = existing ?? (await reg.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: VAPID_PUBLIC_KEY, // base64url string
  }));

  await fetch("/api/push/subscriptions", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ subscription: sub.toJSON(), topic: `order-${orderId}` }),
  });
  return "subscribed";
}
Reuse an existing subscription when there is one. Calling subscribe() again with the same key returns the same one, but checking first makes the intent clear.

The JSON that reaches your server is small and has a fixed shape. The figure below labels each field.

The JSON of a push subscription, line by line, with notes. endpoint is a URL such as https://fcm.googleapis.com/fcm/send/ followed by a long token: the mailbox, unique per browser and device, whose host tells you the push service. expirationTime is usually null. keys.p256dh is the browser's 65-byte public key used for the ECDH key agreement. keys.auth is a 16-byte shared secret. Both keys are base64url strings.{"endpoint": "https://fcm...","expirationTime": null,"keys": {"p256dh": "BNcRdreA...","auth": "tBHItJI5..."}}the mailbox URL,unique per device;host = push serviceusually nullbrowser public key,65 bytes, for ECDHshared secret,16 bytes
This is what subscription.toJSON() gives you and what your server stores. The endpoint routes the message; the two keys let only this browser decrypt it.

Phase 2: sending (every time something happens)

  1. The warehouse system emits order.shipped for order 481. Your notification worker picks it up from a queue.
  2. It loads every subscription for that user that wants the orders topic. Say there are two: a phone and a laptop.
  3. For each one, it builds a payload such as {"title":"Order 481 shipped","body":"Arriving Thursday","url":"/orders/481","tag":"order-481"}, about 100 bytes.
  4. It encrypts the payload for that subscription's keys, signs a VAPID token, and sends POST to the endpoint with headers like TTL: 86400 and Urgency: normal.
  5. The push service answers 201 Created. That only means it accepted the message, not that the user saw it.

Phase 3: delivery and display

  1. The push service forwards the message over the device's open push connection, now or when the device next comes online, as long as the TTL has not run out.
  2. The browser decrypts it, starts your service worker if it is not running, and fires a push event with the payload.
  3. The push handler calls registration.showNotification() inside event.waitUntil(). The operating system shows it.
  4. The user taps it. The browser fires notificationclick in the service worker, which focuses an open Parcel tab on /orders/481 or opens a new one.

Step through the whole journey below. Watch which party is active at each step and what data moves between them.

From a server event to a notification clickStep 1 of 8: An event happens

A sequence diagram with five columns: your server, the push service, the browser, the service worker, and the user. In order: the server posts an encrypted, VAPID-signed message to the push service, which answers 201. The push service delivers the ciphertext to the browser, which fires a push event in the service worker. The worker shows a notification to the user. The user's click fires notificationclick in the worker, which focuses the open tab in the browser.ServerPush svcBrowserSWUserorder.shipped
The warehouse emits order.shipped for order 481. The notification worker loads this user's subscriptions for the orders topic: a phone and a laptop.

An event happens: The warehouse emits order.shipped for order 481. The notification worker loads this user's subscriptions for the orders topic: a phone and a laptop.

Think first

The push service returned 201 Created, but the user says they never got a notification. Name three places the message could have been lost.

VAPID keys, encryption, and the 4 KB limit

Two pieces of cryptography protect every push. VAPID proves to the push service who is sending. Message encryption hides what is being sent from the push service. They use different keys and solve different problems, and interviewers like to check that you can tell them apart.

VAPID: proving the message is from you

VAPID stands for Voluntary Application Server Identification, defined in RFC 8292. You generate one elliptic-curve key pair on the P-256 curve, once, for your whole application. The public key is 65 bytes, usually written as an 87-character base64url string. The private key never leaves your servers.

The public key goes into subscribe() as applicationServerKey. The push service records it with the new mailbox. From then on, it only accepts messages for that mailbox that are signed by the matching private key. If someone steals an endpoint from your database, they still can't push to your users without the private key.

For each send, your server creates a short JSON Web Token (JWT) signed with ES256 (ECDSA on the P-256 curve with SHA-256) and puts it in the Authorization header. It has three claims:

  • aud, the audience: the origin of the push service, such as https://fcm.googleapis.com. Take it from the endpoint URL.
  • exp, the expiry: a Unix time no more than 24 hours ahead. Libraries cache the token and reuse it for many sends to the same push service.
  • sub, the subject: a mailto: or https: contact, so the push service operator can reach you if your traffic causes problems.
http
POST /wpush/v2/gAAAAABk... HTTP/1.1
Host: updates.push.services.mozilla.com
Authorization: vapid t=eyJ0eXAiOiJKV1Qi..., k=BEl62iUYgUivxIkv69yViEuiBIa...
Content-Encoding: aes128gcm
Content-Type: application/octet-stream
TTL: 86400
Urgency: normal
Topic: order-481
Content-Length: 203

(203 bytes of ciphertext)
A push request to Firefox's push service: the VAPID token (t) and public key (k) in Authorization, delivery hints in TTL, Urgency, and Topic, and an encrypted body.

Encryption: hiding the message from the push service

The push service is a third party. It should not be able to read "Your test results are ready" or a chat message. RFC 8291 (Message Encryption for Web Push) makes the payload readable only by the one browser that holds the subscription's private key.

  1. Your server makes a fresh, throwaway P-256 key pair for this one message.
  2. It combines its throwaway private key with the subscription's p256dh public key (an ECDH key agreement) to get a shared secret that only it and the browser can compute.
  3. It mixes in the subscription's auth secret and a random 16-byte salt, using HKDF (a standard key derivation function), to derive the content key.
  4. It encrypts the payload with AES-128-GCM, as defined in RFC 8188, and sends Content-Encoding: aes128gcm.

You almost never write this yourself. Libraries such as web-push for Node.js, pywebpush for Python, and webpush-go do it in one call. What you need to know is the cost: the encryption adds a fixed header and tag to every message, and that counts against the size limit.

The payload limit

RFC 8030 says a push service must accept payloads of at least 4,096 bytes, and the major services stop near that number. The limit applies to the encrypted body. The aes128gcm header takes 86 bytes (16 salt, 4 record size, 1 key length, and the 65-byte throwaway public key), the GCM authentication tag takes 16, and a padding delimiter takes 1. That leaves about 3,993 bytes for your JSON.

One encrypted push message of at most 4,096 bytes. Byte layout: a header of 16 bytes of salt, 4 bytes of record size, 1 byte of key length, and the 65-byte throwaway public key, 86 bytes in all; then your encrypted data plus a 1-byte padding delimiter; then a 16-byte authentication tag. That leaves about 3,993 bytes for your JSON. Below, to scale: 103 bytes of overhead and a 100-byte shipping update together fill about 5 percent of the bar.byte layout of one message (not to scale)salt 16rs 4len 1key 65your data + 1tag 1686-byte headerup to 3,993 bytesto scale: 4,096 bytesfirst sliver: 103 bytes of encryption overheadsecond sliver: a 100-byte shipping updatethe rest: room most notifications never need
The limit counts the encrypted body, so subtract 103 bytes of aes128gcm overhead. A title, a body, and a URL fit many times over; an image or a long thread does not.

That is plenty for a title, a short body, a URL, and a few IDs. It is not enough for an image, a full email, or a list of 50 items. There are two patterns for bigger content:

  • Tickle and fetch. Send only an ID, such as {"type":"reply","id":"c_9f2"}. In the push handler, fetch the details from your API with the user's cookie, then show the notification. This also keeps sensitive text off the push service entirely. The cost is a network request while the worker is awake, so add a fallback notification if the fetch fails.
  • Click to load. Put a short summary in the payload and load everything else when the user clicks through to the page.

Type a notification into the calculator to see how much of the budget it uses, and what happens when it doesn't fit.

Fits: 226 of 4,096 bytes, 6 percent of the budget.

A bar for the 4,096-byte push budget. 103 bytes of encryption overhead plus 123 bytes of JSON. Fits: 226 of 4,096 bytes, 6 percent of the budget.payload: title, body, url, tag, itemsJSON 123 Boverhead 103 Btotal 226 Bfits, 3870 bytes to spare
Your JSON is UTF-8 encoded, so an emoji or an accented letter costs 2 to 4 bytes. Add the order items to see the limit, or switch to tickle and fetch.

Check your understanding · Exercise 2

Your database of push subscriptions leaks, including every endpoint, p256dh, and auth value. Your VAPID private key does not leak. What can an attacker do? Pick one.

Asking for permission at the right moment

The browser's permission prompt is the most fragile part of web push. You usually get one real chance at it. Spend it well and a large share of interested users say yes. Spend it on a cold page load and most people say no, and many of those no's are permanent.

Three states, and one of them is a dead end

Notification.permission is in one of three states. (navigator.permissions.query({ name: "notifications" }) reports the same thing, calls the first state "prompt", and lets you listen for changes.) Your code can only move it out of "default", and only through the prompt.

  • "default": never decided. requestPermission() will show the prompt. Closing the prompt with the X usually leaves it at "default", though Chrome may stop showing the prompt on that site for about a week after a user dismisses it several times in a row.
  • "granted": the user clicked Allow. You can subscribe and show notifications.
  • "denied": the user clicked Block. requestPermission() now resolves to "denied" at once, with no prompt. Only the user can undo it, in the browser's site settings, and very few do.
A state machine for notification permission. It starts in default, not asked yet. Clicking Allow in the prompt moves it to granted, where the site can subscribe. Clicking Block moves it to denied, where the prompt never shows again. Dismissing the prompt leaves it in default. Only the user, in the browser's site settings, can move denied back to granted.defaultnot asked yetgrantedcan subscribedeniedprompt never showsAllowBlockdismiss: stays defaultonly the user, in settings
default is the only state your page can change, and only through one prompt. Leaving it for denied is a one-way door for your code.

Browsers fight prompts on page load

Asking on page load used to be common, and users hated it. Browser makers responded, and these rules are what you design against today:

  • Firefox (since version 72) and Safari only show the prompt when requestPermission() is called from a user action such as a click. Without one, Firefox shows only a small icon in the address bar and Safari shows nothing.
  • Chrome has a quieter permission UI: instead of a dialog, a small crossed-out bell in the address bar (or a mini-infobar on Android). Users who usually deny prompts get it everywhere, and sites with very low acceptance rates get it for every visitor. Most people never notice the quiet version, so your opt-in rate falls further.
  • Chrome also enforces against abusive notification behavior. Sites that use misleading prompts or send deceptive notifications get the quiet UI forced on them after a warning in Search Console.

The double opt-in pattern

The fix is to put your own question in front of the browser's. This is called a soft ask or double opt-in.

First, your page shows its own small card that explains exactly what you'll send and how often. It has two buttons, "Turn on" and "Not now". Only "Turn on" calls requestPermission(), which shows the real browser prompt, the hard ask.

  • A "Not now" on your card costs nothing. The browser state stays "default", and you can offer again later, at a better moment.
  • Users who reach the browser prompt have already said yes once, so most of them say yes again.
  • The click on "Turn on" is the user action that Firefox and Safari require.
A wireframe of an order page at parcel.example/orders/481. The order status reads Packed. The user has just tapped a button labelled Notify me when it ships. Next to it, the site's own card asks: Get a ping when it ships? One notification per status change, no ads. It has two buttons, Turn on and Not now. Below the window: Turn on calls requestPermission and shows the browser prompt, the hard ask; Not now leaves the permission at default so the site can ask again later.parcel.example/orders/481Order 481: PackedNotify me when it shipsGet a ping whenit ships?One per status change.No ads, off any time.Turn onNot nowTurn on: calls requestPermission(), the browser promptNot now: permission stays default; ask again later
The soft ask is ordinary page UI, so a "no" costs nothing. Only Turn on calls Notification.requestPermission(), from a click, with the value already clear.

Decision When should a shop's web app first ask for notification permission?

On page load

Call requestPermission() as soon as the page loads

Pros

  • Every visitor sees the request
  • No product work needed

Cons

  • The user has no reason to say yes yet
  • Firefox and Safari don't show it without a click
  • Low acceptance moves the site to Chrome's quiet UI

After some engagement

Show a soft ask after, say, the second visit or 60 seconds on the page

Pros

  • Fewer cold prompts
  • Easy to add

Cons

  • Still not tied to anything the user wants
  • Timing guesses vary a lot by user

At a moment of intent

Offer push right where it helps: "Notify me when it ships", "Tell me when it's back in stock", or after a first chat message

Pros

  • The value is obvious at that moment
  • The click satisfies every browser
  • Highest acceptance and fewest blocks

Cons

  • Needs product design per feature
  • Fewer users are asked in total

What we pick: Ask at a moment of intent, behind a soft ask. Only consider an engagement-based soft ask for apps where notifications are the core value, such as a messaging app, and never call the browser prompt on load.

Try the timing lab below. It plays the same 1,000 visitors through four prompt strategies. The numbers are illustrative, but the shape matches what product teams report: the later and more relevant the ask, the fewer permanent blocks.

Prompt on page load, Chrome: 1000 saw the browser prompt, 60 granted, 400 denied for good, 540 can be asked later. Prompt acceptance 6 percent. Chrome may switch this site to the quiet prompt.

Bars for 1,000 visitors, to scale. Prompt on page load, Chrome: 1000 saw the browser prompt, 60 granted, 400 denied for good, 540 can be asked later. Prompt acceptance 6 percent. Chrome may switch this site to the quiet prompt.1,000 visitorsgranted60denied for good400can ask later540acceptance: 6% of 1000 prompts, quiet UI likelyCold prompt: most people block or ignore it.
Illustrative numbers, not measurements. Compare the red bar: every denied is a user you can never prompt again, and a low acceptance rate also hurts the users you ask later.

After the yes: keep the permission

Getting permission is half the job; keeping it is the other half. Users who get noise go to the browser settings and block the whole site, which kills the useful notifications too.

  • Offer per-type settings in your own app: orders on, replies on, promotions off. Store them on the subscription row as topics.
  • Give an off switch inside the app that calls subscription.unsubscribe() and deletes the row on your server. A user who can turn you off in your app doesn't need to block you in the browser.
  • Respect the user's day: batch low-value updates into a digest, and avoid sending at night in the user's time zone.

Check your understanding · Exercise 3

A news site shows the browser permission prompt 3 seconds after every article loads. Which outcomes should the team expect? Select all that apply.

The service worker: push events and notification clicks

Everything the user sees is decided in two event handlers in your service worker: push, when a message arrives, and notificationclick, when the user taps it. Both run with no page and no DOM, and both can be stopped by the browser if they don't tell it to wait.

The push event

The browser decrypts the payload and hands it to you as event.data, which has .json(), .text(), and .arrayBuffer() methods. It can also be null: a push with no body is legal, and some servers use it as a pure "something changed" signal.

js
// sw.js
self.addEventListener("push", (event) => {
  let data;
  try {
    data = event.data ? event.data.json() : null;
  } catch {
    data = null; // a bad payload must still show something
  }
  const title = data?.title ?? "Parcel";
  event.waitUntil(
    self.registration.showNotification(title, {
      body: data?.body ?? "You have a new update.",
      icon: "/icons/192.png",
      badge: "/icons/badge-72.png",
      tag: data?.tag,            // same tag replaces, doesn't stack
      renotify: Boolean(data?.tag), // still buzz when replacing
      data: { url: data?.url ?? "/" },
    }),
  );
});
Parse defensively and always end in showNotification. The data option travels with the notification to the click handler.

Three rules matter more than any option:

  • Wrap the work in event.waitUntil(promise). It tells the browser to keep the worker alive until the promise settles. Without it, the worker can be stopped before the notification appears, and the bug only shows up on some devices.
  • Always show a notification. You promised userVisibleOnly: true when subscribing. If a push ends without a notification, Chrome usually shows its own generic one saying the site was updated in the background, and Safari treats it as a broken promise and can revoke the subscription. There is no silent background push on the web.
  • Finish fast. Browsers kill a worker whose single event runs far too long (minutes, not seconds). If you fetch details first, set a timeout of a few seconds and fall back to a generic notification.

The options that matter

OptionWhat it doesWatch out for
bodyThe second line of textLock screens show it; keep secrets out
iconYour logo or the sender's avatarUse at least 192 by 192 pixels
badgeA small monochrome icon for the Android status barOnly the alpha channel is used
tagA notification with the same tag replaces the old oneWithout it, ten updates show as ten notifications
renotifySound and vibrate again when replacing a tagged oneThrows if set without a tag
requireInteractionKeeps it on screen until the user acts (desktop)Annoying for anything but urgent alerts
actionsUp to Notification.maxActions buttons, often 2Not shown in Safari, and only in Firefox 152 and later; the plain click must still work
dataAny structured data you need in the click handlerKeep it small; it is stored with the notification

Handling the click

A tap fires notificationclick in the service worker, not in any page. The notification does not close by itself on every platform, so close it first. Then decide where to take the user.

The rule is simple: if a tab of your app is already open, reuse it; otherwise open a new window. Users who get a fresh tab for every notification end up with five copies of your app and lose their unsaved state.

js
self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  const target = new URL(event.notification.data?.url ?? "/", self.location.origin);
  target.searchParams.set("from", "push");

  event.waitUntil((async () => {
    const tabs = await clients.matchAll({ type: "window", includeUncontrolled: true });
    const same = tabs.find((t) => new URL(t.url).pathname === target.pathname);
    if (same) return same.focus();

    const any = tabs.find((t) => new URL(t.url).origin === target.origin);
    if (any) {
      await any.focus();
      return any.navigate(target.href); // controlled tabs only; or postMessage
    }
    return clients.openWindow(target.href);
  })());
});
Prefer the exact page, then any tab of your app, then a new window. The from=push parameter lets you measure clicks.
A flowchart for the notificationclick handler. First close the notification. Then: is a tab already open on this exact URL? If yes, focus it. If no: is any tab of the app open? If yes, focus it and navigate it, or post it a message so the app routes in place. If no, call clients.openWindow with the URL.notificationclickclose() it firstTab on this URL?yesfocus() itnoAny app tab open?yesfocus, navigateor postMessagenoopenWindow(url)a new tab
Reuse before you open. clients.matchAll({ type: "window", includeUncontrolled: true }) finds the tabs; do the window work first, while the click still counts as a user action.

Two details catch people. First, clients.openWindow() and client.focus() only work during a short window right after the click. Call them later, for example after a slow fetch, and the browser refuses, so do the navigation first and the slow work after.

Second, client.navigate() reloads the page, which throws away unsaved state in a single-page app, and it only works on a tab this service worker controls. A gentler choice is client.postMessage({ type: "open", url }), letting your router change the view.

If you use actions, the click handler gets event.action set to the button's action string, or an empty string for a click on the body. Treat an empty string as the default action. A notificationclose event also fires when the user dismisses a notification, which is useful for measuring how often people swipe yours away.

Check your understanding · Exercise 4

Your click handler first fetches /api/thread/42 to mark it as read, then calls clients.openWindow("/thread/42"). On slow networks nothing opens. Which fixes work? Select all that apply.

Delivery, expiry, and cleaning up dead subscriptions

Web push is best-effort. The push service will try to deliver, but it makes no promise about when, and it will drop messages it can't deliver in time. Your server shapes that behavior with three request headers, and it learns what happened from the response status.

TTL, Urgency, and Topic

  • TTL (time to live, in seconds) is required. It says how long the push service may hold the message if the device is offline. TTL: 0 means deliver now or drop it, which suits an incoming call. TTL: 86400 keeps a shipping update for a day. Push services may cap it; FCM keeps messages for at most four weeks.
  • Urgency is a battery hint with four values: very-low, low, normal, high. RFC 8030 suggests the device state each one waits for: very-low until the phone is on power and Wi-Fi, low until it has either, normal not at all, and high even on low battery. Use high only for things like a security alert or a call; marking everything high drains batteries, and push services may treat it differently from what you hope.
  • Topic names a message so a newer one can replace it while it is still waiting. If an offline phone has "Order 481: packed" waiting with Topic: order-481 and you send "Order 481: shipped" with the same topic, the phone only ever gets "shipped". The value is up to 32 characters from the URL-safe base64 alphabet.

Topic works on the push service, before delivery. The notification tag works on the device, after delivery. Use both with the same key so a stale update is neither delivered nor shown.

A 12-hour timeline for a phone that is offline from hour 0 to hour 8. An alert sent at hour 1 with a TTL of 4 hours expires at hour 5 and is dropped. An update sent at hour 2 with a TTL of one day waits and is delivered at hour 8. A packed status sent at hour 3 with Topic order-481 is replaced at hour 6 by a shipped status with the same topic, so at hour 8 the phone receives only shipped.phoneofflineonlinealert, TTL 4 hdroppedupdate, TTL 1 ddeliveredpacked, topicreplacedshipped, topicdelivered0 h4 h8 h12 h
TTL decides how long a message may wait; Topic lets a newer message replace an older one that is still waiting. The phone never hears about "packed".

Reading the response

StatusMeaningWhat your server does
201 CreatedAccepted for deliveryRecord lastSuccessAt; nothing else
400Malformed request, often a bad headerFix the code; don't retry as is
403VAPID signature doesn't match the key the subscription was made withCheck you sign with the right key pair
404 or 410 GoneThe subscription has expired or was removedDelete the row now; it will never work again
413Payload too largeShrink it or switch to tickle and fetch
429Too many requestsBack off, honoring Retry-After
5xxPush service troubleRetry with exponential backoff and jitter

Unsubscribes and 410 cleanup

Subscriptions die all the time, and your server is not told when. A user blocks your site in browser settings, clears site data, uninstalls the browser, or the push service expires an old subscription. The only signal you get is a 404 or 410 the next time you send.

ts
import webpush from "web-push";

async function sendToDevice(row: PushSubscriptionRow, payload: string) {
  const sub = { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } };
  try {
    await webpush.sendNotification(sub, payload, { TTL: 86400, urgency: "normal", topic: "order-481" });
    await db.pushSubscriptions.update(row.id, { lastSuccessAt: new Date() });
  } catch (err) {
    const status = (err as { statusCode?: number }).statusCode;
    if (status === 404 || status === 410) {
      await db.pushSubscriptions.delete(row.id); // gone for good
    } else if (status === 429 || (status && status >= 500)) {
      await retryQueue.add(row.id, payload);   // backoff with jitter
    } else {
      throw err;
    }
  }
}
Delete on 404 or 410, retry on 429 and 5xx, and surface everything else as a bug.

Skip this and the table grows forever. A site with a million subscribers can easily find, a year later, that a large share of its sends go to dead endpoints: wasted CPU on encryption, wasted requests, and skewed delivery metrics.

Keeping the client and server in sync

A subscription can also change on the device. The spec has a pushsubscriptionchange event in the service worker for this, but browser support has been patchy for years, so don't rely on it alone. The dependable pattern is to re-check on every app load: call pushManager.getSubscription(), and if permission is granted and the endpoint differs from the one you last sent to the server, post the new one.

  • Permission is "granted" but getSubscription() returns null: subscribe again silently (no prompt is needed) and post it.
  • Permission is "denied" but you still have a server row for this device: tell the server to delete it.
  • The user logs out: delete the row, or the next person on that shared computer gets the previous user's notifications.

Think first

A sports app needs to send a goal alert to 2 million subscribers within 30 seconds. What does the sending side look like?

Platform support, iOS, and declarative web push

Web push now works in every major browser engine, which was not true until 2023. The standard pieces (service worker, Push API, VAPID, RFC 8291 encryption) are the same everywhere. The differences are in where it is allowed, how the prompt behaves, and which notification options are shown.

A support matrix. Chrome and Edge on desktop and Chrome on Android: push yes, a click is not required for the prompt, action buttons yes, declarative web push no. Firefox desktop: push yes, click required, action buttons from Firefox 152, declarative no. Safari on macOS: push yes, click required, actions no, declarative from Safari 18.5. A Safari tab on iPhone: no push at all. A Home Screen web app on iPhone: push from iOS 16.4, click required, actions no, declarative from iOS 18.4.pushclick neededactionsdeclarativeChrome, Edge desktopyesnoyesnoChrome on AndroidyesnoyesnoFirefox desktopyesyes152+noSafari on macOSyesyesno18.5+Safari tab on iPhonenoiPhone Home Screen16.4+yesno18.4+
The core (service worker, push, showNotification, click) works everywhere push exists. Design for title, body, icon, and click first; treat actions and declarative payloads as extras.

iPhone and iPad: Home Screen web apps only

Safari on iOS and iPadOS 16.4 and later supports web push, but only for a site the user has added to the Home Screen. In a normal Safari tab, PushManager is not there at all. The site needs a web app manifest with display set to standalone or fullscreen.

Once the app is opened from its Home Screen icon, it can ask for permission from a user action as usual. Notifications then appear like any app's, on the lock screen and in Notification Center, and the Badging API can set a count on the icon.

For your design, this means two different flows on iPhone. If the user is in a Safari tab, the soft ask should not offer notifications; it should explain how to add the app to the Home Screen (Share, then Add to Home Screen) and why. If the user is in the installed web app, show the normal soft ask. You can tell them apart like this:

ts
function pushSupport(): "ready" | "install-first" | "unsupported" {
  const hasSw = "serviceWorker" in navigator;
  const hasPush = "PushManager" in window;
  const standalone = window.matchMedia("(display-mode: standalone)").matches;
  const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) ||
    (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);

  if (hasSw && hasPush) return "ready";
  if (isIos && !standalone) return "install-first";
  return "unsupported";
}
Feature-detect first. The user-agent check is only used to choose which help text to show, never to decide whether the API exists.

Declarative web push in Safari

Apple added declarative web push in 2025: in Safari on iOS and iPadOS 18.4, and in Safari 18.5 on macOS. It has also been written into the editor's draft of the W3C Push API spec. The idea: for the common case, the browser shouldn't need to run your JavaScript at all. The server sends a payload in an agreed JSON format, and the browser shows the notification and handles the click itself.

json
{
  "web_push": 8030,
  "notification": {
    "title": "Order 481 shipped",
    "body": "Arriving Thursday",
    "navigate": "https://parcel.example/orders/481"
  }
}
The key web_push with the value 8030 (the RFC number) marks the payload as declarative. A non-empty title and a navigate URL, where a click goes, are required.
  • Why it helps: no service worker has to start, so delivery is cheaper on battery, and a bug or a slow fetch in your worker can't swallow a notification. You can even subscribe with window.pushManager.subscribe() and no service worker at all.
  • Backward compatibility: a browser that doesn't understand the format delivers it to your service worker's push handler as ordinary JSON, so one handler that reads data.notification.title and data.notification.navigate covers both.
  • Customizing: a top-level "mutable": true lets a service worker, if one is registered, replace the notification before it is shown, for example to add an avatar. If the worker fails to show one in time, the browser shows the original.

Other gaps to plan for

  • In-app browsers inside social and chat apps usually have no web push. Detect the missing API and suggest opening the page in the real browser.
  • Most desktop browsers must be running to receive pushes. Chrome on Windows and macOS can keep a background process alive after the last window closes, but if the user quits the browser, messages wait in the push service until the TTL runs out. Safari on macOS is the exception: the system receives the push and delivers it even when Safari isn't running.
  • Operating system settings and focus modes can hide notifications that the browser delivered. You can't detect this from the page.
  • Notification options differ: actions, image, and requireInteraction are not shown everywhere. Design for title, body, icon, and click first.

Check your understanding · Exercise 5

A user on an iPhone with iOS 18 visits your site in a Safari tab and taps "Notify me when it ships". What should happen? Select all that apply.

In real products: patterns, privacy, and accessibility

The mechanics are the same for everyone. What separates good products from annoying ones is how push fits with the rest of the system: which events trigger it, how it avoids duplicates with the open app, and what it shows on a lock screen.

Patterns that show up everywhere

  • Chat and collaboration apps (Slack, Discord, Microsoft Teams on the web) notify on direct messages and mentions, not on every message in every channel. When the user is active in an open tab, the live WebSocket already shows the message, so the server skips the push for that user. Push is the channel for when they are away.
  • Shops and delivery apps use push for order status, with one tag and Topic per order so the user sees one updating notification instead of a pile.
  • Social apps such as the X (Twitter) web app, one of the early large progressive web apps, use push for replies, likes, and follows, and give fine-grained settings per type.
  • Calendars and reminders often use the Notifications API from an open tab for upcoming events, and push only when no tab is open.

Choosing the channel on the server

Mature systems put a small notification router in front of push. For each event and each user, it picks a channel: the live socket if the user is online, push if they opted in and are away, email for important events that push missed, and nothing if the user muted that type. That router is a good thing to draw in an interview.

Privacy and security

  • Lock screens are public. A notification body can be read by anyone holding the phone. For medical, banking, or private messages, send "You have a new message" and show the content after the user opens the app.
  • Tickle and fetch keeps data on your servers. If the payload holds only an ID, the push service and any logs on the way never see the text, and the fetch from the service worker carries the user's normal session, so revoked sessions stop leaking content.
  • Validate the click target. Build the URL from data you control and make it same-origin, as the click handler above does with new URL(..., self.location.origin). Never open an arbitrary URL from the payload without checking it.
  • Protect the VAPID private key like any signing key: in a secrets manager, never in the frontend bundle. Only the public key ships to the browser.
  • Unlink on logout, as covered earlier, so a shared computer doesn't receive the previous user's notifications.

Accessibility

System notifications are read by screen readers such as VoiceOver, TalkBack, and NVDA through the operating system, so the browser handles most of it. Your part is the content and your own in-page UI.

  • Put the meaning in the title and body. Screen readers read text, not icons or images, and image is not shown on every platform.
  • Keep it short and front-load the key fact: "Order 481 shipped" before "Great news from Parcel!".
  • Don't rely on vibrate or sound alone; some users can't feel or hear it, and silent notifications must still make sense.
  • Your soft ask is a normal in-page dialog or card: real buttons, a clear heading, focus that moves to it only if it opens from a click, and an Escape key or "Not now" that closes it.
  • Settings for notification types are plain checkboxes with labels, so they work with a keyboard and a screen reader.

Check your understanding · Exercise 6

A health app wants to notify users that their lab results are ready. Which designs are sound? Select all that apply.

Using web push in a system design interview

Push comes up whenever the prompt says "notify the user": a chat app, a marketplace, a ride-sharing tracker, a news site, a calendar. The interviewer wants to see that you know when push is the right channel, how the pieces fit, and that you care about the user's attention.

When to bring it up

  • When an event matters while the user is away: a reply, a delivery, a price drop, a security alert.
  • When you are asked how the user learns about something with the tab closed. Say clearly that WebSockets and SSE die with the tab, and push is the only browser channel that doesn't.
  • When the design includes an installable web app, especially if iPhone users matter. Mention the Home Screen requirement before the interviewer does.

What to draw

Draw four boxes from left to right: event source (or your API), a notification service with a queue and workers, the browser's push service, and the device with its service worker. Add the subscription store under the notification service. Then walk one message through, naming TTL, Topic, encryption, showNotification, and notificationclick. Finish with the cleanup loop: 410 deletes the row.

What to say, by level

LevelWhat a strong answer includes
JuniorThe Push API versus the Notifications API; the service worker is woken for a push event; permission is asked from a click; the click opens the right page
Mid-levelSubscriptions stored per device; VAPID to authenticate the server; the 4 KB limit and tickle-and-fetch; tag to avoid stacking; focusing an existing tab; deleting on 410
SeniorSoft ask at a moment of intent and its effect on Chrome's quiet UI; channel routing with socket, push, and email; TTL, Urgency, Topic per event type; fan-out workers and backoff; lock-screen privacy; iOS Home Screen flow and declarative web push

Common mistakes in interviews

  • Saying the server connects to the browser directly, or that the service worker keeps a socket open in the background.
  • Forgetting that every push must show a notification, and proposing silent background sync over push.
  • Asking for permission on page load, or never mentioning the prompt at all.
  • Treating 201 from the push service as "delivered" and building analytics on it.
  • Ignoring iPhone, where a normal Safari tab can't receive push.

Think first

You are designing notifications for a marketplace: buyers want to hear about price drops on saved items, and sellers about new offers. Sketch the decisions in under a minute.

Summary

  • Web push is two APIs sharing one permission: the Push API delivers a message to your service worker, and the Notifications API shows it.
  • Your server never reaches the device directly. It posts an encrypted message to the subscription's endpoint at the browser vendor's push service, which delivers it when it can.
  • VAPID keys prove the message comes from your server; the subscription's p256dh and auth keys encrypt it so the push service can't read it. Payloads must fit in about 4 KB after encryption.
  • Every push must show a notification (userVisibleOnly: true), inside event.waitUntil(). Use tag and Topic to replace stale updates instead of stacking them.
  • Ask for permission from a click, at a moment of intent, behind a soft ask. "denied" is close to permanent, and low acceptance triggers Chrome's quiet prompt.
  • On click, close the notification and focus an existing tab before opening a new one; do window actions before slow work.
  • Delivery is best-effort: set TTL and Urgency per event, delete subscriptions on 404 or 410, unlink on logout, and keep an inbox or email for anything important.
  • On iPhone and iPad, push works only for Home Screen web apps (16.4 and later); Safari also supports declarative web push payloads that need no service worker.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Walk me through what happens between "order shipped" in your database and the user tapping a notification on their phone.

Question 2

Our site asks for notification permission on page load and 90% of visitors block it. How would you fix it?

Question 3

What are VAPID keys for, and how are they different from the keys in the subscription?

Question 4

Can we use push to silently sync data into the app in the background?

Question 5

How do you support iPhone users?

Practice it

These design problems lean on web push notifications.

References

Read next