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
pushevent. 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
notificationclickevent. 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.
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.
| Technique | Needs an open tab? | Typical latency | Best for |
|---|---|---|---|
| Polling, long polling | Yes | Seconds | Simple status updates on an open page |
| Server-Sent Events | Yes | Under 100 ms | One-way live feeds on an open page |
| WebSockets | Yes | Under 100 ms | Two-way chat, games, collaboration |
| Web push | No | Hundreds of ms to minutes | Re-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?
Chrome stops an idle service worker after about 30 seconds, other browsers set similar limits, and all of them kill a worker whose single event runs too long. They do this on purpose: thousands of sites each holding a socket open would drain battery and data.
Push flips the design. The device keeps one connection to one push service that the browser or OS vendor runs, and every site shares it. The browser only starts your service worker when a message for you arrives.
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 atupdates.push.services.mozilla.com, Edge uses the Windows Push Notification Services at anotify.windows.comaddress, and Safari uses Apple's service atweb.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.
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.
// 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
};
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).
| Place | What it does for push | What it cannot do |
|---|---|---|
| The page | Registers the service worker, asks permission, calls subscribe(), posts the subscription to your API | Receive pushes while closed |
| The service worker | Handles push, shows the notification, handles notificationclick, focuses or opens tabs | Touch the DOM, use localStorage, ask permission |
| Your server | Stores subscriptions, encrypts, signs with VAPID, sends, cleans up dead subscriptions | Show 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)
- 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. - The user taps the button. In that click handler the page calls
Notification.requestPermission(), and the browser shows its prompt. The user picks Allow. - The page calls
registration.pushManager.subscribe()withuserVisibleOnly: trueand your public VAPID key. The browser asks its push service for a new mailbox and gets back an endpoint. - The browser generates its encryption key pair and auth secret, keeps the private half, and returns a
PushSubscriptionto the page. - The page sends the subscription, as JSON, to your API, with the order ID or topic it is about. Your server saves it.
// 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";
}
The JSON that reaches your server is small and has a fixed shape. The figure below labels each field.
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)
- The warehouse system emits
order.shippedfor order 481. Your notification worker picks it up from a queue. - It loads every subscription for that user that wants the
orderstopic. Say there are two: a phone and a laptop. - 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. - It encrypts the payload for that subscription's keys, signs a VAPID token, and sends
POSTto the endpoint with headers likeTTL: 86400andUrgency: normal. - The push service answers
201 Created. That only means it accepted the message, not that the user saw it.
Phase 3: delivery and display
- 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
TTLhas not run out. - The browser decrypts it, starts your service worker if it is not running, and fires a
pushevent with the payload. - The
pushhandler callsregistration.showNotification()insideevent.waitUntil(). The operating system shows it. - The user taps it. The browser fires
notificationclickin the service worker, which focuses an open Parcel tab on/orders/481or 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
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.
The device stayed offline longer than the TTL, so the push service dropped it. Or the user turned off notifications for the browser at the operating system level, so the browser received it but the OS hid it. Or the service worker threw an error before showNotification ran, for example on a payload it couldn't parse.
This is why push must never be the only record of something important. Keep an in-app inbox or send an email for anything the user truly needs to know.
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 ashttps://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: amailto:orhttps:contact, so the push service operator can reach you if your traffic causes problems.
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)
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.
- Your server makes a fresh, throwaway P-256 key pair for this one message.
- It combines its throwaway private key with the subscription's
p256dhpublic key (an ECDH key agreement) to get a shared secret that only it and the browser can compute. - It mixes in the subscription's
authsecret and a random 16-byte salt, using HKDF (a standard key derivation function), to derive the content key. - 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.
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 thepushhandler,fetchthe 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.
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.
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.
Notification.requestPermission(), from a click, with the value already clear.Decision When should a shop's web app first ask for notification permission?
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.
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.
// 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 ?? "/" },
}),
);
});
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: truewhen 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
fetchdetails first, set a timeout of a few seconds and fall back to a generic notification.
The options that matter
| Option | What it does | Watch out for |
|---|---|---|
body | The second line of text | Lock screens show it; keep secrets out |
icon | Your logo or the sender's avatar | Use at least 192 by 192 pixels |
badge | A small monochrome icon for the Android status bar | Only the alpha channel is used |
tag | A notification with the same tag replaces the old one | Without it, ten updates show as ten notifications |
renotify | Sound and vibrate again when replacing a tagged one | Throws if set without a tag |
requireInteraction | Keeps it on screen until the user acts (desktop) | Annoying for anything but urgent alerts |
actions | Up to Notification.maxActions buttons, often 2 | Not shown in Safari, and only in Firefox 152 and later; the plain click must still work |
data | Any structured data you need in the click handler | Keep 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.
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);
})());
});
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: 0means deliver now or drop it, which suits an incoming call.TTL: 86400keeps a shipping update for a day. Push services may cap it; FCM keeps messages for at most four weeks.Urgencyis a battery hint with four values:very-low,low,normal,high. RFC 8030 suggests the device state each one waits for:very-lowuntil the phone is on power and Wi-Fi,lowuntil it has either,normalnot at all, andhigheven on low battery. Usehighonly for things like a security alert or a call; marking everythinghighdrains batteries, and push services may treat it differently from what you hope.Topicnames a message so a newer one can replace it while it is still waiting. If an offline phone has "Order 481: packed" waiting withTopic: order-481and 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.
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
| Status | Meaning | What your server does |
|---|---|---|
201 Created | Accepted for delivery | Record lastSuccessAt; nothing else |
400 | Malformed request, often a bad header | Fix the code; don't retry as is |
403 | VAPID signature doesn't match the key the subscription was made with | Check you sign with the right key pair |
404 or 410 Gone | The subscription has expired or was removed | Delete the row now; it will never work again |
413 | Payload too large | Shrink it or switch to tickle and fetch |
429 | Too many requests | Back off, honoring Retry-After |
5xx | Push service trouble | Retry 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.
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;
}
}
}
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"butgetSubscription()returnsnull: 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?
That is about 67,000 sends per second, each with its own encryption, because every subscription has its own keys. No single loop does that. Put one job per batch of subscriptions on a queue and run many stateless workers that encrypt and send in parallel over pooled HTTP/2 connections to each push service.
Use TTL: 0 or a few minutes, because a goal alert is useless an hour later, and Urgency: high. Handle 429 with backoff per push service, delete 410 rows as you go, and measure clicks rather than 201s to know whether it worked.
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.
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:
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";
}
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.
{
"web_push": 8030,
"notification": {
"title": "Order 481 shipped",
"body": "Arriving Thursday",
"navigate": "https://parcel.example/orders/481"
}
}
- 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
pushhandler as ordinary JSON, so one handler that readsdata.notification.titleanddata.notification.navigatecovers both. - Customizing: a top-level
"mutable": truelets 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
TTLruns 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, andrequireInteractionare 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
tagandTopicper 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
titleandbody. Screen readers read text, not icons or images, andimageis 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
vibrateor sound alone; some users can't feel or hear it, andsilentnotifications 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
| Level | What a strong answer includes |
|---|---|
| Junior | The 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-level | Subscriptions 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 |
| Senior | Soft 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
201from 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.
Offers to sellers are high value and time-sensitive: push with Urgency: high, a TTL of a few hours, and email as a backup if the push isn't clicked. Price drops are lower value: batch them into one daily digest per buyer, Urgency: low, with a Topic per item so an offline phone gets only the latest price.
Ask buyers for permission when they save their first item ("Tell me if the price drops?"), and sellers when they list their first item. Store subscriptions per device with topics, delete on 410, and suppress push while the user has the app open, since the live socket already shows it.
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
p256dhandauthkeys 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), insideevent.waitUntil(). UsetagandTopicto 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
TTLandUrgencyper event, delete subscriptions on404or410, 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.
A notification worker loads the user's push subscriptions for the orders topic. For each device it builds a small JSON payload (title, body, URL, tag), encrypts it with that subscription's p256dh and auth keys as in RFC 8291, signs a VAPID JWT with our private key, and posts it to the subscription's endpoint with TTL, Urgency, and Topic headers. A 201 means the push service accepted it.
The push service delivers it over the device's existing push connection when the device is reachable. The browser decrypts it, wakes our service worker, and fires push; the handler calls showNotification inside waitUntil. When the user taps, notificationclick closes it and focuses an open tab on /orders/481 or opens one. A 404 or 410 on any send deletes that subscription.
Question 2
Our site asks for notification permission on page load and 90% of visitors block it. How would you fix it?
Stop asking on load. Firefox and Safari need a user action anyway, and a low acceptance rate pushes Chrome to show its quiet UI to everyone. Ask at a moment of intent, such as "Notify me when it ships", through a soft ask: our own card that says what we'll send, with "Turn on" and "Not now". Only "Turn on" calls requestPermission(), so a "no" doesn't burn the one-time browser prompt.
Then protect the permission: per-type settings, an in-app off switch that unsubscribes cleanly, digests for low-value updates, and quiet hours. Measure acceptance and block rates per prompt location.
Question 3
What are VAPID keys for, and how are they different from the keys in the subscription?
VAPID is our application server's identity. We pass the public key to subscribe(), and the push service then accepts messages for that subscription only when they carry a JWT signed with our private key. It authenticates the sender to the push service.
The subscription's p256dh and auth belong to the browser and are for encryption. Our server uses them to encrypt the payload so only that browser can read it, and the push service sees only ciphertext. One authenticates, the other keeps the content private.
Question 4
Can we use push to silently sync data into the app in the background?
Not on the web. Chrome and Safari only accept subscriptions with userVisibleOnly: true, so every push must show a notification. Chrome shows a generic one if we don't, and Safari can revoke the subscription. Push the notification when it's useful, and sync the rest when the user opens the app, or with Background Sync where it is supported.
Question 5
How do you support iPhone users?
Since iOS and iPadOS 16.4, web push works only for web apps added to the Home Screen, with a manifest whose display is standalone or fullscreen. In a Safari tab, PushManager doesn't exist. So we feature-detect: in a tab we explain how to add the app to the Home Screen and offer email as an alternative; in the installed app we show the normal soft ask.
On Safari 18.4 and later on iOS and iPadOS, and Safari 18.5 on macOS, we can also send declarative web push payloads, which the browser shows without running our service worker, while older browsers still handle them in the push handler.
Practice it
These design problems lean on web push notifications.
References
- MDN: Push API Subscriptions, the push event, and browser support.
- MDN: Notifications API Permission, showNotification options, and click events.
- RFC 8030: Generic Event Delivery Using HTTP Push The push protocol, including TTL, Urgency, and Topic.
- RFC 8291: Message Encryption for Web Push How payloads are encrypted for one browser.
- RFC 8292: VAPID for Web Push How an application server identifies itself.
- web.dev: Push notifications overview Google's end-to-end guide.
- WebKit blog: Web Push for Web Apps on iOS and iPadOS The Home Screen requirement and setup on Apple devices.
- WebKit blog: Meet Web Push Safari's userVisibleOnly rule and why silent pushes revoke a subscription.
- WebKit blog: Meet Declarative Web Push The declarative JSON format and how it falls back to a service worker.
- Chromium blog: Introducing quieter permission UI for notifications When Chrome switches to the quiet prompt.
Read next
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - WebhooksAn HTTP request that another company's server sends to yours the moment something happens, signed so you can trust it and retried until you answer: the first hop of many updates that end on a user's screen.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.