Push messaging
Why every Android app shares one push connection, why that connection is the only reliable way to wake a sleeping phone, and why high-priority messages are a budget rather than a setting.
A phone that lets every app hold its own connection to its own server would never sleep. Push messaging exists because that is untenable, and the design that replaced it is the reason notifications arrive instantly on a device in Doze.
TL;DR#
- C2DM → GCM → FCM: three generations of one idea — a single, system-level connection that servers can use to wake apps.
- The phone holds one long-lived connection to the push service, not one per app. That is the efficiency win.
- The app's registration token is its address. Tokens rotate; the app must send the new one to its server.
- A notification message may be posted by the push service without running your code. A data
message runs your
FirebaseMessagingService. - High priority can wake a Doze-ing device briefly. It is a limited budget for user-visible urgency, not a delivery guarantee.
- Push is at-least-once. Handlers must be idempotent.
Mental model#
Think of a building with one mailroom.
Every tenant — every app — could install its own letterbox through the front wall, and the building would be full of holes. Instead there is one mailroom, staffed around the clock, that receives for everyone and delivers to the right box.
The push service is the mailroom. The token is the tenant's box number, and it can change when the tenant moves. Your server is the sender, and it must know the current box number or its letters are returned to sender.
The mailroom staff can also put a notice on the lobby board themselves — that is a notification message posted by the push service, with no tenant involvement.
Where the analogy breaks#
A mailroom is neutral infrastructure. The real push service is operated by one company, which means using it is a dependency on that company, and on a build of Android that includes its services. On devices without GMS, push works differently or not at all, which is why some apps still maintain alternate paths.
The big picture#
Text description of this diagram
A seven-step sequence. The app obtains a registration token and sends it to your server. The server stores the token against the user. To send, the server authenticates with a service account and posts the message to the FCM backend, naming the target token. The backend routes it to the device's existing connection to Play services. Play services delivers it: a data message calls onMessageReceived in your FirebaseMessagingService, while a notification message may be posted by Play services itself when the app is in the background. Priority policy applies: high priority can wake a Doze-ing device for a short window, normal priority waits for a maintenance window. Delivery is at-least-once, so duplicates are possible.
The step that changes behaviour most is deliver: whether your code runs at all depends on whether the payload is a notification or a data message, and whether the app is in the foreground.
Deep dive#
The three generations#
C2DM (Cloud to Device Messaging) launched in 2010 and was limited and short-lived: a small payload, a small number of messages per day, and no support for the scale Android reached.1
GCM (Google Cloud Messaging) replaced it in 2012 with a broader API, larger payloads and better delivery. It became the standard for years.
FCM (Firebase Cloud Messaging) replaced GCM in 2016, unifying the client SDKs, adding topics and web push, and folding into the Firebase platform.1
The architecture did not change across the three. What changed was the payload size, the targeting features and the client SDK.
Why centralisation is the point#
Every app holding its own socket means every app holding its own radio connection, and the radio is the most expensive component on a phone. Three apps polling means three wakeups a minute; the modem never returns to its low-power state.
One shared connection means the modem wakes once, receives everything queued for every app, and goes back to sleep. The push service multiplexes.
Tokens, and what happens when they change#
class PushService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
// The token is the address. Send it to your server.
// Do this on a background dispatcher; this callback is not a coroutine scope.
applicationScope.launch {
api.registerDevice(token)
}
}
override fun onMessageReceived(message: RemoteMessage) {
val type = message.data["type"] ?: return
when (type) {
"message" -> handleMessage(message)
"sync" -> enqueueSync()
}
}
}Three rules that follow from tokens being addresses:
- Send the token to your server on every
onNewToken, not just at first launch. - Do not treat a token as a user id. It identifies an app installation, and it can be reused after an app is uninstalled.
- Delete tokens on logout. Otherwise the next user of the device receives the previous user's notifications.
Notification messages vs data messages#
This is the distinction that causes the most confusion.
| Payload | App in foreground | App in background or killed |
|---|---|---|
notification + data | onMessageReceived is called | The system posts the notification; your code does not run |
data only | onMessageReceived is called | onMessageReceived is called, subject to priority |
The practical consequence: if you need to run code, send a data message. If you just want a notification, a notification message is simpler and more reliable, because the system delivers it even when your process is dead.
A hybrid payload is a common compromise: a notification block for the visible part and a data
block that your code reads when it does run.
Priority and Doze#
FCM has two priorities:1
- Normal — delivered when the device next wakes. In Doze, that means the maintenance window.
- High — may wake the device briefly, even in Doze.
High priority is not a setting you turn on. It is a claim that the message is user-visible and time-sensitive. The push service monitors apps that use it for non-urgent traffic and can downgrade them.2
{
"message": {
"token": "…",
"android": {
"priority": "high"
},
"data": {
"type": "incoming_call",
"call_id": "abc123"
}
}
}A chat message from a human, an incoming call, a delivery arriving at the door: high priority. A nightly catalogue sync, an analytics event, a promotional push: normal. The distinction is not about how much you care; it is about whether the user would be annoyed to receive it four hours late.
Push as a sync trigger#
The most robust pattern is not to send the data in the push at all. Send a thin signal — "there is new content" — and let the app fetch it with WorkManager.
override fun onMessageReceived(message: RemoteMessage) {
if (message.data["type"] == "sync_available") {
WorkManager.getInstance(applicationContext).enqueueUniqueWork(
"sync-from-push",
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build(),
)
}
}Why this is better than sending the payload:
- The payload limit is small (FCM messages are limited to a few kilobytes).
- The push service does not guarantee delivery order or exactly-once.
- WorkManager handles retries and constraints, which push does not.
- A thin signal is not sensitive data sitting in a notification payload.
What push cannot do#
- Guarantee delivery time. A device in Doze with normal priority waits.
- Guarantee exactly once. Duplicates happen; handlers must be idempotent.
- Work without GMS. On devices without Google Play services, FCM is not available.
- Replace a socket for real-time collaboration. A push is a wake-up, not a transport.
The delivery contract, stated plainly#
Push is best effort with an at-least-once guarantee, and both halves of that phrase matter.
Best effort means the service will try, and the device will receive when it can. A device that is off, in Doze with normal priority, or out of storage may not receive for hours. Any feature that assumes otherwise is broken on real devices, which is why the "thin signal plus WorkManager" pattern is so robust: it degrades into a normal sync.
At-least-once means duplicates. The same message can arrive twice, especially after a reconnect. A handler that increments a counter, inserts a row, or sends a reply must be idempotent, keyed by the message id or by the state it is trying to reach.
The combination is why push is a good trigger and a poor transport. Treat it as a doorbell, not as a delivery van.
What happens when the app is force-stopped#
A force-stopped app does not receive push messages until the user opens it again. That is an OS rule about the app's state, not an FCM limitation, and it is the same rule that stops alarms and jobs. It is also why "reinstall to fix notifications" is occasionally real advice: the reinstall resets the app's stopped state.
How it evolved#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 17 | 4.2 Jelly Bean |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 34 | 14 Upside Down Cake |
|
| 35 | 15 Vanilla Ice Cream |
|
| 36 | 16 Baklava |
|
Under the hood#
- DeviceIdleController.javathe Doze state machine that high-priority messages interact with
- NotificationManagerService.javawhere a notification message posted by the push service ends up
- NotificationManager.javathe client side of posting a notification
The FCM client itself is not in AOSP — it ships as part of Google Play services — which is why a pure AOSP build has no push messaging at all.
Curiosity corner#
- GCM's shutdown was announced years before it happened, and apps that missed the deadline simply stopped receiving messages.
- Topic messaging lets a server send to millions of subscribers without knowing their tokens, which is how news apps do breaking-news pushes.
- A notification message posted by the system while your app is dead is one of the few ways an app can produce UI without its process running.
Try it#
adb shell pm list packages | grep com.google.android.gms
Example output
package:com.google.android.gms versionCode=... curl -X POST
-H "Authorization: Bearer $ACCESS_TOKEN"
-H "Content-Type: application/json"
https://fcm.googleapis.com/v1/projects/example/messages:send
-d '{"message":{"token":"DEVICE_TOKEN","data":{"type":"sync_available"}}}'
Build it#
Add FCM to a sample app, log the token on first run, and send a data-only message from the Firebase
console. Then send a notification message and watch onMessageReceived not be called when the app is
in the background. That difference is the whole payload model.
Check yourself#
Check yourself
Put these background restrictions in the order they were introduced.
Drag, or use the arrows to reorder.
- 1Foreground service types become mandatory (API 34)
- 2Alarms become inexact by default (API 19)
- 3Doze defers network, jobs and alarms (API 23)
- 4Background activity starts are blocked (API 29)
- 5Background services are stopped after the app leaves the foreground (API 26)
Sources#
Sources
Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.
- docsFirebase Cloud Messagingfirebase.google.comaccessed 2026-10-27↩
- docsOptimize for Doze and App Standbydeveloper.android.comaccessed 2026-10-27↩
- docsBackground tasks overview | Background workdeveloper.android.comaccessed 2026-10-27↩
- docsStandby bucketsdeveloper.android.comaccessed 2026-10-27↩
- docsNotifications overviewdeveloper.android.comaccessed 2026-10-27↩
- docsPermissions best practicesdeveloper.android.comaccessed 2026-10-27↩