How Android Handles Push Notifications When the App is Killed

Your app is completely killed. Swiped away from recent apps. Zero processes alive. Yet a notification still shows up instantly.
How is this even possible?
If you’re like most Android developers, you’ve used Firebase Cloud Messaging (FCM) countless times. But have you ever stopped to ask: what’s actually happening under the hood?
In this deep-dive, we’ll uncover the complete architecture behind Android push notifications, why 90% of developers get this wrong in interviews, and how understanding this system can help you land that ₹15–25 LPA Android developer role.
The Problem Most Developers Don’t Understand
Here’s a scenario:
You’re building a chat app. Users complain: “I only get messages when I open the app!”
You check your code. FCM is integrated correctly. onMessageReceived() is implemented. Everything looks fine.
But notifications still don’t work when the app is killed.
The issue isn’t your code. It’s your understanding of how Android push notifications actually work.
Companies like Google, Flipkart, PhonePe, and Swiggy expect senior Android developers (3–6 years experience, ₹15–28 LPA) to understand this at a system design level. Not just “add Firebase dependency and call the API.” Let’s fix that.
The Architecture: It’s Not Your App Holding the Connection
Here’s the truth that surprises most developers:
Your app doesn’t maintain the connection to FCM servers. Google does.
The Complete Flow:
Your Backend Server
↓
Firebase Cloud Messaging (FCM) Servers
↓
Google Play Services (runs on every Android device)
↓
Binder IPC (Inter-Process Communication)
↓
Your App's FirebaseMessagingService
↓
Notification DisplayedKey Components:
1. Google Play Services
Runs as a system-level process (never killed by Android)
Maintains one persistent TCP/QUIC connection to FCM servers
Shared across ALL apps on the device
Has elevated OS privileges your app doesn’t have
2. Binder IPC
Android’s mechanism for inter-process communication
Allows Google Play Services to wake your app’s process
Secure, efficient, kernel-level implementation
3. FirebaseMessagingService
Your app’s service that receives messages
Runs in your app’s process (separate from Play Services)
Woken up by Android OS via Binder IPC when message arrives
Why One Shared Connection? (Battery Life)
Here’s the genius part:
Android maintains ONE shared connection for ALL apps on the device.
Not one per app. ONE.
Think about it:
Average user has 50–100 apps installed
If each app maintained its own persistent connection
Each connection = radio wake-ups, keep-alive packets, battery drain
Your battery would die in 3–4 hours
Instead:
Google Play Services holds one connection
All apps share it
Radio stays idle most of the time
Messages batched when possible
Result: Notifications without killing battery ⚡
This is why Android push notifications work reliably even on budget devices with 4000mAh batteries lasting 1–2 days.
The 3 App States (And How Notifications Work in Each)
This is where most developers get confused. Let’s clarify:State 1: Foreground (App is Open and Active)
What happens:
onMessageReceived()fires immediatelyYour code runs
You control everything
Notification payload:
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// This runs
// You must manually display notification
showNotification(...)
}Data payload:
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// This also runs
// You must manually display notification
showNotification(...)
}Key point: In foreground, system does NOT auto-display notifications. You handle everything.
State 2: Background (App Minimized, Not Killed)
What happens:
App is in background (home button pressed)
Your app process still exists
But not visible to user
Notification payload:
{
"to": "FCM_TOKEN",
"notification": {
"title": "New Message",
"body": "You have a new message"
}
}Result:
System auto-displays notification in tray
onMessageReceived()does NOT fireYour code doesn’t run at all
User taps notification → app opens
Data payload:
{
"to": "FCM_TOKEN",
"data": {
"title": "New Message",
"body": "You have a new message"
}
}Result:
onMessageReceived()firesYou must manually display notification
System does NOT auto-display
Pro tip: In background, notification payload = system handles it, data payload = you handle it.
State 3: Killed (App Swiped Away, Process Dead)
This is the one that confuses everyone.
What happens:
App process is completely dead
No activities, services, or receivers running
User swiped it away from recent apps
Notification payload:
{
"to": "FCM_TOKEN",
"notification": {
"title": "New Message",
"body": "You have a new message"
}
}Result:
System auto-displays notification in tray
Your app code does NOT run
onMessageReceived()does NOT fireUser taps notification → app launches from scratch
Data payload:
{
"to": "FCM_TOKEN",
"priority": "high",
"data": {
"title": "New Message",
"body": "You have a new message"
}
}Result:
Android OS wakes your FirebaseMessagingService via Binder IPC
onMessageReceived()fires (your code runs!)You have ~10 seconds max before OS kills it again
You must manually display notification
Service completes → app goes back to killed state
Critical: Data messages in killed state require "priority": "high". Without it, Android may delay delivery until user opens app. This breaks real-time features silently.
Critical Caveats (Production Gotchas)
1. Force-Stop = No Notifications
If user force-stops your app from Settings:
Notifications stop completely
No workaround exists
User must manually open app once
Why: This is intentional Android security. Force-stop means “I don’t want this app to run.” Android respects that.
What to do: Nothing. This is by design.
2. OEM Battery Optimization (Xiaomi, Huawei, Oppo, Vivo)
Chinese OEMs have aggressive battery optimization:
MIUI (Xiaomi), EMUI (Huawei), ColorOS (Oppo), Funtouch OS (Vivo)
May block background wake-ups even with high priority
Users complain: “I only get notifications when I open the app!”
Solution:
// Request user to disable battery optimization
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val powerManager = getSystemService(POWER_SERVICE) as PowerManager
if (!powerManager.isIgnoringBatteryOptimizations(packageName)) {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:$packageName")
}
startActivity(intent)
}
}Also: Guide users to whitelist your app in battery settings.
3. FCM Token Lifecycle
FCM tokens are not permanent:
Change when app is reinstalled
Change when user clears app data
Rotated periodically by Google for security
Production pattern:
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
// Send new token to your backend
sendTokenToBackend(token)
}
}Always handle token refresh on your backend.
4. 10-Second Service Limit
In killed state, your FirebaseMessagingService has ~10 seconds max to complete.
If your code takes longer:
Android kills it mid-execution
No callback, no error, just gone
Notification never displayed
Solution:
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// Quick work only (display notification)
showNotification(...)
// Heavy work? Offload to WorkManager
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>().build()
WorkManager.getInstance(this).enqueue(workRequest)
}Keep onMessageReceived() minimal. Offload heavy tasks to WorkManager.
Notification vs Data Payload: When to Use What
This is the decision that separates working demos from production apps:
AspectNotification PayloadData PayloadForegroundonMessageReceived() firesonMessageReceived() firesBackgroundSystem displays (no code runs)onMessageReceived() firesKilledSystem displays (no code runs)onMessageReceived() firesControlLimited (system UI)Full (custom UI)Best ForStandard alertsCustom UI, deep linking
Production Pattern:
Use Notification Payload for:
Chat messages
Social media notifications
News updates
Reminders and alerts
Standard use cases (80% of scenarios)
Use Data Payload for:
Custom notification UI (rich notifications)
Deep linking to specific screens
Silent background sync
VOIP calls (incoming call screens)
When you need full control (20% of scenarios)
Most production apps use a mix of both, deliberately not by accident.
Complete Implementation Example
Here’s production-ready code:
FirebaseMessagingService:
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d("FCM", "Message received from: ${remoteMessage.from}")
// Notification payload (only called in foreground)
remoteMessage.notification?.let { notification ->
showNotification(
title = notification.title ?: "Notification",
body = notification.body ?: "",
data = remoteMessage.data
)
}
// Data payload (called in all states)
if (remoteMessage.data.isNotEmpty()) {
// In background/killed, must manually show notification
if (!isAppInForeground()) {
showNotification(
title = remoteMessage.data["title"] ?: "Notification",
body = remoteMessage.data["body"] ?: "",
data = remoteMessage.data
)
}
// Offload heavy work to WorkManager
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setInputData(workDataOf("messageId" to remoteMessage.data["messageId"]))
.build()
WorkManager.getInstance(this).enqueue(workRequest)
}
}
override fun onNewToken(token: String) {
super.onNewToken(token)
// Send new token to backend
sendTokenToBackend(token)
}
private fun showNotification(title: String, body: String, data: Map<String, String>) {
val channelId = "default_channel"
// Deep link intent
val intent = Intent(this, MainActivity::class.java).apply {
putExtra("messageId", data["messageId"])
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
val notificationManager = NotificationManagerCompat.from(this)
notificationManager.notify(System.currentTimeMillis().toInt(), notification)
}
private fun isAppInForeground(): Boolean {
val appProcessInfo = RunningAppProcessInfo()
ActivityManager.getMyMemoryState(appProcessInfo)
return appProcessInfo.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND
}
private fun sendTokenToBackend(token: String) {
// Send to your backend server
// Update user's FCM token in database
}
}AndroidManifest.xml:
<manifest>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name=".MyFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="default_channel" />
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification" />
</application>
</manifest>Interview Questions (Test Your Understanding)
If you can answer these, you’re ahead of 90% of Android candidates:
Q1: Why doesn’t Android give each app its own persistent connection to FCM?
Answer: Battery life. One shared connection for all apps = efficient radio usage, less battery drain.
Q2: What happens when a user force-stops your app from Settings?
Answer: Notifications stop completely until user manually opens app. No workaround (intentional security feature).
Q3: Why do data messages in killed state require "priority": "high"?
Answer: Without high priority, Android may delay delivery until user opens app (breaks real-time features).
Q4: What’s the time limit for FirebaseMessagingService in killed state?
Answer: ~10 seconds max. After that, Android kills it mid-execution.
Q5: How does Google Play Services communicate with your app?
Answer: Binder IPC (Inter-Process Communication) across process boundaries.
Q6: When does onMessageReceived() fire vs when does system auto-display?
Answer:
Foreground: Always fires; you display
Background + notification payload: System displays, doesn’t fire
Background + data payload: Fires, you display
Killed + notification payload: System displays, doesn’t fire
Killed + data payload: Fires, you display
🚀 Want to Master Android & Land ₹30+ LPA?
I’m launching an exclusive Android Job-Ready Bootcamp.
What You Get:
✅ Kotlin, Jetpack Compose, MVVM, Clean Architecture, System Design, Machine coding
✅ Android Internals + Mock Interviews (FAANG engineers)
✅ Step-by-step roadmap and guide to crack Senior Android roles
✅ Direct Referrals to Top Companies
Limited Seats. Serious Developers Only.
DM on LinkedIn: 👉 linkedin.com/in/azimsidd
Explore Curriculum: 🌐 thecodingshef.in/course/master-android
Your pathway to ₹30+ LPA starts here.
Key Takeaways
Google Play Services maintains one shared connection for all apps (not your app)
Binder IPC allows Play Services to wake your app when notifications arrive
3 app states behave differently: foreground (you handle), background (system or you), killed (system or brief wake-up)
Notification payload = system displays, Data payload = you display
Force-stop = no notifications (intentional, no workaround)
OEM restrictions (Xiaomi, Huawei) can block wake-ups (guide users to disable)
FCM tokens change (handle refresh on backend)
10-second limit in killed state (keep work minimal, offload to WorkManager)
Save This Article
Bookmark this for:
Your next FCM debugging session
Android system design interview prep
Understanding production notification issues
Teaching your team how FCM actually works
Comments (0)
No comments yet. Be the first to share your thoughts!
Leave a Comment
Get the latest updates
Join our newsletter and never miss an article.