Skip to content

Latest commit

 

History

History
152 lines (108 loc) · 6.31 KB

File metadata and controls

152 lines (108 loc) · 6.31 KB

Two questions everyone asks

1. Do subscriptions work both ways?

No. Push is one-way: backend → frontend. That is all.

This is the first thing to detach mentally from WebSockets, because it is the most common confusion:

  • A WebSocket is an open pipe. Both sides can talk whenever they want, as long as the pipe is open.
  • A push is a doorbell. You ring the bell at the user's house. The user cannot ring yours back over that same wire. There is no wire: there is a postal address.

A PushSubscription is literally a mailing address for the device. It is for sending to. It is not a channel, it has no return path, it holds no state.

So how does the frontend reach the backend? Plain HTTP, like always. fetch, axios, a POST. You never needed push for that direction, because to talk from front to back your app is already open and online — there is no problem to solve.

The mental model worth fixing:

What you need What you use
Front → back Plain HTTP (fetch/axios). Already solved.
Back → front, app open Polling, SSE or WebSocket
Back → front, app CLOSED Push. The only option that exists.

Those are two different mechanisms for two different situations, not one replacing the other. A perfectly reasonable architecture is polling (or a socket) while the app is open, and push while it is closed.

The detail almost nobody explains: push is the doorbell, not the parcel

When a push arrives, your service worker wakes up with a small payload (4KB max). That does not update your app. Your React state still knows nothing.

The usual real-world flow:

  1. Push arrives → the service worker shows "New message from X".
  2. The user taps the notification.
  3. The service worker postMessages the app to open the right screen.
  4. The message itself shows up when a normal HTTP GET runs.

So: the push announced that something happened, but the content came over an ordinary request. You can put data in the payload and use it, but the standard pattern is ring the bell, then go fetch.

2. Does it always go through Google's cloud?

Short answer:

Yes. Every notification reaching a Chrome user travels through Google's servers. There is no way around it.

The usual intuition is "the subscription is registered against my server", which is half true, and that is the trap:

  • ✅ The subscription is stored on your server. Correct.
  • ❌ But the subscription is not created against your server. The browser creates it, talking to its push service. Your server only receives the finished result.

And that result — the endpoint — is a URL pointing at Google:

https://fcm.googleapis.com/fcm/send/dQw4w9WgXcQ...

That URL is the proof. When your backend calls webpush.sendNotification(subscription, payload), the library POSTs to that URL. And that URL belongs to Google.

The actual round trip

A user opens your site in Chrome and enables notifications:

1. Chrome (user's phone)  ──>  Google
                               "give me an address for this device"
2. Google  ──>  Chrome         "https://fcm.googleapis.com/fcm/send/dQw4..."
3. Chrome  ──>  your server    POST /api/subscriptions
4. your server  ──>  DB        stores endpoint + p256dh + auth

Something happens that is worth notifying:

5. your server  ──>  DB                  writes whatever it was
6. your server  ──>  GOOGLE  ★           POST https://fcm.googleapis.com/fcm/send/dQw4...
                                         (encrypted payload + VAPID-signed JWT)
7. Google  ──>  Chrome (user)            down the connection Chrome already had open
8. Chrome  ──>  sw.js                    fires the 'push' event
9. sw.js  ──>  showNotification()        it appears on screen

Step 6 is your server talking directly to fcm.googleapis.com. That traffic exists, and it leaves your machine every single time you notify someone.

If the user were on Firefox: exactly the same code, but step 6 goes to updates.push.services.mozilla.com. On Safari, web.push.apple.com. No backend changes at all. That is the entire point of the standard.

So what does Google see, and what doesn't it?

Google does NOT see Google DOES see
The message text (end-to-end encrypted) That a push happened
Who your users are When, and to which endpoint (= which device)
Your database The size of the blob
Your app, your tables, your logic Your VAPID identity (your mailto:)

Think of it as postal mail with a sealed envelope: the carrier cannot read the letter, but knows perfectly well who sent it, to which address, when, and how much it weighs. Google is a dumb encrypted relay — but it knows the metadata.

Why can't you avoid it?

Because the persistent connection to the device belongs to the browser, not to you.

Chrome keeps one connection open to Google's infrastructure for every site that uses push. Precisely so that fifty different pages do not hold fifty open sockets draining the battery.

You have no way into that connection, because you are not the browser. If you want somebody to wake up the user's phone, it has to be somebody who is already inside the phone. That is Google, Apple or Mozilla, depending on the browser.

(For the curious: Mozilla's push service is open source, and alternatives like UnifiedPush/ntfy exist in the de-Googled Android world. But they only work if you control the client. For a normal website, not an option.)

The apparent contradiction

The demo in this repo contains zero references to Firebase, Google, GCM or service accounts. Check it yourself:

grep -riE "firebase|serviceaccount|gcm_sender" demo/*.js demo/public/*.js demo/package.json

And yet the traffic goes through Google.

This is the distinction from the previous doc made concrete:

  • FCM as plumbing → Chrome's push infrastructure. You use it whether you like it or not, free, with no account, without knowing.
  • FCM as a product → Firebase, with its console, SDK and credentials. That is what this demo does not use.

VAPID is precisely what lets you use Google's plumbing without being a Google customer. Your VAPID key replaces the Firebase account. That is why the backend is clean of credentials and still works.