#React#TypeScript

Web Push and Service Workers: Native Browser Push Notifications

Web Push lets your site send app-like notifications even when the user has no page open. This article explains the three-part stack (VAPID keys + Push API + Service Worker), the subscribe/send/receive flow, permission-request UX, and why delivery works when the browser is not open.

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

Web Push and Service Workers: Native Browser Push Notifications

Let us start with a question that has been asked countless times: "Can my website still notify users after they close the browser tab?"

Yes. This is not PWA mysticism or some form of "background polling" sorcery. It is a W3C standard called Web Push. It gives your website push capabilities almost equivalent to those of a native app, including system-level notification center entries and lock-screen alerts. Even if the user quits the browser entirely, the message can still be delivered the next time it opens.

The tradeoff is that this flow is more complex than you might expect. It involves three independent specifications, an asymmetric key pair, a Service Worker running in the browser background, and push services maintained by individual browser vendors (FCM / APNs / Mozilla AutoPush). Misunderstand any one component, and you will encounter the classic "works locally, stays silent in production" problem.

This article explains all of it and provides three ready-to-use code samples for frontend subscription, server-side sending, and Service Worker reception.


1. What Web Push Actually Solves

Before Web Push, websites had only two poor options for implementing "notifications":

  1. In-page badges / Toasts: If the user did not have the page open, they saw nothing. These were essentially fake notifications driven by page refreshes.
  2. Persistent WebSocket connections: These still required an open page, and you had to maintain the push infrastructure yourself, including connection management, reconnection, and multi-device synchronization.

The fundamental difference with Web Push is that it does not depend on your page being open or on your server maintaining a persistent connection to the browser. Once the user subscribes, your server only needs to send a message to the browser's push service (FCM / APNs / Mozilla). Delivery from that point onward is entirely the responsibility of the browser and operating system. When the browser receives the message, it awakens the Service Worker, which decides how to call showNotification.

This produces three product-level changes:

  • A fundamental improvement in reach: Users do not need to remain on your site. Abandoned-cart reminders, price changes, and message replies can reach them proactively, just like WeChat messages.
  • Offline delivery: Messages first reach the browser's push service and are delivered when the browser comes online. You do not need to know when the user is online.
  • Efficient use of battery and bandwidth: No heartbeat or polling is required. A system-level push channel is far more efficient than an application-level persistent connection.

Do not rush to production, though. The engineering complexity is real. Let us break it down step by step.


2. The Three-Part Stack: VAPID + Push API + Service Worker

Web Push is an orchestration of three independent specifications. If any one is missing, nothing works. Understanding their responsibilities is a prerequisite for all the code that follows.

1. VAPID: Application Identity Keys

VAPID (Voluntary Application Server Identification) is an identity protocol based on asymmetric cryptography. It answers one critical question: how does the browser's push service know that a push request came from your website rather than someone impersonating it?

VAPID gives you a pair of keys:

  • Private key: Stays on your server, is never exposed, and signs every push request.
  • Public key: Exposed to the frontend because it is public by design. The frontend gives it to the browser during subscription, and the browser passes it to the push service so later request signatures can be verified.

Once this key pair changes, every existing subscription becomes invalid because those subscriptions are bound to the old public key. Treat the VAPID private key like a database password: store it in an environment variable, put it in a secrets manager, and include it in backups, but never commit it to Git.

2. Push API: Browser-Side Subscription and Reception

The Push API is the interface browsers expose to frontend code. It has two core responsibilities:

  • PushManager.subscribe(): The frontend calls it to create a subscription (PushSubscription) and obtain a unique endpoint URL and encryption keys.
  • The Service Worker listens for the push event: After the push service delivers a message to the browser, the browser awakens the SW and fires this event.

A PushSubscription object looks like this. It is the delivery address for the entire flow:

ts
{
  endpoint: "https://fcm.googleapis.com/fcm/send/eqXAp...",
  keys: {
    p256dh: "BLN...=",   // Client public key used to encrypt the payload
    auth: "xR..."          // Authentication key
  }
}

The endpoint belongs to the browser vendor's push service. Chrome and Edge use FCM, Safari uses APNs, and Firefox uses Mozilla AutoPush. You cannot choose the service; the browser decides. This is why cross-browser testing matters.

