@mmmike/web-push
v1.3.0
Published
Zero-dependency Web Push (RFC 8291) for Cloudflare Workers, Deno, Bun, Node.js, and browsers — client subscribe + server send + VAPID.
Maintainers
Readme
@mmmike/web-push
The whole Web Push flow in one zero-dependency package that runs on every modern runtime.
Sending a push notification should not require Node, a compatibility shim, or three packages that each cover a third of the flow. This is Web Push (RFC 8291) built on the Web Crypto API that every modern runtime already ships: subscribe in the browser, send from the server, the same code either side, nothing to polyfill.
- All four pieces. VAPID key generation, a copy-paste service worker to display the notification, the client subscribe helpers, and the server send, one at a time or fanned out to a list.
- No Node built-ins, no polyfills. If it has
fetchandcrypto.subtle, it works. Nonode:crypto, nonode:https, no compat layer. Cloudflare Workers, Bun, Deno, Node, and the browser. - Ratified specs. RFC 8291
aes128gcmencryption and RFC 8292 VAPID, pinned byte-for-byte against RFC 8291's Appendix A test vector. - Small. Minified and gzipped per subpath entry: 1.0 kB for the browser client, 2.9 kB for the server. The badge above measures the whole package, ~3.6 kB.
Installation
npm install @mmmike/web-pushESM-only. import works everywhere, and require() works on Node 22.12+ via require(esm). Shipping both formats would let two copies of WebPushError coexist in one dependency tree and silently break instanceof against it.
Usage
Four steps: generate VAPID keys once, register a service worker, subscribe in the browser, send from the server.
1. Generate VAPID keys (once)
Run this once as a script and save the pair as secrets:
import { generateVapidKeys } from "@mmmike/web-push/vapid";
const { publicKey, privateKey } = await generateVapidKeys();
console.log({ publicKey, privateKey });2. The service worker
Push needs a service worker, because the browser wakes it to display the notification even when your page is closed. Without one registered, subscribe waits on navigator.serviceWorker.ready forever.
// sw.js
self.addEventListener("push", (event) => {
const { title, body, url, tag } = event.data.json();
event.waitUntil(self.registration.showNotification(title, { body, tag, data: { url } }));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url;
if (!url) return;
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
const open = clients.find((client) => new URL(client.url).pathname === url);
return open ? open.focus() : self.clients.openWindow(url);
}),
);
});This is the receiving end of PushPayload: title and body become the notification (only title is required), tag collapses duplicates, and url opens on click, focusing an already-open tab rather than stacking up new windows.
3. Client: subscribe
import { subscribe, sendSubscriptionToServer } from "@mmmike/web-push/client";
await navigator.serviceWorker.register("/sw.js");
const result = await subscribe(vapidPublicKey);
if (result.status === "subscribed") {
await sendSubscriptionToServer(result.subscription, "/api/push/subscribe");
}subscribe resolves to { status: "unsupported" } when the browser can't do push, { status: "denied" } when the user declines the permission prompt, and { status: "subscribed", subscription, isNew } otherwise. isNew is true when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one.
POST the subscription on every visit rather than gating on isNew. Your endpoint has to upsert by endpoint URL anyway, since the browser hands back the same subscription on every call, and gating means one failed upload strands a subscription your server never hears about. Use isNew for what it does tell you: counting fresh subscribes and key rotations.
Requirements:
- a secure context (HTTPS or
localhost) - a registered service worker
- on iOS, the site installed to the home screen. Safari exposes the Push API only to installed web apps, so
isPushSupported()reportsfalsein a plain iOS Safari tab.
Encrypted-payload push works in Chrome, Edge, Firefox, Opera and Samsung Internet, and in Safari 16+ on macOS 13+ and iOS 16.4+. isPushSupported() is the runtime check.
Rotating VAPID keys is handled for you: when the existing subscription was created with a different key, subscribe unsubscribes it and creates a fresh one (isNew: true), since the push service would reject the old one anyway. The one gap is a browser that doesn't expose which key a subscription was bound to (subscription.options.applicationServerKey is null): with nothing to compare against, the existing subscription is kept. If it was in fact bound to a retired key, every send to it fails with a 401/403 WebPushError, and nothing in that error names the rotation as the cause; if you rotate keys and then see those, this is where to look.
4. Server: send
import { sendPushNotification, WebPushError } from "@mmmike/web-push/send";
// Read these from wherever you saved the vapid secrets earlier:
// `env` bindings on a Worker, `process.env` on Node, `Deno.env.get` on Deno.
const vapid = { publicKey, privateKey, subject: "mailto:[email protected]" };
try {
const delivered = await sendPushNotification(
subscription, // PushSubscriptionData from the client
{ title: "Hello!", body: "You have a new message", url: "/messages", tag: "messages" },
vapid,
);
if (!delivered) {
// 404/410: the subscription is gone. Delete it from your store.
}
} catch (err) {
if (err instanceof WebPushError) {
// The push service rejected it. `err.statusCode` is its status, and
// `err.retryAfterMs` is the Retry-After header parsed to milliseconds
// (`err.retryAfter` keeps it verbatim). Back off, retry.
} else if (err instanceof TypeError) {
// `fetch` never reached the push service: DNS, TLS, timeout. Retryable.
} else {
// Your input: bad VAPID subject or keys, oversized payload, invalid topic
// or endpoint. Fix it, don't retry.
}
}The payload does not have to be a PushPayload object: wrap a payload you have already serialized in rawPayload(...) and the string or bytes are encrypted and sent verbatim, for service workers that read their own shape (see Migrating from web-push).
An optional fourth argument accepts ttl, vapidExpiration, urgency, topic, a logger, an abort signal, and a per-request timeoutMs. The timeout defaults to 30 seconds, so a push service that accepts the connection and never responds can't hold the socket indefinitely.
ttlandvapidExpirationare independent settings.ttltells the push service how long to keep retrying an undelivered message, and multi-day values are normal there.vapidExpirationis the auth token's lifetime, which RFC 8292 caps at 24 hours. Reuse yourttlfor it and every send past that cap comes back as a401.
The payload
tagand thetopicoption collapse in different places.tagtravels inside the payload and is read by your service worker: a new notification with the same tag replaces the one already showing on the device.topicis read by the push service: while the device is offline, a newer push with the same topic replaces the queued one (RFC 8030 §5.4). Topics are capped at 32 URL-safe base64 characters;topicFromStringderives a valid one from any string (topic: await topicFromString(\message:${id}`)`), keeping the raw key out of the plaintext header while staying deterministic, so collapse still works.
A complete deployable Worker lives in examples/cloudflare-worker/.
Sending to many subscriptions
sendPushBatch fans one notification out through a worker pool with bounded concurrency (default 100 in flight), so ten thousand subscriptions never become ten thousand simultaneous sockets. Per-subscription failures never reject the batch; the result sorts every subscription into delivered, gone, or failed:
import { sendPushBatch, WebPushError } from "@mmmike/web-push/send";
const { delivered, gone, failed } = await sendPushBatch(subscriptions, payload, vapid);
console.log(`delivered ${delivered} of ${subscriptions.length}`);
// gone: endpoints that answered 404/410. Delete them from your store.
await removeSubscriptions(gone);
// failed: every send that didn't deliver, with its error. A WebPushError
// carries statusCode and retryAfterMs, the inputs for your retry policy.
for (const { endpoint, error } of failed) {
if (error instanceof WebPushError && error.statusCode === 429) {
queueRetry(endpoint, error.retryAfterMs);
}
}Caller mistakes (a bad VAPID config, an oversized payload, an invalid topic) throw before anything is sent, rather than surfacing as ten thousand identical entries in failed. The optional fourth argument accepts everything sendPushNotification does, plus concurrency; aborting its signal stops the pool from starting new sends.
One efficiency comes free at this size: the VAPID JWT is signed once per push-service origin instead of once per message, since the token is scoped to the origin and valid for the whole batch. The ephemeral ECDH key pair stays fresh per message, as RFC 8291 requires.
Migrating from web-push
- import webpush from "web-push";
+ import { sendPushNotification } from "@mmmike/web-push/send";
- webpush.setVapidDetails(subject, publicKey, privateKey);
- await webpush.sendNotification(subscription, JSON.stringify({ title, body }));
+ await sendPushNotification(subscription, { title, body }, { subject, publicKey, privateKey });Three behavioural differences to know about:
- TTL defaults differ.
web-pushdefaults every send to four weeks; this package defaults to 24 hours. If you relied on the long default, pass{ ttl: 2419200 }explicitly. - Gone subscriptions resolve, not throw. HTTP 404/410 returns
falsehere, meaning delete the subscription, whereweb-pushrejects. Other push-service errors throwWebPushErrorin both libraries. - The typed payload is optional. Pass a
PushPayloadobject (title,body,url,tag, the shape the service worker above reads back withevent.data.json()) and it is JSON-serialized for you. Or wrap the pre-stringified payload you were passing toweb-pushinrawPayload(...): it is encrypted and sent byte-for-byte, your existing service worker stays the other half of that contract, andicon,badgeandactionsride along untouched. The wrapper is deliberate, there is no bare-string form: a misdirected string would type-check, deliver, and only fail insideevent.data.json()on the device, so opting out of the typed contract stays visible at the call site instead.
Endpoints are capability URLs
A subscription endpoint is a bearer secret: anyone holding the URL can post messages to that device until the subscription dies. Two habits follow.
Keep endpoints out of logs. WebPushError carries the full endpoint so you can route on it, but its toJSON truncates it, so a structured logger that serializes the error will not persist a pushable URL. If you log fields by hand, prefer statusCode and retryAfterMs.
Treat stored endpoints as untrusted outbound targets. The send functions POST to whatever endpoint the subscription contains, and the subscription came from a client. Non-https: endpoints are rejected (RFC 8030 §3 requires TLS), but no library can know which hosts are genuine push services. If a hostile client can register https://internal-service.local/hooks as its "push endpoint", your server becomes a proxy into networks it can reach and the attacker cannot. Where that matters to your threat model, allowlist the real push hosts at registration time:
| Browser | Endpoint host |
| -------------------------------- | ----------------------------- |
| Chrome, Edge, and other Chromium | fcm.googleapis.com |
| Firefox | *.push.services.mozilla.com |
| Safari on macOS and iOS | *.push.apple.com |
Hosts as of August 2026. Browsers can change services and smaller ones run their own (Samsung Internet, for example), so treat the list as a starting point and log rejections rather than dropping them silently.
API Reference
Client (@mmmike/web-push/client)
| Function | Description |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| isPushSupported() | Check if push is supported in this browser |
| getNotificationPermission() | Get current notification permission |
| requestNotificationPermission() | Request notification permission |
| subscribe(vapidPublicKey) | Subscribe to push, rotating a stale-key subscription; resolves to SubscribeResult |
| unsubscribe() | Unsubscribe from push; resolves to the endpoint (for the server prune) or null |
| subscribeToPush(vapidPublicKey) | Deprecated: renamed to subscribe(), same behavior |
| unsubscribeFromPush() | Deprecated: use unsubscribe(), which returns the endpoint instead of a boolean |
| getCurrentSubscription() | Get the existing subscription, if any |
| serializeSubscription(sub) | Convert subscription to JSON-safe format (throws if it has no p256dh/auth key) |
| sendSubscriptionToServer(sub, serverEndpoint) | POST subscription to your server |
| removeSubscriptionFromServer(subscriptionEndpoint, serverEndpoint) | DELETE subscription from your server |
Server (@mmmike/web-push/send)
| Export | Description |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| sendPushNotification(subscription, payload, vapid, options?) | Encrypt, sign, and send a push notification |
| sendPushBatch(subscriptions, payload, vapid, options?) | Fan out one notification with bounded concurrency; resolves to { delivered, gone, failed } |
| rawPayload(stringOrBytes) | Mark a payload as already serialized; sent verbatim in place of a PushPayload |
| topicFromString(input) | Derive a valid topic from any string: first 32 base64url characters of its SHA-256 |
| WebPushError | Thrown on push-service errors; carries statusCode, body, endpoint, retryAfter, retryAfterMs |
VAPID (@mmmike/web-push/vapid)
| Function | Description |
| ------------------------------- | ------------------------------------- |
| generateVapidKeys() | Generate an ECDSA P-256 key pair |
| createVapidJwt(options) | Create a VAPID JWT for authentication |
| uint8ArrayToUrlBase64(array) | Encode bytes to URL-safe base64 |
| urlBase64ToUint8Array(base64) | Decode URL-safe base64 to bytes |
Types
Every type ships with per-field documentation, so your editor is the reference. The names to reach for:
| Type | Purpose | Exported from |
| ---------------------- | ------------------------------------------------------------------------------------- | ------------------------ |
| PushSubscriptionData | A subscription in transit, endpoint plus the p256dh/auth keys | root, /send, /client |
| SubscribeResult | Outcome of subscribe: subscribed (with isNew), unsupported, or denied | root, /client |
| PushPayload | Notification contents: title (the only required field), body, click URL, grouping tag | root, /send |
| RawPushPayload | A caller-serialized payload from rawPayload, accepted wherever PushPayload is | root, /send |
| VapidConfig | Your VAPID key pair and contact subject | root, /send |
| SendPushOptions | Per-send tuning: TTL, VAPID expiry, urgency, topic, logging, cancellation | root, /send |
| SendPushBatchOptions | SendPushOptions plus the pool's concurrency bound | root, /send |
| SendPushBatchResult | Outcome of sendPushBatch: delivered count, gone to delete, failed to inspect | root, /send |
| Logger | Optional debug sink | root |
| VapidJwtOptions | Inputs to createVapidJwt for signing a token by hand | root, /vapid |
Why not web-push?
The popular web-push was the right library for the world it was built in, a world where a server meant Node on an EC2 and Node's crypto was the only crypto there was. It is still a fine choice and will continue working well, and it carries ~5M weekly downloads and years of production maturity.
But it is built on Node built-ins, crypto.createECDH for the key agreement and https.request for delivery, with no native Web Crypto or fetch path. On a non-Node runtime you are running a Node compatibility layer rather than the platform, and Cloudflare Workers is the sharpest example of what that costs.
Without the compatibility layer it does not build. On a Worker with no nodejs_compat flag, bundling fails with 28 module-resolution errors before workerd ever starts:
✘ [ERROR] Could not resolve "crypto"
node_modules/web-push/src/encryption-helper.js:3:23:
3 │ const crypto = require('crypto');
The package "crypto" wasn't found on the file system but is built into node.
- Add the "nodejs_compat" compatibility flag to your project.With the flag but a compatibility date before 2024-09-23, it bundles cleanly and then fails at send time, from unenv's stubbed HTTP client:
Error: [unenv] https.request is not implemented yet!
at Object.fn [as request] (.../index.js:67:27)
at WebPushLib.sendNotification (.../index.js:9347:14)With nodejs_compat and a current compatibility date, web-push does run on Workers (issue #718, "Cloudflare Worker support?", filed in 2022 and still open, tracks the history). That is Cloudflare's compatibility layer doing the work rather than web-push supporting the platform, and it is not free:
- You carry the
nodejs_compatflag and a compatibility date of 2024-09-23 or later. - The polyfilled
node:httpsandnode:cryptostack ships in your bundle, against a Worker's size budget: measured on the same wrangler scaffold,web-pushuploads 49.97 KiB gzipped where this package uploads about 3 KiB gzipped. It also brings 5 direct dependencies pulling in 16 packages. - Your crypto path runs through a shim rather than the platform's own Web Crypto.
- It is Cloudflare's fix specifically. Every other non-Node runtime needs its own Node-compatibility story, and
web-pushhas no native path on any of them.
There is also the client half. Every library in this space, web-push included, hands you a README snippet and has you call pushManager.subscribe() yourself. The call itself is five lines. The fiddly parts around it are the permission flow that starts from "default", the base64url encoding of the application server key, and the serialisation your server actually stores, since getKey() returns an ArrayBuffer rather than anything you can put in JSON. This package ships that half too, tested.
Comparison
Competitor figures below are as of 1.0.1's release, August 2026.
| | web-push | @pushforge/builder | @block65/webcrypto-web-push | @mmmike/web-push |
| ----------------------------------- | ------------------------------------------------------------------------------ | -------------------- | ----------------------------- | -------------------------------- |
| Runs on Workers / Deno / Bun / Edge | via Node compat (#718) | ✓ | ✓ | ✓ natively |
| Runs on Node.js | ✓ | ✓ | ✓ | ✓ |
| Client subscribe helpers | ✗ | ✗ | ✗ | ✓ |
| Sends the request | ✓ | ✗ builds only | ✗ builds only | ✓ |
| VAPID key generation | ✓ | CLI only | ✗ | ✓ |
| RFC 8291 payload (aes128gcm) | ✓ | ✗ draft-04 aesgcm | ✗ draft-04 aesgcm | ✓ |
| RFC 8292 VAPID (vapid t=…, k=…) | ✓ | ✓ | ✗ draft WebPush <jwt> | ✓ |
| Dependencies | 5 direct, 16 transitive | zero | 3 direct | zero |
| Maturity / ecosystem | high (3.5k★, ~5M/wk) | 39k/wk | 30k/wk, last release 2024-12 | new: 1.0.1, RFC 8291 test vector |
Neither edge-native alternative is on the ratified specs: both encrypt with the draft-04 aesgcm scheme, putting the salt and DH key in Encryption/Crypto-Key headers instead of RFC 8291's binary header block, and one still authenticates with the pre-standard Authorization: WebPush <jwt>.
What this package asks you to trust is deliberately small: no dependency tree, one auditable crypto file (src/encrypt.ts), and its output pinned byte-for-byte against the test vector published in RFC 8291 itself.
License
MIT
