Home | Send Feedback | Share on Bluesky |

Sending browser push messages from Spring Boot with Firebase Cloud Messaging

Published: 9. January 2018  •  Updated: 21. March 2026  •  java, javascript, pwa, spring

Service workers give browser applications a background execution context, and Firebase Cloud Messaging (FCM) adds the delivery infrastructure on top. That combination is still a practical way to send browser notifications from a Java back end when you want Firebase to handle the browser-facing subscription layer.

In this post, I build a small Spring Boot application that sends Chuck Norris jokes to browsers every 30 seconds. The browser app stores the messages in IndexedDB, updates the UI while the page is open, and shows a notification when the page is in the background.

Firebase

Start in the Firebase Console and create a project or select an existing one.

Add a Web app to the project and copy the firebaseConfig values. You need these fields in the browser code and in the service worker.

Open the Cloud Messaging tab in the project settings and generate a Web Push certificate key pair. The public key becomes the vapidKey that the browser passes to getToken().

If your Firebase project is older, also verify that the FCM Registration API is enabled in Google Cloud. Current projects have it enabled by default, but older projects may need this extra step before token creation works.

Finally, create a service account key under Project settings -> Service accounts and download the JSON file. The Spring Boot application uses this credential to initialize the Firebase Admin SDK.

Treat that JSON file like any other privileged credential. Do not commit it to source control.

Server

The server is a Spring Boot application created on start.spring.io with the Reactive Web dependency.

The location of the service account JSON lives in application.properties.

fcm.service-account-file=./firebase-service-account.json

application.properties

FcmSettings binds that property into a Spring bean with @ConfigurationProperties.

package ch.rasc.swpush;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@ConfigurationProperties(prefix = "fcm")
@Component
public class FcmSettings {
  private String serviceAccountFile;

  public String getServiceAccountFile() {
    return this.serviceAccountFile;
  }

  public void setServiceAccountFile(String serviceAccountFile) {
    this.serviceAccountFile = serviceAccountFile;
  }

}

FcmSettings.java

PushChuckJokeService fetches a random joke from the current public Chuck Norris API and turns it into the key-value payload that is sent to the browser clients.

  private static final Logger logger = LoggerFactory
      .getLogger(PushChuckJokeService.class);

  private static final String JOKE_API_URL = "https://api.chucknorris.io/jokes/random";

  private final FcmClient fcmClient;

  private final WebClient webClient;

  private int seq = 0;

  public PushChuckJokeService(FcmClient fcmClient, WebClient webClient) {
    this.fcmClient = fcmClient;
    this.webClient = webClient;
  }

  @Scheduled(fixedDelay = 30_000)
  public void sendChuckQuotes() {
    ChuckNorrisJoke joke = this.webClient.get().uri(JOKE_API_URL).retrieve()
        .bodyToMono(ChuckNorrisJoke.class).block();

    if (joke == null || joke.id() == null || joke.value() == null) {
      logger.warn("No joke payload received from {}", JOKE_API_URL);
      return;
    }

    sendPushMessage(joke);
  }

  void sendPushMessage(ChuckNorrisJoke joke) {
    Map<String, String> data = new HashMap<>();
    data.put("id", joke.id());
    data.put("joke", joke.value());
    data.put("seq", String.valueOf(this.seq++));
    data.put("ts", String.valueOf(System.currentTimeMillis()));

    this.fcmClient.send(data);
    logger.info("Sent Chuck Norris joke {}", joke.id());
  }

PushChuckJokeService.java

To talk to FCM from Java, I use the Firebase Admin SDK.

    <dependency>
      <groupId>com.google.firebase</groupId>
      <artifactId>firebase-admin</artifactId>
      <version>9.8.0</version>
    </dependency>

pom.xml

