← All problems

Mobile Core Concepts

Push and Notifications

Why push is a wakeup signal, not a data channel - and the FCM + notification rules that keep a background app responsive without burning battery.

Free~18 min

Push is how a backend reaches a phone that isn't running its app right now. On Android the wire is owned by Firebase Cloud Messaging: every app shares one persistent connection Play Services maintains, and any per-app long-lived socket is a battery liability you have to justify against that baseline. This page covers what comes up in interviews: FCM tokens and the two payload kinds, why real apps go data-only, the channel + NotificationCompat.Builder shapes, the trampoline ban from Android 12, the push-to-pull pattern, and the runtime POST_NOTIFICATIONS permission from API 33. WebSocket / SSE alternatives to push live in the Network Protocols entry; this page assumes you've already decided push is the right transport.

FCM as the push transport

FCM is the de-facto push layer on Android. Play Services holds one persistent socket to Google's edge, multiplexed across every app on the device. Your backend POSTs to FCM's HTTP v1 endpoint; FCM fans out to the right device's session; Play Services routes the payload into your app's FirebaseMessagingService. There is no long-lived socket your app owns - that's the win. Battery, doze compatibility, network re-establishment are all FCM's problem.

A device acquires an FCM registration token on first run. The token is an opaque Play-Services-issued string scoped to (app install, sender ID); your backend stores it keyed by user ID. Tokens rotate: fresh install, app data clear, restore-to-new-device, or Play Services' own rotation policy invalidates the previous one. FirebaseMessagingService.onNewToken(token) fires on rotation, and the only correct response is to upload it and replace the previous mapping.

class AppMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        // Server-stored mapping: userId -> token. Server addresses pushes by userId,
        // resolves to the current token at send time.
        backend.registerPushToken(userId = session.userId, token = token)
    }
 
    override fun onMessageReceived(msg: RemoteMessage) {
        // Always called when the app receives a data message - foreground or background.
        handle(msg.data)
    }
}

FCM addresses messages three ways: by registration token (one device), by topic (every device subscribed to a name, news_breaking), or by device group (a server-defined token set for one user). Topics are subscribe-on-device and good for broadcast features; per-user notifications go by token.

Sources: FCM architecture overview · Manage FCM registration tokens

Data vs notification messages

FCM accepts two payload kinds, and the choice changes how your app behaves.

A notification message carries a notification block (title, body, image). When the app is backgrounded or killed, Play Services auto-displays without your code running. When the app is foregrounded, onMessageReceived fires and auto-display is suppressed. Simple, but least flexible: no localization, no local-state suppression, no updating an existing chat notification.

A data message carries only a data block (your own key-value map). onMessageReceived is always called - foreground, background, or freshly woken from kill - and your code decides whether and what to display.

Aspectnotification payloaddata payload
Display when backgroundedAuto by Play ServicesYour code, in onMessageReceived
Display when foregroundedSuppressed; onMessageReceived firesonMessageReceived fires
Localization of title/bodyServer-side strings onlyLocal resources via getString(R.string.xxx)
Suppress based on local stateNot possibleTrivial - check state, skip the notify
Update existing notificationLimited to tag matchingFull MessagingStyle re-build
Doze / app-standby overridepriority: high workspriority: high works

Most production apps send data-only. The control - "my code decided not to fire because the user is already in that chat" - is worth the extra code.

Two transport knobs cross both payload kinds:

  • priority - high wakes the device immediately and bypasses Doze for that delivery; normal batches with the next maintenance window. High is rate-limited per app; abusing it gets your app deprioritized.
  • collapse_key - several messages with the same key queued while offline collapse to only the latest on reconnect. Right for score updates; wrong for chat.

Notification channels

Since API 26, every notification must be posted to a NotificationChannel. The channel carries importance, default sound, vibration, light, and lockscreen visibility - the user can toggle each per-channel in system settings. Posting to a missing channel silently drops the notification on API 26+.

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        val nm = getSystemService(NotificationManager::class.java)
        // Idempotent - safe to call every cold start. The system de-dupes by id.
        nm.createNotificationChannel(NotificationChannel(
            "chat_messages", "Messages", NotificationManager.IMPORTANCE_HIGH
        ).apply { description = "Incoming chat messages" })
        nm.createNotificationChannel(NotificationChannel(
            "marketing", "Promotions", NotificationManager.IMPORTANCE_LOW
        ))
    }
}

