Receiving Server Push in an Extension
Wake an MV3 extension from your server with the Push API: subscribing from the service worker with userVisibleOnly false, VAPID keys, handling push events, chrome.gcm, and Firefox and Safari support.
Table of Contents
You want the extension to react within seconds when something happens on your server — a new message, a finished export, a shared item — without polling every minute and without holding a WebSocket open all day. Web pages solve this with the Push API, and since Chrome 121 extension service workers can too, with one important relaxation: extensions may receive silent pushes that do not show a notification. This guide covers subscribing, sending, and handling pushes, plus what to do in engines that do not support them. It belongs to network requests and backend sync.
Why push fits the service worker model
Polling and sockets both fight the MV3 lifecycle: polling wakes the worker on a schedule whether or not anything changed, and a socket keeps it awake whether or not anything is happening. Push inverts that. The browser maintains one connection to its vendor’s push service for all sites and extensions; your server sends a message to the push service, which forwards it to the browser, which wakes your worker with a push event. Between pushes the worker sleeps. For web pages, Chrome requires every push to display a notification (userVisibleOnly: true) to prevent silent tracking; extensions are exempt from that rule from Chrome 121, which makes push usable as a pure data channel.
Step-by-step: push into a Chrome extension
1. Generate VAPID keys for your server
1npx web-push generate-vapid-keys --json > vapid.json
2# { "publicKey": "BEl6…", "privateKey": "x9q…" }
Execution context: your development machine, once. The public key ships in the extension; the private key stays on the server and signs every push. Rotating the key pair invalidates every existing subscription, so treat it like any long-lived server secret.
2. Subscribe from the service worker
1// sw.js
2const VAPID_PUBLIC = "BEl6…"; // base64url public key
3
4export async function ensurePushSubscription() {
5 let sub = await self.registration.pushManager.getSubscription();
6 if (!sub) {
7 sub = await self.registration.pushManager.subscribe({
8 userVisibleOnly: false, // allowed for extensions from Chrome 121
9 applicationServerKey: VAPID_PUBLIC,
10 });
11 }
12 await api("/v1/push-subscriptions", { method: "PUT", body: sub.toJSON() });
13 return sub;
14}
15
16chrome.runtime.onInstalled.addListener(ensurePushSubscription);
17chrome.runtime.onStartup.addListener(ensurePushSubscription);
Execution context: the service worker, where self.registration is the extension’s own registration. No notifications permission or user prompt is required for userVisibleOnly: false in an extension. Re-sending the subscription on every startup is cheap and heals the server’s copy if it was lost; the server should upsert by endpoint. Chrome versions before 121 reject userVisibleOnly: false; gate on minimum_chrome_version or catch and fall back to polling.
3. Send a push from the server
1// server (Node) — web-push library
2import webpush from "web-push";
3webpush.setVapidDetails("mailto:ops@acme.example", vapid.publicKey, vapid.privateKey);
4
5export async function notifyUser(userId, payload) {
6 for (const sub of await db.subscriptionsFor(userId)) {
7 try {
8 await webpush.sendNotification(sub, JSON.stringify(payload), { TTL: 3600, urgency: "normal" });
9 } catch (err) {
10 if (err.statusCode === 404 || err.statusCode === 410) await db.deleteSubscription(sub.endpoint);
11 }
12 }
13}
Execution context: your backend. Payloads are encrypted end-to-end by the library and limited to about 4 KB; send an identifier and let the worker fetch details. TTL controls how long the push service holds the message if the browser is offline. A 404 or 410 means the subscription is gone — the user uninstalled, cleared data, or the browser rotated it — and should be deleted.
4. Handle the push event
1// sw.js — top level
2self.addEventListener("push", (event) => {
3 const msg = event.data?.json() ?? {};
4 event.waitUntil(handlePush(msg));
5});
6
7async function handlePush(msg) {
8 if (msg.type === "item") {
9 const item = await api(`/v1/items/${encodeURIComponent(msg.id)}`);
10 await cacheItem(item);
11 const { unread = 0 } = await chrome.storage.local.get("unread");
12 await chrome.storage.local.set({ unread: unread + 1 });
13 await chrome.action.setBadgeText({ text: String(unread + 1) });
14 }
15}
Execution context: the service worker. The push listener is a standard service worker event registered on self, not a chrome.* event, but the same top-level rule applies: register it during the first synchronous pass. event.waitUntil extends the event’s lifetime until your async work finishes, within the usual limits. Because the user sees no notification, use the badge or in-extension state to surface the update.
5. Handle subscription changes
1self.addEventListener("pushsubscriptionchange", (event) => {
2 event.waitUntil(ensurePushSubscription());
3});
Execution context: the service worker. Browsers may rotate or expire subscriptions; this event fires when that happens, and resubscribing keeps delivery working. Not every engine fires it reliably, which is why the startup resubscription in step 2 also exists.
6. Design payloads for loss, duplication and reordering
Push delivery is best-effort. The push service may drop a message whose TTL expires while the laptop is asleep, deliver the same message twice after a reconnect, or deliver two messages out of order. A handler that increments a counter on every push will drift; one that treats each push as “something changed, go and look” stays correct.
1async function handlePush(msg) {
2 // Treat the push as a hint, not as the data itself
3 const { lastSeq = 0 } = await chrome.storage.local.get("lastSeq");
4 if (typeof msg.seq === "number" && msg.seq <= lastSeq) return; // duplicate or stale
5 const changes = await api(`/v1/changes?since=${lastSeq}`);
6 await applyChanges(changes.items);
7 await chrome.storage.local.set({ lastSeq: changes.seq });
8 await chrome.action.setBadgeText({ text: changes.unread ? String(changes.unread) : "" });
9}
Execution context: the service worker. The server attaches a monotonically increasing sequence number to each push and answers “what changed since N”; the badge is set from the server’s count rather than computed by incrementing. A missed push is then repaired by the next push, by the startup reconciliation, or by a low-frequency alarm — whichever comes first. This is the same cursor idea used for sync, and it makes the push channel a pure latency optimisation rather than a source of truth.
Keep an eye on volume, too. Browsers rate-limit silent pushes they consider abusive, and Firefox applies an explicit quota to background messages. Coalesce server-side: if ten items arrive within a few seconds, send one push, not ten.
The chrome.gcm alternative
Chrome also offers chrome.gcm, an older extension-only API built on Firebase Cloud Messaging. It needs the gcm permission and a Firebase project, registers with chrome.gcm.register(senderIds), and delivers through chrome.gcm.onMessage. It works only in Chrome and Chromium browsers that ship Google’s services. For new code the standard Push API is preferable: it uses the same VAPID setup as your web app, works in Firefox, and does not tie you to a Firebase project.
Cross-browser variation
- Chrome / Edge: Push API in extension service workers with
userVisibleOnly: falsefrom Chrome 121. Edge uses its own push service (WNS-backed) transparently.chrome.gcmremains available. - Firefox: supports the Push API in extensions through Mozilla’s push service. Silent pushes from extensions are permitted, subject to quota rules for background messages — test that your volume stays within them.
- Safari: Safari web extensions do not receive web push. Fall back to polling when an extension page opens, or deliver through the containing app with Apple Push Notification service and native messaging.
Verification
- In the service worker console, run
(await self.registration.pushManager.getSubscription()).toJSON()and confirm an endpoint and keys exist. - Stop the worker from
chrome://serviceworker-internals. - Send a test push from the server (or with
npx web-push send-notification --endpoint=… --key=… --auth=… --vapid-pubkey=… --vapid-pvtkey=… --payload='{"type":"item","id":"42"}'). - Confirm the worker starts, the badge increments, and the item appears in storage — all without any notification being shown.
FAQ
Do I need the notifications permission?
Not for silent pushes in an extension. You need it only if the push handler calls chrome.notifications.create or registration.showNotification.
How large can a push payload be?
About 4 KB after encryption overhead. Send an id and a type, and fetch the rest from your API in the handler.
What happens if the browser is closed when I push?
The push service holds the message for up to its TTL and delivers it when the browser reconnects. Messages beyond the TTL are dropped, so the worker should also reconcile with the server on startup.
Related
- Using WebSockets from an MV3 service worker — the option for while the UI is open.
- Updating the toolbar badge from the service worker — surfacing silent pushes.
- Syncing extension data with a backend API — reconciling after missed pushes.
- Network requests and backend sync — the parent topic.