Web Push from Zero to One: A Code-First Guide for Vibe Projects
Web Push looks like a half-hour job until production hits you with iOS no-delivery, VAPID 401s, and a rotting subscription table. This code sequel walks the full chain: VAPID key generation and safekeeping, permission timing, Service Worker push/click handling, server-side web-push in practice, iOS 16.4 adaptation, 410 subscription cleanup, and a debugging checklist — all code ready to ship.

Your vibe project is live, users sign up — and then vanish. It's usually not the product's fault; users simply forget to come back. Emails land in spam, SMS costs money per message and feels intrusive, while browser push (Web Push) is the cheapest re-engagement weapon an indie developer has: zero per-message cost, delivery even when your page is closed, one tap to land back on your page.
But Web Push's trap is that it looks simple: two frontend APIs, one POST from the server, seemingly done in half an hour. After launch you'll hit the real issues: iOS users getting nothing, 401s from misconfigured VAPID, a subscription table rotting with zombie rows, Chrome popping default notifications out of nowhere. This guide is the code sequel to our strategy-level Vibe Project Notification System Guide (channel selection, frequency control, unsubscribe design) — that one tells you whether and how to decide; this one only covers how to write the code, from VAPID key generation to production debugging, end to end.
1. See the Whole Chain First: Web Push Is a Four-Party Collaboration, Not a Frontend-to-Backend Call
First-timers often assume: "the server calls some browser API to send a notification." Wrong. The standard Web Push chain has four roles, all mandatory:
- Page JS: requests permission, registers the Service Worker, creates the subscription and reports it to your server. Its job ends the moment the user subscribes.
- Push Service: a public service run by the browser vendor — Chrome uses FCM, Firefox uses Autopush, Safari uses APNs. Your server POSTs messages to the subscription's endpoint, which points at these services. This is the one link you can't reach or control; when it breaks, all you get is a status code to guess from.
- App server: your territory. Signs with the VAPID private key, POSTs the encrypted payload to the endpoint, and maintains the subscription store (endpoint + p256dh + auth).
- Service Worker: a background script in the browser. Even with your page closed, as long as the browser runs, the push service can wake it up, and it calls
showNotificationto display the notification.
This picture saves you from the biggest misconception: the reliability ceiling of push lives with the push service and the user's device, not your code. What your code can guarantee is a correct subscription flow, correct signatures, and timely cleanup of dead subscriptions. You won't get 100% delivery — but you can make sure every failure tells you why.
2. VAPID Keys: Generate Once, Guard for a Decade
VAPID (RFC 8292) is Web Push's identity mechanism: every push your server sends carries a JWT signed with your private key in the request headers, and the push service only forwards after verifying the signature. Without VAPID, most push services reject you outright. One-line mental model: the VAPID public key is the "ID number" used at subscribe time; the private key is the "seal" used at send time.
Generating
Use the CLI bundled with the web-push npm package — one line (EC P-256 keypair, base64url-encoded):
npm install -g web-push
web-push generate-vapid-keys
# Sample output (yours will differ — generate your own, don't copy):
# Public Key:
# BKd3f9xQ2vH7... (about 87 characters)
# Private Key:
# qW8zLp2... (about 43 characters)
Storage: three iron rules
- The private key lives only in server-side environment variables — never in frontend code, never in Git. The public key can be public (the frontend needs it to subscribe). A leaked private key means anyone can send pushes impersonating your site. Rotate immediately if it leaks.
- Use
mailto:ops@your-domainas the subject. This is the "application server identity" required by RFC 8292 and must be a validmailto:orhttps:URL. A malformed subject (or a bare email address) gets you a flat 401 from some push services. - Stick with one keypair; don't rotate for fun. Subscriptions are bound to the applicationServerKey (VAPID public key) used at subscribe time. Change the key and stored subscriptions' signatures stop verifying — effectively forcing every user to re-subscribe. If you must rotate (e.g., a leak), do it properly: have the frontend compare the locally stored key fingerprint on each visit and auto
unsubscribe()+ re-subscribe()on mismatch; keep the old private key during the transition to keep signing for old subscriptions.
Environment variable example (Node server):
VAPID_PUBLIC_KEY=BKd3f9xQ2vH7...
VAPID_PRIVATE_KEY=qW8zLp2...
VAPID_SUBJECT=mailto:ops@example.com
3. The Subscription Flow: Ask for Permission at the Right Moment
Subscription is the only step that talks to the user directly, and it's where opt-in rates diverge the most. Here's the full sequence:
Three permission states — and denied is irreversible
Notification.permission has exactly three values: default (not asked yet), granted, denied. The critical fact: once the user clicks "Block", your code can never show the prompt again — requestPermission() just returns denied, and the user must flip it manually in browser settings. Every prompt is a one-shot opportunity; waste it and it's gone.
Hence the iron rule of timing: never prompt on page load. The right pattern is two-step — first render your own "Enable notifications" toggle/button explaining what they'll get ("we'll nudge you when your task finishes"), then call requestPermission() only after the user clicks it (a user gesture). In practice, "give a reason before asking" converts several times better than "prompt on entry." Frequency control and unsubscribe strategy are strategy-layer design — see section 6 of the Notification System Guide, not repeated here.
Complete subscription code (frontend)
// 1. The VAPID public key is base64url-encoded; convert to Uint8Array first (standard MDN recipe)
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
async function enablePush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
alert('This browser does not support Web Push');
return;
}
// 2. Register the Service Worker first (sw.js must be same-origin)
const registration = await navigator.serviceWorker.register('/sw.js');
// 3. Request permission AFTER a user gesture
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
alert('Notification permission was denied. Please enable it in your browser settings.');
return;
}
// 4. Subscribe at the push service. In Chrome, userVisibleOnly must be true
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('YOUR_VAPID_PUBLIC_KEY'),
});
// 5. Report the subscription to your server (endpoint + keys.p256dh + keys.auth)
await fetch('/api/push/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
});
alert('Subscribed!');
}
// Bind to YOUR button, not to page load
document.getElementById('btn-enable-push').addEventListener('click', enablePush);
On the server, store three fields per subscription: endpoint (the push URL; fixed-format string, works as a dedup key), keys.p256dh, keys.auth (for encryption). Also store userId and createdAt for targeted pushes and future cleanup. Make reporting idempotent (upsert on endpoint) — otherwise every button click mints another zombie subscription.
4. Service Worker: Receiving Pushes and Handling Clicks
sw.js is where half of all Web Push bugs live — it runs in an isolated worker thread with no DOM and no window, and you debug it through DevTools' Application panel. It has exactly two jobs: show a notification when a push arrives, and navigate correctly when the user clicks it.
// sw.js — serve from the site root so its scope covers the whole site
self.addEventListener('push', (event) => {
// The payload was encrypted by your server; the browser decrypts it for you
const data = event.data ? event.data.json() : {};
const title = data.title || 'New message';
// event.waitUntil: tells the browser "don't kill me until the notification is shown"
event.waitUntil(
self.registration.showNotification(title, {
body: data.body || '',
icon: '/icon-192.png', // large icon, 192x192 recommended
badge: '/badge-72.png', // Android status-bar icon, monochrome 72x72 works best
tag: data.tag || 'default', // same-tag notifications replace each other, no pile-up
renotify: false, // whether replacing re-alerts; default false
data: { url: data.url || '/' }, // click target, stored in notification.data
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close(); // dismiss the notification first
const url = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
// If the target page is already open, focus it instead of opening a new tab
for (const client of list) {
const path = new URL(client.url).pathname;
if (path === new URL(url, self.location.origin).pathname && 'focus' in client) {
return client.focus();
}
}
return clients.openWindow(url);
})
);
});
Three traps 90% of people fall into
- Chrome's iron rule: you MUST call
showNotificationinside thepushhandler. Receive a push and show nothing (wanting "silent push", see section 6), and Chrome pops a default system notification for you ("This site has been updated in the background") — terrible UX. This "user-visible" enforcement is baked into the browser, not a bug. event.waitUntilis not decoration. Service Workers are killed when idle and the handler is async; withoutwaitUntilthe browser may terminate the worker beforeshowNotificationcompletes, and the notification is lost.- You must handle notification clicks, or clicks do nothing. Many tutorials write
pushbut skipnotificationclick— the user taps and nothing happens. That's the last mile of the re-engagement chain snapping.
5. Server-Side Sending: web-push Library (Recommended) vs. Native Implementation (Compared)
The server's job: sign each push with the VAPID private key (JWT, ES256), encrypt the payload with the subscription's p256dh/auth (RFC 8291 aes128gcm), POST to the endpoint. Conclusion first, comparison second:
| Approach | Code size | Maintenance | For whom |
|---|---|---|---|
web-push library (recommended) | ~20 lines | Follows npm updates; protocol details handled | 99% of vibe projects — just use it |
| Native implementation (hand-rolled signing + encryption) | 500+ lines | ECDH key agreement, HKDF, AES-GCM all on you; one wrong byte = 400 | Learners studying the protocol; not for production |
Hand-rolling means: signing an ES256 VAPID JWT yourself (aud = push service origin, exp expiry), X25519 ECDH to derive a shared secret, HKDF to derive the content-encryption key, AES-128-GCM to encrypt the payload and assembling the aes128gcm framing per RFC 8291. Get one byte order wrong and the push service replies with a dry 400, telling you nothing about which byte. An indie developer's time belongs in the product, not in arm-wrestling encryption bytes.
web-push in practice (Node.js)
const webpush = require('web-push');
// Initialize once: subject must be a mailto: or https: URL
webpush.setVapidDetails(
process.env.VAPID_SUBJECT, // mailto:ops@example.com
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// Push to a single subscription
async function pushTo(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload), {
TTL: 3600, // keep for 1h while offline; TTL: 0 means "drop if not immediately deliverable"
});
} catch (err) {
// 410 / 404: subscription expired (user wiped data / browser reclaimed it) — delete it, see section 8
if (err.statusCode === 410 || err.statusCode === 404) {
await db.pushSubscriptions.delete({ where: { endpoint: subscription.endpoint } });
return 'expired';
}
// 401 / 403: VAPID misconfigured — fix the config first, don't delete the subscription
// 413: payload over 4096 bytes — slim it down and retry
throw err;
}
}
// Bulk push: allSettled so one failure doesn't sink the rest; too much
// concurrency gets you rate-limited by the push service
async function pushToMany(subscriptions, payload) {
const results = await Promise.allSettled(
subscriptions.map((s) => pushTo(s, payload))
);
const failed = results.filter((r) => r.status === 'rejected');
console.log(`Pushed ${results.length}, failed ${failed.length}`);
}
Production notes: payload cap is 4096 bytes (pre-encryption) — over it is an instant 413, so don't stuff large objects in there; set TTL by message shelf life — verification codes get TTL: 0 (stale = useless), regular notifications 3600–86400; don't fire thousands of subscriptions through a naked Promise.all — batch them (50–100 per batch with pauses) or the push service will throttle you.
6. The Truth About "Silent Push": Chrome Forbids It — Accept It, Then Work Around It
A common ask: "I just want to quietly update a badge or sync data without bothering the user." The harsh truth: there is no true silent push in Web Push on Chrome. As section 4 noted, skipping showNotification in the push handler makes Chrome pop a default notification for you — the "user-visible" requirement is hard-coded in the browser. No workaround.
Three honest alternatives, in recommended order:
- Data-message pattern (most recommended): the push payload carries only a minimal identifier like
{ id, type }, no body text; the Service Workerfetches the fresh details from your server before displaying. This dodges the 4KB cap and guarantees the user always sees current content (a push that sat in the offline queue for half an hour may carry a stale body). - Accept one "light" notification: use
tagso same-type notifications replace each other ("3 new comments" stays one row), withrenotify: falseto avoid re-buzzing. Weak user perception, simple and reliable plumbing. - Background sync (Chrome/Edge only, PWA-only):
Periodic Background Synclets an installed PWA wake periodically to pull data — genuinely silent. But it requires the PWA installed, the browser dictates the minimum interval (usually hours), and Safari/Firefox don't support it — progressive enhancement only, never the main strategy.
// Data-message pattern: SW receives an id, fetches details, then displays
self.addEventListener('push', (event) => {
const { id, type } = event.data.json();
event.waitUntil(
fetch(`/api/notifications/${id}`)
.then((r) => r.json())
.then((detail) => self.registration.showNotification(detail.title, {
body: detail.body,
tag: type, // collapse by type
data: { url: detail.url },
}))
);
});
7. iOS 16.4+: It Works Now — With Strings Attached
Before 2023, "iOS doesn't support Web Push" was gospel; iOS 16.4 loosened it, but the conditions are strict — every one is mandatory:
- iOS / iPadOS 16.4 or later;
- your site must be a proper PWA: a manifest,
display: standalone, a Service Worker; - the user must add your site to the Home Screen and open it from the icon — subscribing from a page opened directly in Safari delivers nothing.
macOS is far more lenient: since macOS Ventura + Safari 16, subscribing right inside Safari works. So the same code works on a desktop Mac but needs the Add-to-Home-Screen flow on iPhone — this difference must be communicated to users at the product level, or you'll (as your own support team) drown in "I enabled notifications, why nothing arrives?"
Detection + fallback code
function pushCapability() {
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true; // legacy iOS check
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS && !isStandalone) {
// iPhone opened directly in Safari: guide them to "Share → Add to Home Screen"
return { ok: false, reason: 'ios-not-installed' };
}
if (!('PushManager' in window)) return { ok: false, reason: 'unsupported' };
return { ok: true };
}
// At the call site: show different guidance per reason, not a dead-end "unsupported"
const cap = pushCapability();
if (!cap.ok && cap.reason === 'ios-not-installed') {
showGuide('iPhone users: please "Add to Home Screen" first, then enable push');
}
Fallback strategy in one line: for iOS users who can't receive push, use an in-app message center + email fallback (the "channel selection decision table" is section 2 of the Notification System Guide). Web Push is one re-engagement channel, not the whole strategy — never put all eggs in one basket.
8. Subscription Expiry and 410 Gone Cleanup: Skip It and Your Table Rots
Subscriptions aren't forever. Users wipe browser data, uninstall the PWA, browsers reclaim long-idle subscriptions — all invalidate endpoints. There's a standard signal for dead subscriptions: the push returns 410 Gone (or 404). RFC 8030 says a 410 means "this subscription will never be valid again," and there's exactly one correct action: delete it.
// Cleanup logic inside the push worker (extends pushTo from section 5)
async function pushTo(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload), { TTL: 3600 });
await db.pushSubscriptions.update({
where: { endpoint: subscription.endpoint },
data: { lastSuccessAt: new Date(), failCount: 0 },
});
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
// Permanently dead: delete, don't retry
await db.pushSubscriptions.delete({ where: { endpoint: subscription.endpoint } });
return 'expired';
}
// Other errors (network jitters / throttling): count failures, review after N consecutive ones
await db.pushSubscriptions.update({
where: { endpoint: subscription.endpoint },
data: { failCount: { increment: 1 } },
});
throw err;
}
}
Recommended cleanup strategy is "lazy first, sweeps second": delete on 410/404 right where the push happens (zero extra cost), plus a monthly sweep deleting subscriptions with excessive consecutive failCount or lastSuccessAt older than 90 days. A 100k-subscription table with no cleanup can be half zombies within a year — every bulk push then burns money (well, rate-limit quota and your server's time) on thin air.
There's also "soft expiry": the user never deleted the subscription, dozens of pushes fulfilled in a row, but they never click. That's not a technical problem, it's a content problem — your notifications aren't valuable to them. The fix is lower frequency or guiding them to unsubscribe, not more blasting. For "what to push and how often" strategy, again see the Notification System Guide.
9. Local Debugging and the Troubleshooting Checklist
Environment first: Service Workers require a secure context, and localhost counts as one — so http://localhost:3000 works fine for local debugging. But testing on a phone (e.g., iOS) over http://192.168.x.x won't — use mkcert for a local HTTPS cert, or expose localhost as a public HTTPS domain via something like Cloudflare Tunnel.
The most efficient locator is Chrome DevTools: Application → Service Workers → Push lets you inject a simulated push straight into your SW, bypassing the entire "server → push service" leg. If the simulated push displays but real pushes don't, the problem is server signing or subscription data; if even the simulated push won't display, it's SW code or permissions — bisection in one step.
Debug top-down using the chart above; 90% of issues land in this table:
| Symptom | Most likely cause | Fix |
|---|---|---|
| Subscribe button does nothing / no prompt | requestPermission called outside a user gesture, or previously denied | Bind to a button click; for denied, guide the user to browser settings |
register('/sw.js') throws | Not HTTPS (and not localhost), cross-origin sw.js, wrong scope | Use localhost locally; serve sw.js same-origin from site root |
subscribe() throws InvalidAccessError | VAPID public key not converted to Uint8Array, or private key passed by mistake | Convert the public key with urlBase64ToUint8Array |
| Server gets 401 / 403 | Wrong VAPID private key, or malformed subject (not mailto:/https:) | Recheck env vars; regenerate with generate-vapid-keys |
| Server gets 410 / 404 | Subscription expired | Delete it (section 8) — normal, not a bug |
| Server gets 413 | Payload over 4096 bytes | Switch to the data-message pattern (section 6) |
| Push succeeds but user receives nothing, no error | SW push listener missing/not wrapped in waitUntil; or OS Do-Not-Disturb | Bisect with DevTools simulated push; check OS notification settings |
| iPhone gets nothing, Android fine | Not added to Home Screen, or iOS below 16.4 | Guide "Add to Home Screen"; email fallback for older versions |
10. Pre-Launch Checklist (Copy-Paste Ready)
- ☐ VAPID private key exists only in server env vars — unfindable in frontend code or Git history
- ☐
subjectis a validmailto:address - ☐
requestPermissionfires only after the user clicks "Enable notifications" — never on page load - ☐
deniedstate shows guidance (telling users where to re-enable in browser settings), not repeated prompts - ☐
sw.jsis same-origin with site-wide scope; bothpushandnotificationclicklisteners written - ☐ The
pushhandler wraps inevent.waitUntiland always callsshowNotification - ☐ Notifications carry a
tagagainst pile-up; clicks correctly focus/open the target page - ☐ Subscription reporting upserts on
endpoint— no duplicates - ☐ Push error handling covers 410/404 deletion, 401/403 alerting, 413 slimming
- ☐ iOS users get "Add to Home Screen" guidance + email fallback
- ☐ Bulk pushes use
allSettled+ batching — one failure can't sink the batch
Closing: Web Push Is a Subscription Asset, Not a Send Function
Looking back at the chain, the expensive part was never the code — 20 lines of server code in an afternoon. The expensive part is the subscription asset: behind every granted is a real user's trust (they could have clicked Block), and every zombie subscription is tech debt from a 410 you never handled. Guard the VAPID key, time the permission ask right, clean expired subscriptions — do those three and Web Push becomes the highest-ROI re-engagement channel in your vibe project.
One last boundary check: this guide only solves "shipping the Web Push chain in code." For "which messages deserve a push, how many per day, how to design the unsubscribe page" — that's strategy; head back to the Vibe Project Notification System Guide. Code lets you send notifications; strategy decides whether users keep receiving them.
Comments (0)
Related articles

Import is the dirty-data inlet, export is the data-sovereignty outlet. This guide covers the three-stage import pipeline (stage, validate and preview, confirm), five validation categories, idempotent upserts, streaming export, async queues with signed links, and a ten-item anti-pattern list — with copy-pasteable TypeScript snippets.

Backups answer whether data is lost; disaster recovery answers how much comes back and how fast. This hands-on guide covers RPO/RTO targets, a five-scenario failure matrix, copy-pasteable PITR setups for Postgres, Supabase, Neon and SQLite, automated weekly restore drills, a one-page runbook template, and degradation switches.

DST switchover days, cross-timezone display chaos, broken recurrence rules — booking looks simple and hides the deepest pits. This guide covers the UTC-storage iron rule, the IANA timezone database, floating vs. absolute time, RRULE, transactional conflict detection, and 12 mandatory test cases for a booking system that survives daylight saving.