Importance is a four-level scale: HIGH (heads-up banner + sound), DEFAULT (sound, no banner), LOW (silent in tray), MIN (collapsed, no sound, no badge). HIGH needs real justification - the user is one swipe away from muting your entire app.

Channel groups are the right shape when one app has many notification kinds: per-conversation channels in a messenger, per-team channels in a workplace app. Group them under "Direct messages" / "Mentions" / "Channels" so the user toggles a category, not 200 conversations.

Channel properties are immutable after creation. Bumping IMPORTANCE_DEFAULT to IMPORTANCE_HIGH on an existing channel does nothing - the user's local setting wins. The workaround is to ship a new channel id (chat_messages_v2) when you genuinely need to reset defaults.

Sources: Create and manage notification channels

Building a notification

NotificationCompat.Builder is the API - same call shape across API levels, per the "prefer *Compat" rule in Android Compatibility.

val tap = PendingIntent.getActivity(
    this, /* requestCode = */ chatId.hashCode(),
    Intent(this, ChatActivity::class.java).putExtra("chat_id", chatId),
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notif = NotificationCompat.Builder(this, "chat_messages")
    .setSmallIcon(R.drawable.ic_notification)
    .setContentTitle(senderName)
    .setContentText(messagePreview)
    .setContentIntent(tap)               // tap on the notification body
    .setAutoCancel(true)
    .build()
NotificationManagerCompat.from(this).notify(chatId.hashCode(), notif)

The interesting parts are the expanded styles and actions.

Expanded styles change the layout when the user pulls the notification down. BigTextStyle for long single-string content, BigPictureStyle for an image, InboxStyle for a list of lines, MessagingStyle for chat. MessagingStyle is the one to actually know - it models conversation / sender / self, renders in the Android 11+ Conversations section, and is the input to bubble notifications.

val style = NotificationCompat.MessagingStyle(
    Person.Builder().setName("You").setKey(myUserId).build()
).apply {
    conversationTitle = chat.title
    isGroupConversation = chat.isGroup
    chat.recentMessages.forEach { m ->
        addMessage(m.text, m.timestampMs,
            Person.Builder().setName(m.sender).setKey(m.senderId).build())
    }
}
val notif = NotificationCompat.Builder(this, "chat_messages")
    .setSmallIcon(R.drawable.ic_notification)
    .setStyle(style)
    .setShortcutId(chat.shortcutId)      // links notification to a dynamic shortcut → bubble eligible
    .build()

Actions (addAction) hang buttons off the notification: reply, mark-as-read, snooze. A RemoteInput attached to an action turns it into an inline reply - the user types in the shade and your PendingIntent receives the text. Reply actions target a BroadcastReceiver (so the shade doesn't open the app's UI) and must update the existing notification so the user sees their own reply land.

The split between setContentIntent and addAction is essential: setContentIntent is the primary tap target (the notification body) and dismisses on tap with setAutoCancel(true); addAction items are explicit buttons and do not auto-dismiss.

Notification trampolines (banned on Android 12+)

Pre-Android-12, a common pattern was: notification taps a PendingIntent.getBroadcast(...) or getService(...); the BroadcastReceiver or Service runs some logic (analytics, lookup, redirect) and then calls context.startActivity(...). The Service or Receiver was the "trampoline" the tap bounced off on its way to the UI.

On Android 12 (API 31) Google banned this. From targetSdk >= 31, an activity launched from a notification trampoline is blocked by the OS - the startActivity no-ops with a logcat warning, and the user taps into nothing. The fix: your PendingIntent must directly target the Activity (PendingIntent.getActivity(...)). Work that used to happen in the trampoline (analytics, deep-link routing) moves into the activity's onCreate.

// Pre-Android-12 pattern - now broken when targetSdk >= 31:
val tap = PendingIntent.getBroadcast(  // Receiver was meant to log + launch Activity
    this, 0, Intent(this, NotificationTapReceiver::class.java),
    PendingIntent.FLAG_IMMUTABLE,
)
 
// Correct on Android 12+:
val tap = PendingIntent.getActivity(   // Direct to Activity; log analytics from onCreate.
    this, 0, Intent(this, ChatActivity::class.java).apply { putExtra("from_push", true) },
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)

One escape hatch: a Service that calls startActivity while the app is already foregrounded is still allowed. For the cold-tap-from-locked-screen case - which is the one that matters - it's getActivity only.

Push-to-pull pattern

The single most important design pattern in this space. A push payload is a notification, not a payload.

The model:

  1. Server emits push with a small data payload: { "type": "new_message", "chat_id": "c_42", "message_id": "m_8821" }.
  2. Client onMessageReceived reads the type, calls the app's own REST / WebSocket API to fetch the actual message body, attachments, sender metadata.
  3. Client builds the notification (or updates UI state) from the freshly-pulled data.

Why this is the staff move:

  • FCM payload caps at 4 KB. A real chat message with quoted-reply context, attachments, and sender info routinely blows past that. Push-to-pull side-steps the cap.
  • Server can retract. Between push and fetch, the user may have deleted, spam may have been detected, the sender may have been blocked. Pulling from authority gives the server one last chance to say "don't show this."
  • One source of truth for read state. If the push is the data, you have two copies that drift. If the push is a poke, the client's fetch updates delivered_at / read_at atomically server-side.
  • Doze-aware. A low-priority push that arrives during Doze can wake your worker, which schedules a constrained WorkRequest for the actual sync when network is up - see Background Work and Scheduling.

The cost is one extra round-trip per notification, and a fallback path: if the fetch fails, post a minimal "you have a new message" notification from the push payload alone - degraded but not silent.

POST_NOTIFICATIONS on API 33+

Before API 33, posting a notification needed no permission - notify() always worked. Starting API 33 (Android 13), POST_NOTIFICATIONS is runtime-prompted. Without it, every notify() is silently dropped (no exception, no log).

The compatibility shape is asymmetric:

  • Fresh installs on API 33+: not granted by default; you must request explicitly.
  • Apps upgrading on API 33+ with notifications enabled at the OS level pre-upgrade: auto-granted on first launch.
  • targetSdk < 33 on Android 13+: the system shows the dialog on your behalf the first time you create a channel or post. No code change, but the timing is unfortunate (mid-flow surprise).

The correct flow for a targetSdk >= 33 app:

private val askPostNotifications = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { granted ->
    if (!granted) analytics.log("post_notif_denied")
    // No exception either way - just denied notifications until user re-enables.
}
 
private fun requestNotificationPermissionIfNeeded() {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
    val granted = ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) == PERMISSION_GRANTED
    if (!granted) askPostNotifications.launch(POST_NOTIFICATIONS)
}

