Description
Operating System
macOS Sequoia 15.5
Environment (if applicable)
Chrome 136.0.7103.115
Firebase SDK Version
11.6.1
Firebase SDK Product(s)
Messaging
Project Tooling
- React (19.1.0) app built with Vite 6.3.4
- Service worker built with
vite-plugin-pwa
1.0.0
Detailed Problem Description
I am using FCM to send display push notifications to a React web app. The notifications are properly displayed, but they are always accompanied by an extra notification that says This site has been updated in the background.
So far, I have tested in Chrome and Safari and only Chrome exhibits this behaviour.
I have tried:
Service Worker Configuration | Real Notification | "Site Updated" Notification | Notes |
---|---|---|---|
Empty firebase-messaging-sw.js file |
❌ Not displayed | Real notification completely suppressed | |
Service worker with self.registration.showNotification() in onBackgroundMessage |
Duplicate real notifications | ||
Service worker without onBackgroundMessage handler |
✅ Displayed once | Code included below |
I know it has been suggested in other similar bug reports to use data-only notifications, but I don't want to do that as we plan on using the FCM campaigns dashboard to send some notifications to our users. Also, I believe that data notifications would not work on mobile devices (iOS).
This issue has been reported in various forms over the years, with no clear resolution or workaround:
- [Messaging] onBackgroundMessage use in background service worker always shows notification on message receipt (MV3 Chrome extension) #6764
- [Messaging] "This site has been updated in the background" #6478
- Push notification does not arrive OR arrive generic notification message #5603
Steps and code to reproduce issue
Firebase initialization code
import { captureException } from '@sentry/react';
import { FeatureFlagService, UserIdentificationService } from '@shared/services';
import { deleteToken, getMessaging, getToken, Messaging, onMessage } from 'firebase/messaging';
import { action, autorun, computed, makeObservable, observable } from 'mobx';
import { FirebaseService } from './FirebaseService';
import { DisplayNotificationAuthorizationState, PushNotificationsService } from './PushNotificationsService';
import { StudyoEnvironmentService } from './StudyoEnvironmentService';
export class WebFirebasePushNotificationsService implements PushNotificationsService {
private readonly _messaging: Messaging;
private _lastRegisteredUserId?: string;
@observable private _authorizationState: DisplayNotificationAuthorizationState;
@computed
get authorizationState() {
return this._authorizationState;
}
constructor(
firebaseService: FirebaseService,
private readonly _environmentService: StudyoEnvironmentService,
private readonly _featureFlagService: FeatureFlagService,
private readonly _userIdentificationService: UserIdentificationService
) {
makeObservable(this);
this._messaging = getMessaging(firebaseService.app);
this._authorizationState = 'not_supported';
void this.updateAuthorizationState();
autorun(async () => {
await this.registerDevice(this._userIdentificationService.userId);
});
onMessage(this._messaging, (payload) => {
console.log('Received foreground message:', payload);
new Notification(payload.notification?.title ?? 'Studyo', {
body: payload.notification?.body ?? 'Studyo Notification',
icon: payload.notification?.icon ?? '/favicon-32x32.png',
tag: `foreground-${payload.messageId}`,
data: payload.data
});
});
}
async requestPermissionIfNeeded() {
if (this.authorizationState !== 'unknown') {
return;
}
try {
const permission = await Notification.requestPermission();
await this.updateAuthorizationState();
if (permission === 'granted') {
await this.registerDevice(this._userIdentificationService.userId);
}
} catch (error) {
console.error('Error requesting permission:', error);
captureException(error);
}
}
@action
private async updateAuthorizationState() {
await this._featureFlagService.isReady;
if (!this._featureFlagService.isEnabled('push-notifications')) {
this._authorizationState = 'not_supported';
return;
}
try {
switch (Notification.permission) {
case 'granted':
this._authorizationState = 'granted';
break;
case 'denied':
this._authorizationState = 'denied';
break;
default:
this._authorizationState = 'unknown';
break;
}
} catch (error) {
console.error('Error checking notification authorization state:', error);
this._authorizationState = 'not_supported';
}
}
private async registerDevice(userId?: string) {
if (this.authorizationState !== 'granted') {
return;
}
if (this._lastRegisteredUserId && this._lastRegisteredUserId !== userId) {
await this.unregisterDevice(this._lastRegisteredUserId);
}
if (!userId) {
return;
}
try {
const token = await this.getToken();
console.log(`Registering user ${userId} with device push notification token: ${token}`);
// TODO: Call API to register device token
this._lastRegisteredUserId = userId;
} catch (error) {
console.error('FCM push notification token not available:', error);
captureException(error);
}
}
private async unregisterDevice(userId: string) {
if (this.authorizationState !== 'granted') {
return;
}
try {
const token = await this.getToken();
console.log(`Unregistering user ${userId} with device push notification token: ${token}`);
// TODO: Call API to unregister device token
this._lastRegisteredUserId = undefined;
await deleteToken(this._messaging);
} catch (error) {
console.error('FCM push notification token not available:', error);
captureException(error);
}
}
private async getToken() {
const token = await getToken(this._messaging, {
vapidKey: this._environmentService.firebaseConfig.vapidKey
});
return token;
}
}
Service worker code
import { initializeApp } from 'firebase/app';
import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw';
importScripts('/environment.js');
declare const self: ServiceWorkerGlobalScope;
const app = initializeApp(self.STUDYO_ENV.firebaseConfig);
const messaging = getMessaging(app);
// eslint-disable-next-line @typescript-eslint/no-misused-promises
onBackgroundMessage(messaging, (payload) => {
console.log('FCM onBackgroundMessage', payload);
return Promise.resolve();
});