MQTT.Agent - open protocol for AI agents

Back to blog
Push Notifications Web Push

Web Push Without Firebase

We added VAPID-based web push so apps can reach users when the tab is closed, without taking a dependency on FCM.

January 21, 2026
6 min read
By CloudSignal Team

When we sat down to add notifications that survive a closed browser tab, the easy path was Firebase Cloud Messaging. It is the default that everyone reaches for, and almost every blog post and tutorial assumes it. We chose not to. The web has a perfectly good push standard that does not require a Firebase project, a Google Cloud account, or an SDK that hides what it is doing. This post is the short story of how we wired it up, what we store, and where the standard quietly falls short.

Why teams default to Firebase

FCM became the default for web push for understandable reasons. The SDK is one import. The console is friendly. The name is recognizable to anyone who has shipped a mobile app, which makes it the safe choice in a planning meeting. Tutorials are everywhere, and almost every “add push to your app” guide reaches for it within the first two paragraphs.

The cost is not in the developer experience on day one. It is in what you take on for a single capability. A Firebase project to maintain. Service accounts to rotate. An opaque dependency on Google’s auth infrastructure, where outages and quota changes are not yours to fix. For teams that already run their own backend and just want browsers to receive a message when the tab is closed, this is a lot of surface area in exchange for a small piece of functionality. We did not want a second identity system or a second console for a feature that, underneath, is just an HTTP request to a push service. The standard is already designed around that, and the browsers already implement it.

What web push actually is

Web push is a W3C standard, not a Google product. The flow is simple once you see it. The browser, on the user’s instruction, registers a push subscription with whichever push service it talks to: Mozilla’s autopush for Firefox, Apple’s push service for Safari, Chrome’s push service for Chrome and Edge. The subscription comes back as three things: an endpoint URL on the push service, an auth secret, and a p256dh public key bound to the user’s device. Your server holds your own VAPID keypair, signs a request to the endpoint URL with the private half, and the push service delivers the payload to the browser. No Firebase, no FCM, no third-party SDK in the page. The push service is whichever one the browser already trusts, and the browser already knows how to receive from it.

Our pipeline

The integration is four steps, and we kept each of them honest:

  1. Client registers a service worker on first visit.
  2. Client calls pushManager.subscribe with our public VAPID key.
  3. Client posts the subscription to our /v2/push/subscriptions endpoint, scoped to the current user.
  4. When an MQTT message lands on notifications/{org}/{user} and the user’s foreground client is offline (no active session at the broker), our push dispatcher signs a VAPID request and sends to the push service endpoint.

The client side is small:

const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: VAPID_PUBLIC_KEY,
});
await fetch("/v2/push/subscriptions", {
  method: "POST",
  body: JSON.stringify(sub.toJSON()),
});

The dispatcher runs against the same notification topic the in-app transport reads, so push is a fallback path rather than a parallel system. If a session is live at the broker, MQTT wins and push stays quiet.

Storing subscriptions

We persist the subscription as a row, not a blob. The fields are explicit: endpoint URL, the p256dh key, the auth key, user_id, org_id, an expires_at if the push service provided one, and a created_at for housekeeping. The endpoint is unique per device, so a single user with two browsers ends up with two rows, and the dispatcher fans out across them when it sends.

Revocation is a real concern. On logout we delete the row and call unsubscribe on the browser side, so the push service stops accepting requests against that endpoint. Browsers also rotate endpoints silently, usually after a long idle period or a browser update, and there is no notification that they did. We treat a 410 Gone response from the push service as the canonical signal that a subscription is dead and evict the row. A 404 from a Chrome endpoint means the same thing in practice. Without that eviction step the table grows linearly with churned devices, and most of the rows are sending to addresses nobody is listening on.

Where this falls short

Honesty matters more than coverage. iOS Safari supports web push, but only inside a PWA the user has installed to their home screen. A user who visits the site in a regular Safari tab gets no push, and there is no JavaScript workaround. Convincing a user to install the app on their device is its own product problem.

Payload size is capped. Chrome and Firefox allow about four kilobytes of ciphertext. Apple’s push service is stricter. This is fine for a notification body and a few fields of metadata; it is not fine for embedding rich content. We treat the payload as a pointer and let the service worker fetch the rest if it needs to.

Rich media support is inconsistent. Image attachments, action buttons, and inline replies behave differently across browsers, and the safe subset is small. Background delivery itself is best-effort: push services prioritize battery and bandwidth over latency, and a message can arrive seconds or minutes after we send it. For status updates and high-signal events this is fine. For anything time-critical it is not.

What we want to add next

The shape we are aiming at is unified delivery. A backend should publish one event and have it land in MQTT for live sessions, web push for closed tabs, and native push for installed mobile apps, without choosing the transport upstream. The routing, the deduplication when two transports both reach a user, and the suppression logic when one of them succeeds, all belong in the dispatcher rather than in application code. We are not promising dates on this, because the deduplication problem is the interesting one and we want to get the semantics right before shipping an API. The substrate is in place; the next pass is making the choice of transport stop being a question the application has to answer.

Ready to get started?

Try CloudSignal free and connect your first agents in minutes.

Start Building Free