The right moment to ask is the judgment call. First launch maximizes denials - the user has no context. Asking right before the first notification would be useful (incoming chat, ride matched) maximizes grants. The capability-abstraction strategy from Android Compatibility applies cleanly: NotificationPermissionHandler interface, API 33 impl that prompts, legacy impl that no-ops.

Denial is silent. No exception when you call notify() without permission - the call returns normally, the system drops it, no error surfaces. Mitigation: check NotificationManagerCompat.from(ctx).areNotificationsEnabled() before posting, and if false, surface an in-app banner that deep-links to Settings.ACTION_APP_NOTIFICATION_SETTINGS.

Sources: Notification runtime permission

See also

  • Network Protocols - WebSocket, SSE, and long-poll are the alternatives to push for server-to-client messaging when the app is running. Push is the only viable option for backgrounded / killed delivery.
  • Android Compatibility - covers API 33 (POST_NOTIFICATIONS), API 31 (trampoline ban), and the Play Services availability question at a higher level.
  • Background Work and Scheduling - FCM-triggered work (push-to-pull's fetch step) is the canonical "push wakes a worker" pattern.

Used in

  • Messenger App - chat notifications use MessagingStyle, per-conversation tag for update / dismiss, inline-reply RemoteInput action, and push-to-pull for the message body. Per-chat channel groups under "Direct messages" / "Mentions."
  • Newsfeed App - low-importance channel for live updates; collapse_key per post id so several rapid-fire updates collapse to the latest while the user's offline.
  • Ride-Sharing App - high-priority data push for driver-arrival; the push wakes a worker that updates UI state and fires a high-importance notification on the ride_status channel. The trampoline ban matters: the tap on "your driver is here" goes directly to the live-trip activity.
  • Crash Reporter SDK - the control plane uses FCM topics (crash_reporter_sample_rate_change) for low-rate config push to every installed device; data-only payloads, no notifications surfaced to the user.
  • Analytics SDK - similar control-plane shape: FCM topic for "flush now" / "rotate config" pokes. Push-to-pull means the actual config is fetched over the SDK's own HTTPS path so it can be cached and signed.

Done reading? Mark it so it sticks in your dashboard.