One push pipeline, four surfaces
Modern products rarely live in a single browser tab. The same user may open a web ops dashboard, a packaged desktop client, and a phone app. When something important happens—a new message, an approval, an incoming call—they expect a notification wherever they are logged in.
Firebase Cloud Messaging (FCM) is a practical backbone for that. The API owns sending. Each client registers a device token. Web, mobile, and desktop differ in how they obtain and refresh that token, but they should hit the same registration endpoint and the same server-side send path.
If you invent a separate “mobile notification service” and a second “web socket only” path without a shared model, you will fix the same bug three times. One pipeline with platform adapters is slower on day one and much cheaper by month six.
This article captures patterns from building multi-client products: optional Firebase on the server, safe client config, and priority handling for time-sensitive alerts. No vendor project IDs or private keys appear here—only architecture you can reuse.
Server: Admin SDK, optional by design
Keep Firebase Admin on the API only. Initialize with a service account (project id, client email, private key) loaded from environment variables—never from a public Next.js or Vite bundle.
Treat Firebase as an optional integration. If the credentials are missing in a local or staging environment, the API should still boot. Push endpoints can return a clear “not configured” error instead of crashing the whole process. That makes onboarding new developers easier and keeps CI green without secrets in every machine.
Centralize send logic in one notification service: resolve the user’s stored tokens, build a multicast (or batch) payload, call FCM, and prune tokens that FCM reports as invalid. Logging should record success and failure counts without dumping full tokens into logs.
A useful shape for that service is: createNotificationRecord → resolveTokensForUser → sendMulticast → updateTokenHealth. Keeping “what happened in the product” separate from “how FCM delivered it” makes support easier when a user says they never got the alert.
Validate tokens when they are registered if your Admin SDK setup allows it. Reject empty strings and obvious garbage early so your token table stays small and sends stay cheap.
Clients: public config only
Web and desktop renderers need the Firebase web config (apiKey, authDomain, projectId, messagingSenderId, appId). Those values are public by design, but they are not authorization. Restrict who can register tokens by requiring a logged-in session on your API.
Mobile apps (Expo or React Native) use platform-specific setup, but the contract stays the same: obtain a token, POST it to the API with device metadata (platform, app version), and refresh when the token rotates.
Desktop Electron apps often run the same React notification helpers as web inside the renderer. Prefer registering tokens after auth succeeds, and clear them on logout so a shared machine does not keep receiving another user’s alerts.
On the web, remember service workers and permission prompts. Ask for notification permission in a context the user understands—after they enable “desktop alerts” in settings—not on the first paint of the marketing page.
Store a stable device id or label alongside the token when you can (for example “Chrome on Linux” vs “Pixel”). Support staff and power users appreciate being able to revoke one device without logging out everywhere.
Priority and call-style alerts
Not every notification is equal. A routine digest can be data-only or low priority. An incoming call or time-critical alert may need high priority and a dedicated payload flag so mobile can wake aggressively and desktop can show a stronger UI.
Keep that decision on the server when the event is created. Clients should not invent priority; they should react to fields the API already understands. That prevents one client from screaming while another silently drops the same event.
Payload design matters. Put a short title and body for display, plus a small data map for deep links (thread id, order id). Avoid stuffing large JSON into the push; the app should fetch details after open.
For call-style flows, document what happens if the push is delayed: the callee might open the app from a missed-call inbox instead. Your API should still expose the missed state even if FCM was slow.
- Register token after login; unregister or deactivate on logout
- Store multiple tokens per user (phone + laptop + browser)
- Delete tokens that FCM marks unregistered
- Use high priority only for true real-time events
- Deep link with ids, not full records, in the payload
Failure modes you will hit in production
Tokens go stale when apps are uninstalled. Expect a steady stream of invalid-token errors and clean them automatically.
Users deny permission and later forget. Your UI should show “notifications blocked in browser settings” instead of pretending everything is fine.
Rate limits and vendor outages happen. Queue or retry with backoff for non-critical sends; for critical alerts, consider a secondary channel (email or in-app bell) so the product still communicates.
Duplicate sends confuse users. Deduplicate by event id when the same domain event can trigger notification code more than once (webhooks, double submits, retries).
Testing without hurting production
Use a separate Firebase project for development when you can. If you must share a project, tag messages with environment metadata and never point a production service account at a developer laptop.
Smoke-test the path end to end: login on each client, confirm a token row exists, trigger a test notification from a protected admin or script, and verify delivery. API-only unit tests are not enough for push.
Document the exact env vars each surface needs. Missing one Firebase Admin field should fail validation with a readable message—not a vague 500 when the first push is sent.
Add a staging checklist: web permission granted, mobile physical device (simulators are limited), desktop build with the correct public config, and at least one successful multicast in logs.
Closing
Firebase push works best when the API is the single sender and every client is just a token provider. Make FCM optional in local env, prune dead tokens automatically, and keep the service account off every public bundle.
If you only remember one rule: clients may hold public Firebase web config; only the server may hold the service account that can actually send.