3. Service Worker: Background Handler

A Service Worker (SW) is a script that the browser runs on an independent background thread. It is the only place that can act after a push arrives.

The key point is that the SW remains available when the page is closed, or is awakened by the push service. This is the underlying mechanism that allows users to receive pushes without an open tab. After receiving the push event, the SW calls self.registration.showNotification(...) to display a system notification. This step is mandatory: the Web Push specification requires every push to produce a visible notification through userVisibleOnly: true; otherwise, the browser treats it as a prohibited silent push.

The three components can be summarized in one sentence: VAPID proves who you are, the Push API handles subscription and delivery, and the Service Worker handles reception and display.


3. Subscription Flow: Frontend Code

Subscription is a carefully ordered sequence: request permission → register the SW → call subscribe → send the subscription object to the server for storage.

ts
// lib/web-push.ts - Browser-side helper functions

/** VAPID public key (note: the public key can and should be exposed to the frontend) */
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;

/** Convert a base64url string into the Uint8Array required by subscribe */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(base64);
  const output = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; ++i) output[i] = raw.charCodeAt(i);
  return output;
}

/** Entry point: request permission + subscribe + report to the backend */
export async function subscribeToPush(): Promise<PushSubscription | null> {
  // 1. Capability check: reject iOS Safari and older browsers immediately
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.warn('[push] This browser does not support Web Push');
    return null;
  }

  // 2. Request permission - note: trigger this from a user gesture, not when the page loads
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.info('[push] The user did not grant notification permission');
    return null;
  }

  // 3. Register the Service Worker (use ready to wait for activation)
  const reg = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;

  // 4. Reuse an existing subscription to avoid creating duplicates
  const existing = await reg.pushManager.getSubscription();
  const subscription =
    existing ??
    (await reg.pushManager.subscribe({
      userVisibleOnly: true, // Required by the specification: a notification must be displayed
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
    }));

  // 5. Send the subscription object to the backend for persistence (one per user/device)
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

Several easily overlooked details matter:

  • You must check getSubscription() first. Creating a new subscription every time can leave dozens of records for the same user in your database, causing the push service to deliver the same message dozens of times. APNs / FCM may also label you as a spam source.
  • userVisibleOnly: true is mandatory. This is the promise Chrome requires: every push will display a notification. Safari is even stricter and rejects the subscription outright if this requirement is violated.
  • applicationServerKey is the VAPID public key, but it must be provided as a Uint8Array; passing the base64 string directly produces an error.

After receiving the subscription object, the server should bind it to the currently authenticated user and store it. A typical PushSubscription table contains userId / endpoint / p256dh / auth / createdAt, with endpoint as the unique key so renewed subscriptions from the same device can be upserted.


4. Sending Flow: Server-Side Code

The server's job sounds simple: "Send a POST request to an endpoint." But that includes both encryption and signing. Implementing those by hand is impractical, so use web-push, the de facto standard library.

ts
// app/api/push/send/route.ts - Next.js Route Handler
import webPush from 'web-push';
import { NextRequest, NextResponse } from 'next/server';
import { getSubscriptionsByUser } from '@/lib/push-store';

// One-time configuration: tell web-push your VAPID identity
webPush.setVapidDetails(
  'mailto:[email protected]', // Contact email used if the push service encounters a problem
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
);

export async function POST(req: NextRequest) {
  const { userId, title, body, url } = await req.json();
  const subscriptions = await getSubscriptionsByUser(userId);
  if (subscriptions.length === 0) {
    return NextResponse.json({ sent: 0, reason: 'no-subscription' });
  }

  const payload = JSON.stringify({ title, body, url });

  // Send concurrently and catch each result separately; one failure must not break the entire batch
  const results = await Promise.allSettled(
    subscriptions.map((sub) =>
      webPush.sendNotification(
        {
          endpoint: sub.endpoint,
          keys: { p256dh: sub.p256dh, auth: sub.auth },
        },
        payload,
        {
          TTL: 60 * 60 * 24, // 24 hours: how long to retain the message while the user is offline
          urgency: 'normal', // high / normal / low; affects battery use and delivery policy
          topic: 'chat-msg', // Messages with the same topic are collapsed, leaving only the latest one
        },
      ),
    ),
  );

  // Remove invalid subscriptions (410 Gone / 404) so they are not retried next time
  await Promise.all(
    results
      .map((r, i) => ({ r, sub: subscriptions[i] }))
      .filter(({ r }) => r.status === 'rejected' && isGone(r.reason))
      .map(({ sub }) => deleteSubscription(sub.endpoint)),
  );

  return NextResponse.json({
    sent: results.filter((r) => r.status === 'fulfilled').length,
  });
}

