←all writing
0819 Aug 2026/6 min read

Web push with Firebase Cloud Messaging: seven bugs from a real app

Push notifications in the Happy Pet Tech web app ran for two years before we looked at them closely. Seven bugs we found in five weeks, with the cause and the fix for each.

Web PushFirebaseService Worker

The Happy Pet Tech web app has had browser push notifications since May 2024. They use Firebase Cloud Messaging. For two years they mostly worked, and nobody looked closely.

This summer we made the app installable on phones, and push became important. A new booking request or a WhatsApp message from a customer has to reach a phone in a pocket. So between mid July and mid August we went through the whole thing. We found seven bugs.

1. The permission prompt was wasted

A browser lets a site show the notification permission prompt once. If the user dismisses it, the site cannot ask again. The user has to dig into browser settings.

Our app asked for permission at import time, when the code loaded, before the user had done anything. It asked again when the WhatsApp inbox opened. Someone who had just logged in for the first time saw a permission box with no context, closed it, and was blocked from notifications forever.

The fix was to split one function into three:

  • getNotificationPermission only reads the current state.
  • syncNotificationPermission runs on load. It registers the token if permission is already granted. It never shows a prompt.
  • requestNotificationPermission shows the prompt, and is only called from a button the user pressed.

The app now shows its own small banner that explains what notifications are for. The browser prompt comes only after the user presses “Turn on”. For users who already blocked it, the banner explains how to unblock.

2. The page and the service worker used different Firebase projects

Push on the web has two halves. The page gets a token. A service worker, a separate file, receives the push in the background. Both need the same Firebase config.

The page reads its config from environment variables. A service worker is a static file and has no process.env. At one point someone changed the worker to read process.env anyway. In a service worker that is simply undefined, so the worker had no config. That was reverted to hard-coded values. Later the environment changed to a new Firebase project, the hard-coded values did not, and the two halves were on different projects. The page made tokens in one project, the worker listened in another. There was no error anywhere.

The fix is to have one source. The page passes its config to the worker in the registration URL:

const serviceWorkerUrl = () => {
  const params = new URLSearchParams(
    Object.entries(firebaseConfig).filter(([, value]) => Boolean(value))
  );
  return `/firebase-messaging-sw.js?${params.toString()}`;
};

export async function registerServiceWorker() {
  if (!('serviceWorker' in navigator)) return null;
  try {
    return await navigator.serviceWorker.register(serviceWorkerUrl());
  } catch {
    return null; // a failed registration should not break the app
  }
}

The worker reads the values from its own URL. One more detail: you must pass this registration to getToken. If you do not, the Firebase SDK registers a second worker at the default URL, without the config.

3. The token was only saved at login

The push token was sent to the server as part of the login request. So a user who logged in, and turned notifications on ten minutes later, had a token that the server never heard about. Nothing was delivered to them until their next login.

Now there is a separate endpoint that saves the token for a device, and it is called whenever the token is created or changes.

4. Every notification showed twice

A Firebase message can carry a notification block, a data block, or both. With a notification block, the browser SDK shows a notification by itself in the background. We also had our own handler that showed one. Two boxes for each push.

The server now sends data-only messages. Title, body and icon travel inside data, and the worker is the only thing that shows anything. One rule from Firebase to remember here: every value inside data must be a string, or the send is rejected.

5. It still showed twice, for two other reasons

After that fix, duplicates were rarer but still there.

Reason one: old tokens. A browser can hold more than one live token for the same account. A token changes when the service worker registers again, and the old one keeps working for a while. The server sends to both.

Reason two: many tabs. When the app is open, the push is handed to every open tab. Three tabs, three popups.

For the first, the server puts an event_key in every push, and the worker uses it as the notification tag. A notification with the same tag replaces the earlier one, and renotify: false makes the replacement silent.

messaging.onBackgroundMessage((payload) => {
  const info = payload?.data || {};
  self.registration.showNotification(info.title || 'Notification', {
    body: info.body || '',
    icon: info.icon || '/favicon.ico',
    data: { url: resolveUrl(info) },
    tag: info.event_key || `${info.notification_type || ''}:${info.type_id || ''}`,
    renotify: false,
  });
});

For the second, the tabs agree between themselves. The first tab to see an event writes its key to localStorage with a 10 second life. The other tabs see the key and stay quiet.

6. Clicking a notification reloaded the whole app

Our first click handler found an open tab of the app, focused it, and called client.navigate(url). That works, but it is a full page load. In an installed app, a full reload to jump to one chat feels broken.

Now the worker focuses the tab and sends it a message. The app hears it and changes the route on the client side, with no reload.

const targetPath = url.split('?')[0];
const target =
  appTabs.find((client) => client.url.split('?')[0].endsWith(targetPath)) || appTabs[0];

if (target) {
  return Promise.resolve(target.focus()).then((focused) => {
    (focused || target).postMessage({ type: 'NOTIFICATION_NAVIGATE', url });
  });
}
return self.clients.openWindow ? self.clients.openWindow(url) : undefined;

It prefers a tab that is already on the destination page, and compares only the path, so ?id=123 does not break the match. A new window opens only when the app is not open anywhere.

7. Notifications kept coming after logout

A user logged out and their phone kept receiving notifications for that account. The token was still registered on the server.

Now the token is deleted on logout, when a session expires, and when an account is suspended.

That fix had its own bug. The cleanup waited for navigator.serviceWorker.ready. That promise never resolves if no worker was ever registered. For those users it blocked the redirect that should follow a suspension. The wait now races against a two second timeout.

Cleaning up dead tokens on the server

When a send fails for a token, the server removes that token, but only for three specific error codes that mean the token is dead. There is a fourth code, invalid-argument, that looks like it belongs in that list. We left it out on purpose. It also fires when our own payload is wrong. One bad payload would then delete the tokens of every user in a company.

Sends go out in groups of 500 tokens, which is the limit for one request.

What the service worker does not do

Our worker has a fetch handler because browsers want one before they call an app installable. The handler passes every request to the network. It caches nothing.

A cached app shell is the classic bug of installable web apps: you deploy a fix and users keep getting yesterday’s app. We deploy often, so we chose no offline mode over stale code.

← all writing nextWhatsApp message templates: six things the docs did not prepare us for →