Web push notifications with Node.js
The web-push package handles encryption and VAPID signing for you. With Express and a small database table, you can send standard push to every browser in under 50 lines.
- npm install web-push gives you sendNotification() with encryption and VAPID built in.
- Generate keys once with npx web-push generate-vapid-keys and keep the private key in env vars.
- Store each PushSubscription (endpoint + keys) server-side, keyed by endpoint.
- Delete subscriptions that return 404 or 410.
Install and configure
npm install web-push express
npx web-push generate-vapid-keys # prints a public and a private key once// push.js
const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:push@yoursite.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
module.exports = webpush;Save subscriptions with Express
const express = require('express');
const app = express();
app.use(express.json());
app.post('/api/push/subscriptions', async (req, res) => {
const { endpoint, keys } = req.body || {};
if (!endpoint || !keys?.p256dh || !keys?.auth) return res.sendStatus(400);
await db.subscriptions.upsert({ endpoint, p256dh: keys.p256dh, auth: keys.auth });
res.sendStatus(201);
});The browser side that produces this JSON is in the Push API example.
Send and clean up
const webpush = require('./push');
async function sendToAll(message) {
const payload = JSON.stringify(message); // { title, body, url, icon }
const subs = await db.subscriptions.all();
for (const s of subs) {
const subscription = { endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } };
try {
await webpush.sendNotification(subscription, payload, { TTL: 3600, urgency: 'normal' });
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 410) await db.subscriptions.remove(s.endpoint);
else console.error('push failed', err.statusCode, err.body);
}
}
}For large lists, send with limited concurrency (for example 50–100 in flight) and a queue, so a slow push service doesn’t block your app.
Useful options
| Option | Purpose |
|---|---|
TTL | Seconds to keep trying if the device is offline |
urgency | very-low … high |
topic | Replace an undelivered message with the same topic |
headers | Extra request headers if needed |
What these mean on the wire: the Web Push protocol.
A tiny concurrency limiter
async function sendAll(subs, payload, limit = 50) {
let i = 0;
const worker = async () => {
while (i < subs.length) {
const s = subs[i++];
try { await webpush.sendNotification(s, payload, { TTL: 3600 }); }
catch (e) { if (e.statusCode === 404 || e.statusCode === 410) await db.subscriptions.remove(s.endpoint); }
}
};
await Promise.all(Array.from({ length: limit }, worker));
}For bigger lists, move this into a job queue (BullMQ, a cron worker, etc.) and record sent/failed counts per campaign.
Retries and backoff
Treat responses in three groups: 201 done; 404/410 delete the subscription and never retry; 429 and 5xx retry later. Push services send a Retry-After header with 429 — respect it. A simple policy is three attempts with exponential backoff (for example 30 s, 2 min, 10 min) and a dead-letter log after that. Don’t retry 400 or 413: those mean the request itself is wrong (bad headers, payload too large) and will fail every time.
const res = await webpush.sendNotification(sub, payload, opts).catch(e => e);
if (res.statusCode === 404 || res.statusCode === 410) await db.remove(sub.endpoint);
else if (res.statusCode === 429 || res.statusCode >= 500) await queue.retry(sub, payload, Number(res.headers?.['retry-after']) || 60);A subscription table that scales
| Column | Why |
|---|---|
| endpoint (unique) | Natural key; upsert on every visit |
| p256dh, auth | Needed to encrypt each message |
| user_id (nullable) | Link to accounts for transactional sends |
| created_at, last_seen_at | Recency segments and cleanup |
| ua / platform | Debugging per browser, device segments |
| failures | Count consecutive 5xx to spot broken endpoints |
Index last_seen_at and user_id; most sends filter on one of them.
Design the payload once
Send small JSON — {"v":1,"title","body","url","image","tag"} — and let the worker build the notification. A version field lets a newer server talk to an older cached worker safely. Keep it under about 4 KB after encryption, and never put secrets or personal data in the payload: it’s encrypted in transit, but it ends up on a lock screen.
Production checklist
- Private key in env/secrets only; never in the client bundle.
- Rate-limit the subscription endpoint and validate input.
- Queue sends; retry 429/5xx with backoff; never retry 404/410.
- Log sent/failed counts per campaign; add click tracking via your notification URLs.
- Serve
sw.jsuncached from your root.
Rather not maintain this? Pusharoo gives you the same standards-based delivery with a dashboard, segments, schedules and automations, free (docs).
Questions
Does the web-push npm package need Firebase?
No. It speaks the standard Web Push protocol to every browser’s push service using VAPID.
Can I use web-push with TypeScript or ES modules?
Yes. Import it with import webpush from 'web-push' and use the same API; type definitions are available.
How do I send to thousands of subscribers quickly?
Use a job queue and limited concurrency, and remove expired subscriptions as you go so later sends are faster.
Can I use the web-push package in Cloudflare Workers or Deno?
The web-push package relies on Node’s crypto and HTTP modules. Some edge runtimes provide Node compatibility layers, but test carefully; libraries written for Web Crypto are often a better fit on the edge.
How do I set a notification image from Node?
Include an image URL in your JSON payload and pass it to showNotification({ image }) in the service worker. The server only sends data; the worker decides how it’s displayed.
Keep reading
The Web Push API in JavaScript: a working example
Register a worker, ask on click, subscribe with VAPID, store the subscription and send — complete code.
VAPID keys for web push, explained
What the key pair does, how the signed JWT works, how to generate keys and why rotation breaks lists.
Web push notifications in PHP
Install with Composer, create VAPID keys, store subscriptions, queue and flush, remove expired endpoints.
Web push notifications in React and Next.js
Service worker in public/, a subscribe hook, a route handler with web-push, env keys and iPhone notes.
More in Developers: Service worker push events, done right · The Web Push protocol, explained · Laravel browser push notifications (no Firebase) · Declarative Web Push, explained · Firebase web push vs. standard web push · Web push in Python with pywebpush · Django push notifications to the browser · Web push notifications in Ruby on Rails · Web push in Go with webpush-go · Angular push notifications with SwPush · Push notifications in Vue 3 and Nuxt · Push notifications in SvelteKit · All guides
Free for life. No limits.
Unlimited subscribers, campaigns and automations. No badges. About five minutes to install.