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.

Published October 2, 2026 Updated October 2, 2026 8 min read
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.

The push delivery pathYour server signs a message with its VAPID key and posts it to the subscription endpoint at the browser vendor's push service, which delivers it to the browser, which wakes the extension's service worker with a push event.Your serverVAPID-signed POSTPush servicevendor endpointBrowserone shared connectionwake the workerpush eventevent.dataHandlefetch details, update stateOptional UIbadge or notification
The worker sleeps until the push service delivers — no connection of your own to maintain.

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.

Subscribe once, receive manyOn install the worker subscribes and uploads the subscription; later the server pushes a small payload; the browser wakes the worker, which fetches details from the API and updates the badge.Service workerPush serviceYour APIsubscribe(VAPID key)PUT subscriptionpush {type:'item', id}push event (wakes worker)GET /items/{id}item JSON
Push carries a pointer; the worker fetches the content.

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: false from Chrome 121. Edge uses its own push service (WNS-backed) transparently. chrome.gcm remains 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.
Push options by engineAvailability of the Push API, silent pushes, chrome.gcm and the recommended fallback in Chrome, Firefox and Safari extensions.CapabilityChrome / EdgeFirefoxSafariPush API in extensionYes (121+)YesNoSilent pushesYesYes, with quota—chrome.gcmChrome onlyNoNoFallbackAlarm pollAlarm pollPoll on open / APNs via a…
Build push for Chrome and Firefox; keep a poll path for Safari.

Verification

  1. In the service worker console, run (await self.registration.pushManager.getSubscription()).toJSON() and confirm an endpoint and keys exist.
  2. Stop the worker from chrome://serviceworker-internals.
  3. 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"}').
  4. 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.

Other Core APIs & Cross-Browser Data Management Resources