function isGone(err: unknown): boolean {
  return err instanceof webPush.WebPushError && [404, 410].includes(err.statusCode);
}

A few engineering lessons:

  • TTL determines the offline delivery window. For example, if a user turns off their computer for three days, a message with TTL: 86400 is delivered when they come back online. TTL: 0 means "discard it if it cannot be delivered now," which is useful when an IM message has already been recalled.
  • urgency affects APNs / FCM delivery strategy. With low, the system may combine or delay messages to conserve power. Use high for important alerts. normal is sufficient for most use cases.
  • topic is the collapse key. A new message with the same topic replaces an older unread message, which is useful for incremental notifications such as "Unread messages: 3 → 4."
  • Invalid subscriptions must be removed. Clearing site data, uninstalling the browser, or natural subscription expiration can all make an endpoint return 410. Failing to clean them up is a slow form of self-destruction because every push pointlessly retries a pile of dead links.

Under the hood, web-push handles three things you do not want to implement yourself: aes128gcm payload encryption using p256dh + auth, request-header signing with the VAPID private key, and communication with the appropriate push service protocol based on the endpoint's domain. FCM uses the older web-push protocol, while APNs uses HTTP/2. Implementing it once can be educational, but do not reinvent it in production.


5. Service Worker: Reception and Display

Once the push arrives, the action moves to the SW. This code lives independently in /public/sw.js. Note that the SW must be served from the root domain and cannot use the bundler's module resolution, so it is usually placed under public/ and hosted unchanged as a static file.

js
// public/sw.js
self.addEventListener('install', (e) => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));

// Core logic: handle the push event
self.addEventListener('push', (event) => {
  let data = { title: 'New message', body: 'You have a new notification', url: '/' };
  try {
    if (event.data) data = event.data.json();
  } catch {
    /* Fall back to the default when the payload is not JSON */
  }

  const options = {
    body: data.body,
    icon: '/icon-192.png',
    badge: '/badge-72.png',
    data: { url: data.url }, // Used when the notification is clicked
    tag: data.tag, // Notifications with the same tag are merged
    renotify: true, // Whether to vibrate / play a sound again when merging by tag
    requireInteraction: false, // true = do not dismiss automatically; useful for important alerts
    vibrate: [100, 50, 100],
  };

  event.waitUntil(self.registration.showNotification(data.title, options));
});

// The user clicks the notification
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const targetUrl = event.notification.data?.url ?? '/';

  event.waitUntil(
    (async () => {
      const allClients = await self.clients.matchAll({
        type: 'window',
        includeUncontrolled: true,
      });

      // Prefer focusing an existing same-origin page
      for (const client of allClients) {
        if (client.url.includes(targetUrl) && 'focus' in client) {
          return client.focus();
        }
      }

      // Otherwise, open a new one
      if (self.clients.openWindow) {
        return self.clients.openWindow(targetUrl);
      }
    })(),
  );
});

Several pitfalls tend to appear only in production:

  • event.waitUntil(...) is mandatory. The browser terminates the SW as soon as the event loop becomes idle. waitUntil tells it, "I still have work to finish; wait for me." Without it, the notification may never appear.
  • Payload parsing needs a fallback. The server may send an empty push, known as a "push ping." In that case, event.data is null, and calling .json() directly throws an error.
  • You must handle page focus / opening in notificationclick yourself. Without this handler, clicking the notification does nothing, and users will assume it is broken.
  • iOS / iPadOS Safari imposes additional restrictions on SW notifications, which the pitfalls section below covers. On iOS, showNotification requires a PWA installed on the Home Screen.

