Web Push Notifications for a Flutter App with Firebase Cloud Messaging
Wire Web Push into a Flutter app through FCM: VAPID keys, register() from Dart, service-worker delivery, and the browser rules that decide whether you get permission.
Published on • November 11, 2026
AI Assistant

Web push in a Flutter app is not a Flutter feature — it is the browser’s Push API wearing a Dart coat, delivered through Firebase Cloud Messaging. The Dart plugin handles registration, but the browser decides whether you may subscribe at all, and its rules (secure context, active service worker, user gesture) are where most implementations quietly fail. This post covers the whole chain.
What the browser demands
Three non-negotiables before any subscription exists: the page must be a secure context (HTTPS, or localhost for development), a service worker must be registered for the origin, and PushManager.subscribe() must be called. The Push API spec’s subscription options are fixed:
const registration = await navigator.serviceWorker.register('/sw.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // every push must show UI (Chrome enforces)
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
// POST subscription.toJSON() to your server
applicationServerKey is your VAPID public key — an ECDSA P-256 key that authenticates your application server to the push service; it is not the key used to encrypt payload data. It arrives from the console as a base64url string, while subscribe() wants raw bytes, hence the conversion helper in the snippet above. Subscriptions are unique per service worker: the endpoint URL is a capability, so anyone who has it can send you a message — treat it as a secret and store it server-side against the user it belongs to.
The subscription also records the applicationServerKey it was created with, and the push service checks every send against it — rotate the VAPID key and existing subscriptions stop authenticating until they are re-created, so treat a rotation as a migration rather than a config change.
MDN is explicit that subscribe() should run in response to a user gesture: browsers increasingly refuse notification prompts that were not triggered by one, and Firefox has enforced this since version 72. The permission prompt itself is per-origin — granted once, remembered until the user changes it.
The subscription object you get back carries exactly what a server needs to send a push: endpoint, keys.p256dh, and keys.auth. Persist all three — or, with FCM, the FID that stands in for them — and treat the endpoint as a credential, because anyone holding it can send messages to that browser.
Wire FCM web credentials
FCM uses the same VAPID scheme. In the Firebase console go to Settings → General → Cloud Messaging tab → Web configuration → Web Push certificates, then either generate a key pair or import an existing one (importing requires owner-level project access). You get a public key string; that is what the Dart side needs.
final messaging = FirebaseMessaging.instance;
// Subscribe to FIDs BEFORE registering so the listener is live
messaging.onRegistered.listen((fid) {
// Send the Firebase Installation ID to your application server
});
// Required on Web — vapidKey is optional on Android/Apple
await messaging.register(
vapidKey: 'BKagOny0KF_2pCJQ3m....moL0ewzQ8rZu',
);
Before register() can do anything, the user has to grant notification permission; ask for it explicitly and branch on the result rather than assuming the prompt appeared:
final settings = await FirebaseMessaging.instance.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.denied) {
// Do not ask again — route to the in-app notification center
}
The registration model has moved: register() delivers a Firebase Installation ID through the onRegistered stream, and the old getToken(vapidKey:) / onTokenRefresh pair is deprecated (the web variant still requires your VAPID key, but it is marked for removal). Do not mix the two APIs in one app. Note that on web there is no static opt-in flag — register() is what triggers permission asking as well as registration, so call it from an explicit “Enable notifications” button rather than on startup, and check the result: Notification.permission (or requestPermission()’s return value) tells you whether to show the enable button or the fallback UI.
Foreground, background, and the service worker
Where a message lands depends on app state. Foreground messages arrive in Dart through FirebaseMessaging.onMessage, and you present them yourself — a SnackBar, an in-app banner — because while the tab is focused there is no system notification UI to show:
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
final notification = message.notification;
if (notification == null) return; // data-only message: handle silently
_showInAppBanner(
title: notification.title ?? '',
body: notification.body ?? '',
);
});
Background and closed-tab messages never reach Dart: the service worker’s push handler wakes up, and it decides whether to show a system notification via registration.showNotification().
If the user taps that notification while the app is closed, FirebaseMessaging.instance.getInitialMessage() is the Dart-side replay of the payload — read it during startup, before you assume the user landed on the home screen.
// sw.js — the part Firebase's web SDK hooks into
self.addEventListener('pushsubscriptionchange', (event) => {
// The push service invalidated the subscription: re-subscribe with the
// same applicationServerKey and report the new endpoint to your server.
event.waitUntil(
self.registration.pushManager
.subscribe({ userVisibleOnly: true, applicationServerKey })
.then((sub) => fetch('/api/push/resubscribe', {
method: 'POST',
body: JSON.stringify(sub),
})),
);
});
Handle pushsubscriptionchange: subscriptions expire, users rotate browser profiles, and a server that only ever stores the first endpoint will silently stop delivering. The FCM web SDK re-registers on this event, which surfaces again through onRegistered — but only if you kept that listener.
The same file is where notificationclick lives: closing the notification and opening or focusing the tab is plain service-worker work, and the payload you sent along arrives there, so decide which route to open before you send the campaign.
Browser quirks: Safari, denial, and fallbacks
Support is not uniform. Desktop Safari and Chrome/Edge/Firefox on Android support web push, but iOS and iPadOS only support it for sites added to the home screen as installed PWAs (iOS 16.4+) — a tab in Safari mobile cannot subscribe at all, so your “enable notifications” UI should hide itself there rather than fail. Branch on the four permission states instead of a boolean:
granted— subscribe and send the subscription to your serverdenied— never re-prompt; the user must undo it in browser settingsdefault— no decision yet; ask only from an explicit button pressunsupported— hide the enable UI entirely (iOS Safari tabs, older browsers)
Permission denial is permanent-ish: the user must go into browser settings to reverse it, and browsers will not re-prompt. Design for that with an in-app notification center — an inbox badge fed by your normal API polling — so denied users still get the information, just without the interruption. Always request permission from an explicit button tap, never on page load; an auto-prompt on first paint is the fastest route to a permanently denied origin.
If register() itself rejects, log the error before anything else: an InvalidStateError almost always means there is no active service worker for the origin, which is the single most common cause of a silent failure on first run.
Test the loop
Each browser family talks to a different push service — Chrome to FCM’s, Firefox to Mozilla’s, Safari to Apple’s — so a message that lands on one browser proves nothing about the others; test at least one device per family you claim to support.
Verify end to end with a real device, not just a desktop tab: confirm the service worker is active (DevTools → Application → Service Workers), subscribe from a button press, and check the subscription object reaches your server. Send a test campaign from the Firebase console targeting the FID, then repeat with the tab in the background and with the app fully closed — those two paths exercise the service worker, not your Dart code. Log the Notification.permission state at startup as well; a silent denied is the most common reason a user reports that push “never works”. Finally, revoke notification permission in browser settings and confirm your UI degrades to the in-app center instead of silently doing nothing.
Chrome DevTools → Application → Push Messaging shows the live subscription JSON — diff its endpoint and keys against what your server stored, because a mismatch there is the other silent failure mode.
Keep those endpoints healthy on the server side as well: store them keyed by FID, and prune any endpoint that answers 410 Gone — that is the push service telling you the subscription is dead for good.
Takeaway: Web Push needs HTTPS, an active service worker, and a gesture-triggered subscribe(); wire FCM through register(vapidKey:) and onRegistered, handle pushsubscriptionchange, and ship an in-app notification center as the fallback for denied or unsupported browsers.