FcmClient initializes FirebaseApp from the downloaded service account JSON.

  public FcmClient(FcmSettings settings) {
    String serviceAccountFile = settings.getServiceAccountFile();
    if (serviceAccountFile == null || serviceAccountFile.isBlank()) {
      throw new IllegalStateException("fcm.service-account-file must be configured");
    }

    Path path = Path.of(serviceAccountFile);
    try (InputStream serviceAccount = Files.newInputStream(path)) {
      FirebaseOptions options = FirebaseOptions.builder()
          .setCredentials(GoogleCredentials.fromStream(serviceAccount)).build();

      FirebaseApp firebaseApp = FirebaseApp.getApps().isEmpty()
          ? FirebaseApp.initializeApp(options)
          : FirebaseApp.getInstance();
      this.firebaseMessaging = FirebaseMessaging.getInstance(firebaseApp);
    }
    catch (IOException e) {
      throw new IllegalStateException("Unable to initialize Firebase Admin SDK", e);
    }
  }

FcmClient.java

The same class also sends a data message to the topic chuck and subscribes browser tokens to that topic.

  public void send(Map<String, String> data) {
    try {
      Message message = Message.builder().putAllData(data).setTopic("chuck")
          .setWebpushConfig(WebpushConfig.builder().putHeader("TTL", "300").build())
          .build();

      String response = this.firebaseMessaging.send(message);
      logger.info("Sent message {}", response);
    }
    catch (Exception e) {
      logger.error("Failed to send Firebase message", e);
    }
  }

  public void subscribe(String topic, String clientToken) {
    try {
      TopicManagementResponse response = this.firebaseMessaging
          .subscribeToTopic(List.of(clientToken), topic);
      logger.info("{} tokens were subscribed successfully",
          response.getSuccessCount());
    }
    catch (Exception e) {
      logger.error("Failed to subscribe client to topic {}", topic, e);
    }
  }

FcmClient.java

I only send a data payload here. That keeps the server-side message small and lets the service worker decide how the notification should look.

Client

The browser app is just a static page served from Spring Boot. index.html loads the Firebase JavaScript SDK, the app script, and the Web App Manifest.

<body>
<h1>Firebase Cloud Messaging Demo</h1>
<div class="controls">
  <button id="enablePush" type="button">Enable notifications</button>
  <div class="status" id="status" role="status" aria-live="polite"></div>
</div>
<div id="outTable"></div>
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js"></script>
<script src="https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js"></script>
<script src="index.js"></script>
</body>

index.html

Because this example serves a plain JavaScript service worker without a bundling step, I use the current Firebase compat packages from the CDN. That keeps the service worker simple while still using the current FCM token flow.

The top of index.js contains the Firebase configuration, the VAPID key, and the message handler for foreground messages.

const firebaseConfig = {
  apiKey: 'AIzaSyAMBZJQqEL9ZjA2Y01E0bj9wV4BGZMvdJU',
  authDomain: 'demopush-7dacf.firebaseapp.com',
  projectId: 'demopush-7dacf',
  storageBucket: 'demopush-7dacf.firebasestorage.app',
  messagingSenderId: '425242423819',
  appId: '1:425242423819:web:e34dad8cf7e765216c8d0e'
};

const vapidKey =
    'BE-ASg0VyvsQIxoCzGF7K7cT5Xzj_eJCsnZytY3q71Mwou_5i7S0-9NTQwfpU8wdmZXRb3w7DXSfoXms0QXeybc';

let dbPromise;

firebase.initializeApp(firebaseConfig);
const messaging = firebase.messaging();

navigator.serviceWorker.addEventListener('message', event => {
  if (event.data && event.data.type === 'newData') {
    void showData();
  }
});

messaging.onMessage(async payload => {
  if (payload.data) {
    await storeJoke(payload.data);
    await showData();
  }
});

index.js

The page registers sw.js, waits until the service worker is ready, and only requests notification permission after the user clicks a button. Once permission is granted, it calls messaging.getToken({ vapidKey, serviceWorkerRegistration }) and posts the token to the server.

