npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@mmmike/web-push

The whole Web Push flow in one zero-dependency package that runs on every modern runtime.

npm minzipped size CI license

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 fetch and crypto.subtle, it works. No node:crypto, no node:https, no compat layer. Cloudflare Workers, Bun, Deno, Node, and the browser.
  • Ratified specs. RFC 8291 aes128gcm encryption 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-push

ESM-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() reports false in 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.

ttl and vapidExpiration are independent settings. ttl tells the push service how long to keep retrying an undelivered message, and multi-day values are normal there. vapidExpiration is the auth token's lifetime, which RFC 8292 caps at 24 hours. Reuse your ttl for it and every send past that cap comes back as a 401.

The payload tag and the topic option collapse in different places. tag travels 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. topic is 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; topicFromString derives 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-push defaults 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 false here, meaning delete the subscription, where web-push rejects. Other push-service errors throw WebPushError in both libraries.
  • The typed payload is optional. Pass a PushPayload object (title, body, url, tag, the shape the service worker above reads back with event.data.json()) and it is JSON-serialized for you. Or wrap the pre-stringified payload you were passing to web-push in rawPayload(...): it is encrypted and sent byte-for-byte, your existing service worker stays the other half of that contract, and icon, badge and actions ride along untouched. The wrapper is deliberate, there is no bare-string form: a misdirected string would type-check, deliver, and only fail inside event.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_compat flag and a compatibility date of 2024-09-23 or later.
  • The polyfilled node:https and node:crypto stack ships in your bundle, against a Worker's size budget: measured on the same wrangler scaffold, web-push uploads 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-push has 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