6. Designing the Permission-Request Experience

Notification.requestPermission() is one of the Web's few one-shot opportunities: once a user clicks "Block," no matter how many times you call requestPermission(), the prompt will not appear again. The user must manually change the setting in the browser, and almost nobody will do that.

By default, you therefore should not request permission in the page's onload handler. This is the most common beginner mistake. Conversion is extremely low, and Chrome may flag your site as intrusive.

The correct approach has two steps:

  1. Explain the value with your own UI first. After the user performs a high-intent action, such as clicking "Follow author," "Set reminder," or liking a reply to a message, show a custom overlay: "We would like to notify you immediately when someone replies. Is that okay?"
  2. Call Notification.requestPermission() only after the user clicks "Yes". The request now has context and was actively initiated by the user, which can improve the authorization rate by an order of magnitude.

A practical wrapper looks like this:

tsx
// components/PushOptIn.tsx
'use client';
import { useState } from 'react';
import { subscribeToPush } from '@/lib/web-push';

export function PushOptIn() {
  const [asked, setAsked] = useState(false);

  // The user clicks this button first instead of seeing a system prompt immediately
  if (!asked) {
    return (
      <button onClick={() => setAsked(true)}>
        Enable message alerts
      </button>
    );
  }

  return (
    <div className="opt-in-card">
      <p>We will notify you immediately when someone replies to your comment.</p>
      <button onClick={() => subscribeToPush()}>Allow notifications</button>
      <button onClick={() => setAsked(false)}>Maybe later</button>
    </div>
  );
}

Remember one sentence: the system permission prompt should always be the result of the user having already agreed twice.


7. Pitfall Checklist

Web Push is a classic example of something that "works perfectly locally and breaks everywhere in production." Most problems cluster in the following areas.

1. The Special Requirements of iOS / iPadOS Safari

This is the most frequently asked question: Does Safari support Web Push?

The answer is: yes on 16.4+, but only under certain conditions.

  • It requires iOS / iPadOS 16.4 or later.
  • The user must "Add to Home Screen" and install the site as a PWA. Push subscriptions are unavailable in a standalone Safari tab. This is an intentional Apple restriction.
  • You must provide a manifest.json containing required PWA fields such as display: standalone or fullscreen.
  • Notifications must be displayed through the SW with showNotification; silent pushes are unsupported.

Practical advice: if you detect iOS but the PWA is not installed, guide the user to "Add to Home Screen" instead of calling subscribe directly, which will fail.

2. HTTPS Is Mandatory

An SW can register only on an HTTPS site or localhost. Local development at http://localhost:3000 works, but after deployment to any HTTP environment other than localhost, the browser rejects SW registration and the Web Push flow fails at the first step. Staging environments also need HTTPS. An internal domain with a self-signed certificate or ngrok will work.