async function init() {
  if (!('serviceWorker' in navigator) || !('Notification' in window)) {
    setStatus('This browser does not support service workers and notifications.');
    return;
  }

  const registration = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;

  document.getElementById('enablePush').addEventListener('click', () => {
    void enablePush(registration);
  });

  if (Notification.permission === 'granted') {
    await syncToken(registration);
    setStatus('Notifications are enabled.');
  }
  else if (Notification.permission === 'denied') {
    setStatus('Notifications are blocked for this site.');
  }
  else {
    setStatus('Click "Enable notifications" to subscribe this browser.');
  }

  await showData();
}

async function enablePush(registration) {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    setStatus('Notification permission was not granted.');
    return;
  }

  await syncToken(registration);
  setStatus('Notifications are enabled.');
}

async function syncToken(registration) {
  try {
    const currentToken = await messaging.getToken({
      vapidKey,
      serviceWorkerRegistration: registration
    });

    if (!currentToken) {
      setStatus('No FCM registration token is available.');
      return;
    }

    if (localStorage.getItem('fcmToken') !== currentToken) {
      await registerToken(currentToken);
      localStorage.setItem('fcmToken', currentToken);
    }
  }
  catch (error) {
    console.error('Unable to subscribe this browser', error);
    setStatus('Unable to subscribe this browser. Check the console for details.');
  }
}

async function registerToken(token) {
  const response = await fetch('/register', {
    method: 'POST',
    headers: {
      'Content-Type': 'text/plain;charset=UTF-8'
    },
    body: token
  });

  if (!response.ok) {
    throw new Error(`Token registration failed with status ${response.status}`);
  }
}

index.js

That token is the browser instance identifier that FCM uses. I cache the last token in localStorage so the browser only calls /register when the token changes.

The rest of the script stores incoming jokes in IndexedDB and renders the saved entries into the page.

async function storeJoke(jokeData) {
  const db = await getDb();
  const transaction = db.transaction('jokes', 'readwrite');
  transaction.objectStore('jokes').put(normalizeJoke(jokeData));
  await waitForTransaction(transaction);
}

async function showData() {
  const db = await getDb();
  const transaction = db.transaction('jokes', 'readonly');
  const store = transaction.objectStore('jokes');
  const jokes = await requestToPromise(store.getAll());
  showJokes(jokes);
}

function showJokes(jokes) {
  const table = document.getElementById('outTable');
  table.replaceChildren();

  jokes.sort((left, right) => right.ts - left.ts);
  for (const joke of jokes) {
    const wrapper = document.createElement('div');
    wrapper.className = 'joke-entry';

    const header = document.createElement('div');
    header.className = 'header';
    header.textContent = `${new Date(joke.ts).toISOString()} ${joke.id} (${joke.seq})`;

    const message = document.createElement('div');
    message.className = 'joke';
    message.textContent = joke.joke;

    wrapper.append(header, message);
    table.append(wrapper);
  }
}

function normalizeJoke(jokeData) {
  return {
    id: String(jokeData.id),
    joke: String(jokeData.joke),
    seq: Number.parseInt(jokeData.seq, 10),
    ts: Number.parseInt(jokeData.ts, 10)
  };
}

function setStatus(message) {
  document.getElementById('status').textContent = message;
}

async function getDb() {
  if (!dbPromise) {
    dbPromise = new Promise((resolve, reject) => {
      const openRequest = indexedDB.open('Chuck', 1);

      openRequest.onupgradeneeded = event => {
        const db = event.target.result;
        db.createObjectStore('jokes', { keyPath: 'id' });
      };

      openRequest.onsuccess = event => resolve(event.target.result);
      openRequest.onerror = () => reject(openRequest.error);
    });
  }

  return dbPromise;
}

