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.
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:
- Push arrives → the service worker shows "New message from X".
- The user taps the notification.
- The service worker
postMessages the app to open the right screen. - The message itself shows up when a normal HTTP
GETruns.
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.
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.
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.
| 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.
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 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.jsonAnd 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.