3. Service Worker Registration Timing and Scope

  • The SW script path determines its scope. /sw.js controls the entire root domain by default, while /app/sw.js controls only /app/*. If you need root scope, either place the file at the root or pass { scope: '/' } during registration and configure the Service-Worker-Allowed response header.
  • Do not force an SW update on every visit. The browser automatically compares the bytes cached for register('/sw.js'), and repeated registration is idempotent. Frequent calls to update() can instead leave you in an awkward state where the old SW is handling pushes while the new one has not activated.
  • Changes to SW code do not take effect immediately. By default, the new version takes control only after every old tab is closed and a new page is opened. During development, enable DevTools → Application → Service Workers → "Update on reload" to bypass this behavior.

4. Payload Size Limits

Push services limit encrypted payload size to about 4KB for FCM, 4KB for APNs, and 4KB for Mozilla. Do not try to include an entire article. Send a lightweight payload such as "You have a new message; fetch it," then have the frontend retrieve the body through your API after opening.

5. Multiple Devices for One User

It is normal for a user to have one subscription on a phone and another on a computer. The push target is always a device, not a user, so the server's PushSubscription table must use endpoint as its unit of storage and maintain a one-to-many relationship by userId. Sending a notification to one user means sending concurrently to all of that user's subscriptions.

6. Blocking Third-Party Cookies Has No Effect

One piece of good news: Web Push does not depend on cookies. FCM / APNs delivery uses a system-level channel, so Web Push continues to work even as Chrome phases out third-party cookies.


8. Practical Next.js Integration

Putting the pieces together in a Next.js 16 project produces roughly this structure:

Environment variables (.env.local):

bash
# Generate with the web-push library: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=BGh...
VAPID_PRIVATE_KEY=Z3I...
# The public key must be available to the frontend, so it needs the NEXT_PUBLIC_ prefix
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BGh...

Directory structure:

text
public/
  sw.js                  # SW script, root scope
  icon-192.png           # Notification icon
src/
  app/api/push/subscribe/route.ts   # Receives subscriptions reported by the frontend
  app/api/push/send/route.ts        # Triggers push delivery
  lib/web-push.ts                  # Frontend subscription utilities
  lib/push-store.ts                # Subscription persistence (database)
  components/PushOptIn.tsx         # Permission guidance UI

Route for receiving subscriptions:

ts
// src/app/api/push/subscribe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { upsertSubscription } from '@/lib/push-store';
import { getSessionUserId } from '@/lib/auth';

export async function POST(req: NextRequest) {
  const userId = await getSessionUserId();
  if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });

  const sub = await req.json();
  await upsertSubscription({
    userId,
    endpoint: sub.endpoint,
    p256dh: sub.keys.p256dh,
    auth: sub.keys.auth,
  });

  return NextResponse.json({ ok: true });
}

Where to trigger delivery: Do not expose POST /api/push/send directly for arbitrary frontend calls. Business events should drive it, such as a reply to a comment, a scheduled price-change job, or an order-status update, and it should be called internally on the server. The frontend should expose only a way to configure reminders; actual delivery must always occur in your trusted backend.

About next/web-push: The ecosystem currently has no official next/web-push module. Some online resources mention it, but most refer to third-party wrappers or misinformation about older versions. The mainstream approach is to use the web-push library directly. It works with Next.js Route Handlers without difficulty. A Next 16 Route Handler, defined as export async function POST in app/api/.../route.ts, uses standard Web APIs, and a pure Node library such as web-push can be imported and called directly. Place setVapidDetails at module scope or in an initialization function; do not set it again for every request.

Deployment notes:

  • By default, Next.js serves /public/sw.js unchanged at the root path, which is exactly what SW scope requires. Do not add unusual rewrites for /public in next.config.ts.
  • If the sending route is deployed as an edge function using Vercel Edge Runtime, remember that web-push uses Node's crypto and must run in the Node.js runtime (export const runtime = 'nodejs'). Edge Runtime currently lacks some required APIs.
  • In production, verify that the VAPID private key exists only on the server, applicationServerKey uses the public key paired with that private key, and the SW file is actually accessible from the root domain (curl https://yoursite.com/sw.js returns the file).

Summary

Returning to the original question, "Can users still receive notifications after closing the tab?" Yes. But this flow deserves respect:

  • Web Push is an orchestration of three components: VAPID answers "Who are you?", the Push API handles subscription and delivery, and the Service Worker handles reception and display. All three are essential, and understanding their responsibilities matters more than memorizing APIs.
  • Subscription code must be idempotent and deduplicated: Call getSubscription() before deciding whether to create one, and upsert on the server with endpoint as the unique key. Otherwise, dead subscriptions accumulate.
  • Sending code must remove invalid subscriptions: Delete 410 / 404 records from the database. Promise.allSettled with per-item error handling is standard practice. Leave payload encryption and signing to the web-push library rather than reinventing it.
  • Permission requests are a product decision, not a technical one: Show the system prompt only after the user has agreed twice, and explain the value in your own overlay first.
  • iOS Safari is difficult: Even on 16.4+, users must "Add to Home Screen." This is Apple's policy, not a bug. Detect the condition and guide installation instead of forcing the subscription flow.

One final thought: Web Push is not complex because of any individual code sample, but because it spans three specifications, three browser vendors, and two operating-system notification systems. Run the entire path through your head once, from private-key signing to SW activation. Only then do you truly understand it instead of merely having copied the code correctly.

Related

Related Content