Design a Push Notification System
1. Understanding the Problem
Design the Android client side of a push notification system β think the layer that handles FCM token registration, receives messages from Firebase Cloud Messaging, routes them to the correct notification channel, builds rich notifications, and deep-links users into the right screen on tap. The backend sends the push; your job is everything that happens on device.
The end-to-end flow on Android has four phases: (1) Register β obtain an FCM token and sync it to your backend. (2) Receive β FirebaseMessagingService.onMessageReceived() is your entry point for every push. (3) Build β construct a NotificationCompat with the right channel, priority, and actions. (4) Route β a tap fires a PendingIntent that deep-links into the correct screen via Navigation Component or explicit Intent.
β Functional Requirements
- Register device FCM token with backend on first launch
- Re-register when token rotates (security refresh)
- Receive push when app is in foreground, background, killed
- Show rich notifications: title, body, image, action buttons
- Group related notifications (e.g., 5 unread chat messages)
- Tapping a notification deep-links to the correct screen
- Notification preferences: user can disable per category
β Non-Functional Requirements
- Android 13+ POST_NOTIFICATIONS permission at runtime
- Notification channels for each category (Android 8+)
- Data messages must process even when app is killed
- No duplicate notifications for retried server sends
- Notification tap must work from killed state (cold start)
- Token sync must be idempotent β safe to call multiple times
- Respect Do Not Disturb / battery saver constraints
2. The Set Up
Notification message vs Data message β the most important FCM distinction
FCM displays the notification automatically using the notification payload. When the app is in the background or killed, Android shows it without calling your code.
- β Zero code to show notification
- β No custom logic before display
- β No image loading, no channel control
- β Always uses default channel
FCM always calls onMessageReceived() β even in background/killed state (via a high-priority wake). Your code has full control over display, channel, image loading, and local data sync.
- β Full notification customisation
- β Correct channel routing
- β Image download with Coil/Glide
- β Local data sync before showing
Notification channels β Android 8+ mandatory
3. High-Level Design
onMessageReceived() fires for foreground + background + killed (data messages). onNewToken() fires when token rotates.type field from the data payload and dispatches to the correct channel and builder strategy. Decoupled from FCM β testable in isolation.NotificationCompat with the right style (BigText, BigPicture, InboxStyle). Loads images synchronously via Coil for background builds.PendingIntent. For Navigation Component apps: wraps a NavDeepLinkBuilder. For cold-start: sets Intent extras for the launcher Activity.NotificationChannels on app launch. Idempotent β calling createNotificationChannel() with same ID is a no-op on Android 8+.4. Low-Level Design
FirebaseMessagingService β the FCM entry point
class AppMessagingService : FirebaseMessagingService() {
@Inject lateinit var router: NotificationRouter
@Inject lateinit var tokenManager: TokenManager
/**
* Called for data messages regardless of app state (foreground / background / killed).
* For notification+data combos in background, system shows the notification payload
* automatically β onMessageReceived is NOT called. Use pure data messages.
*/
override fun onMessageReceived(message: RemoteMessage) {
val type = message.data["type"] ?: return
router.route(type, message.data)
}
/**
* Called when FCM rotates the token (security refresh, app reinstall, etc.)
* Must sync new token to backend β old token becomes invalid.
*/
override fun onNewToken(token: String) {
tokenManager.onTokenRefreshed(token)
}
}
TokenManager β idempotent registration
class TokenManager @Inject constructor(
private val dataStore: DataStore<Preferences>,
private val api: NotificationApi,
private val scope: CoroutineScope
) {
private val KEY_TOKEN = stringPreferencesKey("fcm_token")
/** Called on first launch and whenever token changes */
fun initToken() {
scope.launch {
FirebaseMessaging.getInstance().token.await().let { newToken ->
syncIfChanged(newToken)
}
}
}
/** Called by FirebaseMessagingService.onNewToken() */
fun onTokenRefreshed(token: String) {
scope.launch { syncIfChanged(token) }
}
private suspend fun syncIfChanged(newToken: String) {
val cached = dataStore.data.first()[KEY_TOKEN]
if (cached == newToken) return // idempotent β don't spam backend
try {
api.registerToken(TokenRequest(token = newToken, platform = "android"))
dataStore.edit { it[KEY_TOKEN] = newToken } // only cache after server ACK
} catch (e: Exception) {
// Retry on next launch β don't cache so next call tries again
}
}
/** Called on logout β unregister token so server stops sending */
suspend fun clearToken() {
FirebaseMessaging.getInstance().deleteToken().await()
dataStore.edit { it.remove(KEY_TOKEN) }
api.unregisterToken()
}
}
NotificationRouter + Builder strategy
class NotificationRouter @Inject constructor(
private val context: Context,
private val deepLinkHandler: DeepLinkHandler
) {
fun route(type: String, data: Map<String, String>) {
val notification = when (type) {
"chat_message" -> buildChatNotification(data)
"promo" -> buildPromoNotification(data)
"breaking_news" -> buildBreakingNewsNotification(data)
"silent_sync" -> { triggerSilentSync(data); return }
else -> return // unknown type β ignore safely
}
val notifId = data["notif_id"]?.hashCode() ?: System.currentTimeMillis().toInt()
NotificationManagerCompat.from(context).notify(notifId, notification)
}
private fun buildChatNotification(data: Map<String, String>): Notification {
val tapIntent = deepLinkHandler.chatScreen(data["conversation_id"]!!)
val person = Person.Builder().setName(data["sender_name"]!!).build()
return NotificationCompat.Builder(context, CH_CHAT)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(data["sender_name"])
.setContentText(data["body"])
.setStyle(
NotificationCompat.MessagingStyle(person)
.addMessage(data["body"]!!, System.currentTimeMillis(), person)
)
.setContentIntent(tapIntent)
.setAutoCancel(true)
.setGroup("chat_${data["conversation_id"]}") // bundle by conversation
.build()
}
private fun buildBreakingNewsNotification(data: Map<String, String>): Notification {
// Load image synchronously β we're already on a background thread
val bitmap = data["image_url"]?.let {
Coil.imageLoader(context)
.executeBlocking(ImageRequest.Builder(context).data(it).build())
.drawable?.toBitmap()
}
val style = if (bitmap != null)
NotificationCompat.BigPictureStyle().bigPicture(bitmap)
else
NotificationCompat.BigTextStyle().bigText(data["body"])
return NotificationCompat.Builder(context, CH_BREAKING)
.setSmallIcon(R.drawable.ic_news)
.setContentTitle(data["title"])
.setContentText(data["body"])
.setStyle(style)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setContentIntent(deepLinkHandler.articleScreen(data["article_id"]!!))
.setAutoCancel(true)
.build()
}
private fun triggerSilentSync(data: Map<String, String>) {
// Launch an expedited WorkManager job β no notification shown
val work = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setInputData(workDataOf("entity" to data["entity"]))
.build()
WorkManager.getInstance(context).enqueue(work)
}
}
DeepLinkHandler β tap-to-screen routing
class DeepLinkHandler @Inject constructor(private val context: Context) {
/**
* NavDeepLinkBuilder correctly handles:
* - Foreground: navigates within running nav graph
* - Background/killed: builds a synthetic back stack so Back works correctly
*/
fun chatScreen(conversationId: String): PendingIntent =
NavDeepLinkBuilder(context)
.setGraph(R.navigation.nav_graph)
.setDestination(R.id.chatFragment)
.setArguments(bundleOf("conversationId" to conversationId))
.createPendingIntent()
fun articleScreen(articleId: String): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW, "app://news/article/$articleId".toUri())
.setPackage(context.packageName)
return PendingIntent.getActivity(
context, articleId.hashCode(), intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}
LLD Whiteboard β notification lifecycle flows
| App / Activity | TokenManager | Firebase SDK | Your Backend | DataStore |
|---|---|---|---|---|
1.App launches. Application.onCreate() calls tokenManager.initToken() |
Calls FirebaseMessaging.getInstance().token.await() |
β | β | β |
| β | β | 2.Returns current token (or generates new one on first run) | β | β |
| β | 3.Reads cached token from DataStore β null on first launch | β | β | read KEY_TOKEN β null |
| β | 4.Token changed β calls api.registerToken(token, "android") |
β | Stores token mapped to userId in DB | β |
| β | 5.Server returns 200 OK β writes token to DataStore | β | β | write KEY_TOKEN = newToken |
| On subsequent launches: DataStore token == FCM token β no API call | β | β | β | β |
| FCM / OS | MessagingService | NotifRouter | NotifBuilder | System Tray |
|---|---|---|---|---|
1.Backend sends FCM data message with priority: "high" |
β | β | β | β |
| FCM wakes device (high-priority wakelock), starts MessagingService process | 2.onMessageReceived() called β ~10 s deadline to complete |
β | β | β |
| β | 3.Reads data["type"] = "breaking_news", passes to router.route() |
Matches "breaking_news" branch |
β | β |
| β | β | 4.Calls buildBreakingNewsNotification(data) |
Fetches image via Coil.executeBlocking(imageUrl). Builds BigPictureStyle. |
β |
| β | β | 5.Calls NotificationManagerCompat.notify(id, notification) |
β | Heads-up notification appears. User sees image + title. |
| β | β | β | β | 6.User taps β PendingIntent fires β NavDeepLink opens ArticleFragment |
| System Tray | MainActivity (cold start) | NavController | NavDeepLinkBuilder | Fragment |
|---|---|---|---|---|
| 1.User taps the notification while app is killed | β | β | β | β |
OS fires the PendingIntent built by NavDeepLinkBuilder |
2.MainActivity starts cold. onCreate() inflates nav graph. |
β | β | β |
| β | 3.NavController.handleDeepLink(intent) is called automatically |
Reads the deep link URI from intent extras | β | β |
| β | β | 4.NavDeepLinkBuilder pre-built a synthetic back stack: Home β Article | Back stack: [HomeFragment, ArticleFragment] |
β |
| β | β | β | β | 5.ArticleFragment is on screen. articleId available via Safe Args. Back press β HomeFragment. |
5. Deep Dives
Notification channels and permission (Android 13+)
object ChannelRegistry {
const val CH_CHAT = "channel_chat"
const val CH_PROMO = "channel_promo"
const val CH_BREAKING = "channel_breaking"
const val CH_SILENT = "channel_silent"
// Call once from Application.onCreate() β idempotent on 8+
fun createAll(context: Context) {
val mgr = context.getSystemService(NotificationManager::class.java)
listOf(
NotificationChannel(CH_CHAT, "Chat Messages", IMPORTANCE_HIGH),
NotificationChannel(CH_PROMO, "Promotions", IMPORTANCE_DEFAULT),
NotificationChannel(CH_BREAKING, "Breaking News", IMPORTANCE_MAX),
NotificationChannel(CH_SILENT, "Background Updates", IMPORTANCE_MIN)
).forEach { mgr.createNotificationChannel(it) }
}
}
// Android 13+ runtime permission β request from Activity/Fragment
val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) {
// Show rationale: "Enable notifications to get breaking news alerts"
}
}
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
launcher.launch(POST_NOTIFICATIONS)
}
Grouped notifications (InboxStyle + summary)
private fun buildGroupedChatNotif(messages: List<ChatMessage>): List<Notification> {
val groupKey = "com.app.CHAT_GROUP"
// Individual notification per message
val children = messages.map { msg ->
NotificationCompat.Builder(context, CH_CHAT)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(msg.senderName)
.setContentText(msg.body)
.setGroup(groupKey)
.build()
}
// Summary notification β required for grouping to work
val summary = NotificationCompat.Builder(context, CH_CHAT)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("${messages.size} new messages")
.setStyle(
NotificationCompat.InboxStyle()
.setSummaryText("${messages.size} new messages")
.also { s -> messages.take(5).forEach { s.addLine(it.body) } }
)
.setGroup(groupKey)
.setGroupSummary(true)
.build()
return children + summary
}
6. Expected at Each Level
- Knows FCM token must be registered with backend
- Understands
onMessageReceived()+onNewToken()lifecycle - Creates notification channels for Android 8+
- Handles foreground vs background behaviour difference
- Builds basic
NotificationCompatwith tap PendingIntent - Requests POST_NOTIFICATIONS on Android 13+
- Data message vs notification message β knows when to use each
- Idempotent token sync via DataStore cached comparison
- NavDeepLinkBuilder for correct back stack on cold start
- MessagingStyle / BigPictureStyle / InboxStyle β right style per type
- Grouped notifications with mandatory summary
- Silent sync via expedited WorkManager from
onMessageReceived - Token cleanup on logout with
deleteToken()
- Server-side fanout architecture β topic vs individual token sends
- Dedup:
notif_idfield, same ID = replace existing notification - Notification analytics: tap rate, dismiss rate per channel
- Batch notification collapse β "5 messages" summary strategy
- A/B testing push copy via remote config feature flags
- Fallback: if FCM unavailable (China), detect and route to alternative
- Multi-device token management (tablet + phone same user)
7. Interview Q&A (20 Questions)
A notification message contains a notification JSON payload. When the app is in the background or killed, FCM displays it automatically using Android's system tray β onMessageReceived() is NOT called. You lose control over channel, image, and custom logic. A data message contains only a data payload. FCM always calls onMessageReceived(), even in background/killed state (using a high-priority wakelock). You have full control to build the notification with the correct channel, load images, or trigger a silent data sync without any visible notification. For production apps: always use pure data messages with a type field to dispatch logic in your service.
Two cases. (1) When the message has a notification payload and the app is in background or killed β FCM intercepts and displays it automatically without calling your code. (2) When the device has data saver enabled and the message is not high-priority β FCM may throttle delivery. To guarantee onMessageReceived() is always called: (a) use pure data messages only, (b) set priority: "high" on the FCM send request. For breaking-news style content, high priority is essential. Normal priority messages can be batched and delayed by the OS.
Since Android 8.0 (API 26), every notification must be assigned to a NotificationChannel. Channels let users control each notification category independently β they can mute Promotions without affecting Chat. You create channels via NotificationManager.createNotificationChannel(channel). Crucially, this is idempotent β calling it with the same channel ID multiple times is a no-op, so it's safe to call from Application.onCreate() on every launch. Once created, the user controls importance β your app cannot downgrade a channel the user has elevated, and vice versa. Create all channels on first launch, before posting any notification.
Android 13 introduced android.permission.POST_NOTIFICATIONS as a runtime permission. Without it, notifications are silently blocked. Request it using ActivityResultContracts.RequestPermission(). Best practice: don't request immediately on launch β show the permission rationale in context first (e.g., "Enable notifications to receive chat messages"). If the user denies: gracefully degrade without crashing, offer to enable it later via Settings. Check Build.VERSION.SDK_INT >= 33 before requesting β on older versions it's granted automatically. Target 33+ in your manifest to trigger the runtime request.
Token sync is called on every app launch and every onNewToken(). Without idempotency, you'd hit the backend API on every cold start β wasteful and potentially rate-limited. The fix: cache the last successfully synced token in DataStore. On each launch, compare the FCM-provided token against the cached value. Only call the backend if they differ. Crucially: only write the new token to DataStore after the server returns 200 OK β if you cache first and the network call fails, the token appears synced but isn't, and future launches won't retry.
When a user taps a notification from the killed state, the app cold-starts directly into the destination screen. Without careful setup, pressing Back would exit the app instead of going to Home β a broken UX. NavDeepLinkBuilder from Navigation Component solves this by constructing a synthetic back stack matching the nav graph hierarchy. You call .setGraph(R.navigation.nav_graph).setDestination(R.id.chatFragment).setArguments(bundle). The PendingIntent it creates inflates the full back stack, so Back from ChatFragment correctly goes to HomeFragment. This is the recommended approach over manual Intent construction.
On Android 8+, the notification is silently dropped β it doesn't appear anywhere, no error is thrown. This is a common bug where notifications work on Android 7 but silently fail on 8+. The fix: always call ChannelRegistry.createAll() in Application.onCreate() before any notification is posted. Since channel creation is idempotent, there's no cost to calling it on every launch. The notification must also reference the exact channel ID string β a typo means the notification is posted to a non-existent channel and dropped.
Use Android's notification grouping API. (1) Assign each notification a setGroup(groupKey) β same key groups them together. (2) Post a mandatory summary notification with setGroupSummary(true) and the same group key β without this, Android won't visually collapse the group. Use InboxStyle on the summary to show a preview of all messages. The summary appears on Android 7+ where multiple notifications collapse into one expandable bundle. On Android <7, individual notifications appear separately. Always post the summary last, after all child notifications.
onMessageReceived() runs on a background thread, so synchronous image loading is acceptable. Use Coil's imageLoader.executeBlocking() or Glide's Glide.with(context).asBitmap().load(url).submit().get() β both block until the image is downloaded. Convert the result to a Bitmap and pass to NotificationCompat.BigPictureStyle().bigPicture(bitmap). Cap total time: onMessageReceived() has about 10 seconds to complete before FCM kills it. Use a short timeout on the image request (2β3 s) and fall back to BigTextStyle if the download times out. Never load images on the main thread.
FCM gives onMessageReceived() approximately 10 seconds to complete all work. If it exceeds this, the service is killed and the message may be lost or shown incompletely. For work that exceeds 10 seconds (e.g., heavy sync, large image download): use onMessageReceived() only to enqueue an expedited OneTimeWorkRequest β .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST). The worker runs with expedited priority (similar to foreground service) and has no time limit. The notification can either be shown inside the worker or pre-shown in onMessageReceived() with a "Loadingβ¦" placeholder.
The server includes a stable notif_id in the data payload (e.g., "notif_id": "msg_12345"). On the client: use notif_id.hashCode() as the Android notification ID passed to NotificationManagerCompat.notify(id, notification). Calling notify() with an already-active ID replaces the existing notification silently β the user sees at most one notification per logical event. This handles the case where FCM delivers the same message twice (rare but possible). If the server retries with a different ID, you'll get a duplicate β so the ID must be stable and message-specific, not time-based.
A silent push is a data message where onMessageReceived() triggers a data sync without showing any notification to the user. Use cases: refreshing a feed in the background when new content is published, syncing chat message list before the user opens the app, invalidating a cached screen. Implementation: inside onMessageReceived(), check type == "silent_sync" and enqueue an expedited WorkManager job. The job syncs Room from the API. When the user opens the app, fresh data is ready. No NotificationManagerCompat.notify() call is made. Works even when the app is killed (high-priority data message).
FCM rotates tokens for security (when the app is reinstalled, the user clears app data, or Google Play Services decides to refresh). onNewToken(token) is called with the new token. You must sync it to your backend immediately β the old token is now invalid and server pushes will fail silently. Call tokenManager.onTokenRefreshed(token) which launches a coroutine to call api.registerToken(newToken) and updates DataStore. Also sync on every launch via FirebaseMessaging.getInstance().token.await() as a belt-and-suspenders check β onNewToken delivery is not guaranteed if the device was offline when the rotation happened.
Two steps on logout. (1) Client: call FirebaseMessaging.getInstance().deleteToken().await() β this invalidates the current token so FCM can no longer deliver to this device. Clear the cached token from DataStore. (2) Server: call your backend api.unregisterToken() so it removes the token from the user's device list. If you skip the server step, the backend will try to push to a deleted token, FCM will return a 404/InvalidRegistration error, and the backend should handle that by removing the stale token. If you skip the client step, a new token is generated on next launch β but until then, someone else logging in could receive the previous user's notifications.
MessagingStyle is purpose-built for person-to-person messaging. It displays a conversation thread with sender names, avatars via Person.Builder(), and supports multiple messages in one notification. It also integrates with smart reply suggestions on Android 10+ and the Bubbles API. BigTextStyle is for long text content from a single source β news articles, emails. Use MessagingStyle whenever you're showing chat messages. It gets preferential treatment by the OS (Conversation Space on Android 11+), appears higher in the notification shade, and supports direct reply without opening the app. Do NOT use it for non-conversation content.
Use RemoteInput to capture typed text inline. Build it with RemoteInput.Builder("reply_key").setLabel("Replyβ¦").build(). Create a PendingIntent pointing to a BroadcastReceiver or Service that handles the reply. Attach both to a NotificationCompat.Action with setAllowGeneratedReplies(true). In your receiver/service: call RemoteInput.getResultsFromIntent(intent) to extract the typed text. Send the reply to your API, then update the notification's MessagingStyle to add the sent message β otherwise the notification spinner keeps spinning. Cancel the old notification and re-post with the sent message included so the user sees their reply.
IMPORTANCE_MAX: full-screen intent (for incoming calls) or heads-up with sound + vibration. IMPORTANCE_HIGH: heads-up notification + sound β pops up over the current screen. IMPORTANCE_DEFAULT: sound, shows in shade but no heads-up pop. IMPORTANCE_LOW: silent, in shade only, no badge. IMPORTANCE_MIN: silent, collapsed at bottom of shade, no badge. The user can adjust this in System Settings per channel β your code cannot override user changes to importance after channel creation. This is by design: users have full control. Choose the right importance at channel creation β you cannot change it later in code.
FCM supports topic subscriptions. Call FirebaseMessaging.getInstance().subscribeToTopic("sports_cricket") β FCM registers the device for that topic on its servers. Your backend can then send one message addressed to /topics/sports_cricket and FCM fans it out to all subscribed devices. The client doesn't need to manage individual tokens for broadcast scenarios. Unsubscribe with unsubscribeFromTopic(). Limitations: topic messages are always low-priority by default; you can't target a specific segment of topic subscribers with personalized data; and FCM throttles bulk topic sends. For user-specific data (e.g., "your order shipped"), use individual token addressing.
FLAG_IMMUTABLE: once created, the PendingIntent cannot have its extras modified by the receiving component. Required for notification actions and deep links from Android 12+ (mandatory; your app will crash without it on API 31+). FLAG_MUTABLE: the receiving component can modify the Intent extras β required for specific cases like AlarmManager exact alarms and media session callbacks that need to inject data. For notification PendingIntents, always use FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT. The FLAG_UPDATE_CURRENT ensures that if a PendingIntent with the same request code already exists, its extras are updated β important when the same screen can be opened with different data.
FirebaseMessagingService runs in the main app process by default. If your app uses multiple processes (e.g., a separate :push process), ensure the service and its Hilt/Dagger injections are initialised correctly in that process β Application.onCreate() is called once per process, so multi-process apps need to guard initialisation with ProcessPhoenix or explicit process name checks. For Room: multi-process Room requires WAL mode and careful connection pooling. Simpler alternative: always run the MessagingService in the main process (the default) by not specifying android:process in the manifest. Dispatch heavy work to WorkManager, which handles cross-process coordination safely.