Back to Explore
GuideVibeFix 编辑部Updated Oct 14, 2026

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.

Flat illustration of a developer facing code and browser windows, symbolizing hands-on Web Push implementation

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:

Web Push push-chain architecture diagram: how the browser, push service, app server, and Service Worker collaborate
  • 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 showNotification to 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

  1. 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.
  2. Use mailto:ops@your-domain as the subject. This is the "application server identity" required by RFC 8292 and must be a valid mailto: or https: URL. A malformed subject (or a bare email address) gets you a flat 401 from some push services.
  3. 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:

Web Push subscription flow sequence diagram: 8 steps from user click to subscription stored in DB

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

  1. Chrome's iron rule: you MUST call showNotification inside the push handler. 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.
  2. event.waitUntil is not decoration. Service Workers are killed when idle and the handler is async; without waitUntil the browser may terminate the worker before showNotification completes, and the notification is lost.
  3. You must handle notification clicks, or clicks do nothing. Many tutorials write push but skip notificationclick — 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:

ApproachCode sizeMaintenanceFor whom
web-push library (recommended)~20 linesFollows npm updates; protocol details handled99% of vibe projects — just use it
Native implementation (hand-rolled signing + encryption)500+ linesECDH key agreement, HKDF, AES-GCM all on you; one wrong byte = 400Learners 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:

  1. Data-message pattern (most recommended): the push payload carries only a minimal identifier like { id, type }, no body text; the Service Worker fetches 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).
  2. Accept one "light" notification: use tag so same-type notifications replace each other ("3 new comments" stays one row), with renotify: false to avoid re-buzzing. Weak user perception, simple and reliable plumbing.
  3. Background sync (Chrome/Edge only, PWA-only): Periodic Background Sync lets 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:

  1. iOS / iPadOS 16.4 or later;
  2. your site must be a proper PWA: a manifest, display: standalone, a Service Worker;
  3. 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.

Web Push debugging decision flowchart: six steps from permission to Service Worker to subscription to status codes to iOS to system settings

Debug top-down using the chart above; 90% of issues land in this table:

SymptomMost likely causeFix
Subscribe button does nothing / no promptrequestPermission called outside a user gesture, or previously deniedBind to a button click; for denied, guide the user to browser settings
register('/sw.js') throwsNot HTTPS (and not localhost), cross-origin sw.js, wrong scopeUse localhost locally; serve sw.js same-origin from site root
subscribe() throws InvalidAccessErrorVAPID public key not converted to Uint8Array, or private key passed by mistakeConvert the public key with urlBase64ToUint8Array
Server gets 401 / 403Wrong VAPID private key, or malformed subject (not mailto:/https:)Recheck env vars; regenerate with generate-vapid-keys
Server gets 410 / 404Subscription expiredDelete it (section 8) — normal, not a bug
Server gets 413Payload over 4096 bytesSwitch to the data-message pattern (section 6)
Push succeeds but user receives nothing, no errorSW push listener missing/not wrapped in waitUntil; or OS Do-Not-DisturbBisect with DevTools simulated push; check OS notification settings
iPhone gets nothing, Android fineNot added to Home Screen, or iOS below 16.4Guide "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
  • ☐ subject is a valid mailto: address
  • ☐ requestPermission fires only after the user clicks "Enable notifications" — never on page load
  • ☐ denied state shows guidance (telling users where to re-enable in browser settings), not repeated prompts
  • ☐ sw.js is same-origin with site-wide scope; both push and notificationclick listeners written
  • ☐ The push handler wraps in event.waitUntil and always calls showNotification
  • ☐ Notifications carry a tag against 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.

Views 1Comments 0

Comments (0)

ME
0/1000
Loading comments...
Browse projectsPublish your project

Related articles

Laptop screen showing a CSV data import spreadsheet, cover image for the vibe project data import and export guide
Guide
Don't Wing Exports, Don't Crash on Imports: Data Import & Export in Practice for Vibe Projects

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.

Developer WorkflowTool TipsBackend Engineering
Calendar scheduling cover art: planner and world clock motif
Guide
Timezones Are the Graveyard of Booking Features: Calendar Scheduling for Vibe-Coded Projects

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.

AutomationBackend EngineeringProject Building