function requestToPromise(request) {
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

function waitForTransaction(transaction) {
  return new Promise((resolve, reject) => {
    transaction.oncomplete = () => resolve();
    transaction.onabort = () => reject(transaction.error);
    transaction.onerror = () => reject(transaction.error);
  });
}

void init();

index.js

The /register endpoint is tiny. It trims the posted token and forwards it to the FCM client for topic subscription.

  @PostMapping("/register")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  public Mono<Void> register(@RequestBody Mono<String> token) {
    return token.map(String::trim).filter(t -> !t.isEmpty())
        .doOnNext(t -> this.fcmClient.subscribe("chuck", t)).then();
  }

RegistryController.java

Service worker

sw.js starts with a notificationclick handler so an incoming notification can focus an open tab or open /index.html in a new one. After that, it loads the Firebase compat packages and initializes the app with the same firebaseConfig values as the page.

self.addEventListener('notificationclick', event => {
  event.notification.close();

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

    for (const client of existingClients) {
      if (client.url.endsWith('/index.html') || client.url.endsWith('/')) {
        await client.focus();
        return;
      }
    }

    await clients.openWindow('/index.html');
  })());
});

importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js');

firebase.initializeApp({
  apiKey: 'AIzaSyAMBZJQqEL9ZjA2Y01E0bj9wV4BGZMvdJU',
  authDomain: 'demopush-7dacf.firebaseapp.com',
  projectId: 'demopush-7dacf',
  storageBucket: 'demopush-7dacf.firebasestorage.app',
  messagingSenderId: '425242423819',
  appId: '1:425242423819:web:e34dad8cf7e765216c8d0e'
});

const messaging = firebase.messaging();
let dbPromise;

messaging.onBackgroundMessage(async payload => {
  if (payload.data) {
    await storeJoke(payload.data);
    await notifyClients();
  }

  const notification = createNotification(payload);
  await self.registration.showNotification(notification.title, notification.options);
});

sw.js

The important part is messaging.onBackgroundMessage(...). When a data message arrives while the page is in the background, the handler writes the payload to IndexedDB, notifies all open windows with postMessage, and shows a notification with self.registration.showNotification(...).

async function storeJoke(jokeData) {
  const db = await getDb();
  const transaction = db.transaction('jokes', 'readwrite');
  transaction.objectStore('jokes').put({
    id: String(jokeData.id),
    joke: String(jokeData.joke),
    seq: Number.parseInt(jokeData.seq, 10),
    ts: Number.parseInt(jokeData.ts, 10)
  });
  await waitForTransaction(transaction);
}

async function notifyClients() {
  const allClients = await clients.matchAll({
    type: 'window',
    includeUncontrolled: true
  });

  for (const client of allClients) {
    client.postMessage({ type: 'newData' });
  }
}

function createNotification(payload) {
  const title = payload.notification && payload.notification.title
    ? payload.notification.title
    : 'New Chuck Norris joke';

  const body = payload.notification && payload.notification.body
    ? payload.notification.body
    : payload.data && payload.data.joke ? payload.data.joke : 'A new joke is available.';

  return {
    title,
    options: {
      body,
      badge: '/mail.png',
      icon: '/mail2.png'
    }
  };
}

sw.js

This gives the application one persistence path for foreground and background delivery:

IndexedDB works well here because both the page and the service worker can access the same database.

Running the example

Before you start the application, replace the Firebase project values in index.js and sw.js with the values from your own Firebase project, and point fcm.service-account-file to your downloaded service account JSON.

Then start the Spring Boot app and open:

http://localhost:8080/index.html

Click Enable notifications. If permission is granted and the token registration succeeds, the page starts to fill with new jokes. When the app is in the background, the service worker shows a notification and still stores the message in IndexedDB.

Remember that browser push on the web requires a secure context. localhost is allowed for development, but for a deployed version you need HTTPS.

Conclusion

The current Firebase-based browser flow is straightforward once you line up the three moving parts correctly:

That keeps the code small while still giving you full control over how the client stores messages and displays notifications.