Every Android team has a ticket that reads something like: “Tapping the notification opens a second copy of the screen, and now Back takes me through the same page three times.” The bug is rarely in your UI code. It lives in the contract between three things most of us configured once, years ago, and never looked at again: the manifest launchMode, the flags on the Intent, and the PendingIntent your notification carries.
This post is a field guide to that contract. We’ll use a fictional delivery app called Parcelo, with a TrackingActivity that shows where a courier is right now, and we'll follow one notification, "Your courier is two stops away", through every configuration that can go wrong.
1. What a task and back stack actually are
A task is a collection of activities the user perceives as one journey. The back stack is the ordered list of activities inside that task. The activity at the top is the one on screen, and pressing Back pops it.
Two details matter more than they look:
- A task belongs to the system, not to your process. Your process can die while the task, with its stack of activity records, stays in Recents.
- Activities from different apps can share a task. If your screen is launched from a messaging app without special flags, it joins the messaging app’s task.
Keep that picture in mind. Every launch mode below answers a single question: when an intent arrives, do I push a new entry, reuse one, or rearrange the stack?
2. The four launch modes, step by step
standard: always push
This is the default. Each intent creates a new instance, even if an identical screen is already on top.
For most screens this is correct. A product page opened from a product page should stack. It becomes a bug the moment an external entry point (notification, widget, shortcut) can fire the same intent repeatedly.
singleTop: reuse only when already on top
If an instance of the activity sits at the top of the target task, the system delivers the intent to it through onNewIntent(). Otherwise it behaves like standard.
The common misreading is “singleTop means only one instance.” It doesn’t. It means no back-to-back duplicates. The third panel is the trap: once the user wanders to a details screen, the same notification creates a second tracker.
singleTask: one instance, trim everything above it
If the activity already exists in the matching task, the system brings that task forward, finishes every activity above it, and calls onNewIntent().
Two consequences deserve respect. First, the finished activities lose their state. If the user was typing in a chat screen above the tracker, that draft is gone. Second, the instance may or may not be at the root of a task depending on affinity (section 6), so don’t assume “single task” means “root of the app.”
On Android 12 and later there is one more behavior to know: pressing Back on a root launcher activity moves the task to the background rather than finishing the activity. If your singleTask screen happens to be the root, users can return to it with stale state far more often than they used to.
singleInstance: a task of one
The activity lives alone in its own task. Anything it starts is placed in a different task.
This is almost never right for an ordinary screen. It exists for things like a call-handling UI that must be separate from everything else. Expect a second card in Recents and a Back order that surprises people. If what you want is “one instance per task”, newer platform versions offer singleInstancePerTask, which is a different and more flexible tool.
3. onNewIntent(): when it fires and what it doesn't do
onNewIntent() runs when the system routes an intent to an existing instance rather than creating one. It runs before onResume(), and if the activity was stopped, onRestart() and onStart() come first.
The failure mode people hit most often: getIntent() does not change. It still returns the intent that originally created the activity. If you later rotate the device, the recreated activity reads the old intent and quietly shows the previous order.
The classic override fixes it with setIntent:
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent) // keep getIntent() in sync for recreation
handleOrderIntent(intent)
}
In an androidx.activity.ComponentActivity you can also register a listener, which composes better with Compose-era code and with libraries that need to see the same events:
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
class TrackingActivity : ComponentActivity() {
private val viewModel: TrackingViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Cold start: only act on a *fresh* creation, not on rotation.
if (savedInstanceState == null) {
viewModel.showOrder(intent.orderIdOrNull())
}
// Warm path: delivered by singleTop / singleTask / CLEAR_TOP reuse.
// The listener lives on this activity instance, so it goes away when the
// instance is destroyed (rotation creates a new instance and a new listener,
// not an accumulating pile). Unregister manually only if you register from
// something that outlives the activity.
addOnNewIntentListener { newIntent ->
setIntent(newIntent)
viewModel.showOrder(newIntent.orderIdOrNull())
}
setContent { TrackingScreen(viewModel) }
}
private fun Intent.orderIdOrNull(): String? = getStringExtra(EXTRA_ORDER_ID)
companion object {
const val EXTRA_ORDER_ID = "parcelo.order_id"
}
}
Note the savedInstanceState == null guard. Without it, a configuration change replays the launch intent and can reset a screen the user has already moved on from. For process-death safety, keep the "current order" in a SavedStateHandle inside the ViewModel rather than re-deriving it from the intent.
4. Intent flags vs. manifest launch modes
Both mechanisms steer the same system logic, but they differ in who decides:
- A manifest
launchModeis declared by the target and applies to every launch of that activity. - An intent flag is chosen by the caller for one specific launch.
When they disagree, the caller’s flags generally win for the behaviors they express (SINGLE_TOP, CLEAR_TOP, NEW_TASK), but this is not a blanket override. The system resolves the two together, and some manifest modes are still enforced: an activity declared singleInstance cannot be forced into an ordinary shared stack just because a caller omitted or added a flag. That interplay is why an activity declared singleTop can still be duplicated by a caller that adds the wrong flag, and why changing the manifest alone sometimes appears to do nothing.
The flags you will actually use:
-
FLAG_ACTIVITY_NEW_TASKstarts the activity in a task matching its affinity, creating the task if needed. It is required when launching from a non-activityContext. -
FLAG_ACTIVITY_SINGLE_TOPmirrorssingleTopfor this launch. -
FLAG_ACTIVITY_CLEAR_TOPpops everything above an existing instance. On astandardactivity, the existing instance is itself finished and recreated unless you also addSINGLE_TOP. This is the second most common source of "why did my screen reset?" -
FLAG_ACTIVITY_CLEAR_TASK(withNEW_TASK) wipes the whole task first. Powerful, and destructive.
The two mappings in the graphic marked “close, not identical” are exactly that. NEW_TASK | CLEAR_TOP approximates singleTask, but affinity and root-of-task rules differ, so treat flags as an adjustment tool and not as a re-implementation.
5. Case study: the notification should open the existing screen
Scenario. The user is already watching order 4812 in TrackingActivity. A push arrives: courier is two stops away. They tap it. We want the same screen, updated. We do not want a second copy.
The naive version
fun buildPendingIntent(context: Context, orderId: String): PendingIntent {
val intent = Intent(context, TrackingActivity::class.java)
.putExtra(TrackingActivity.EXTRA_ORDER_ID, orderId)
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}
Four independent bugs hide in these five lines.
-
Duplicate screen. With
standardmode, each tap pushes another tracker. -
Stale extras.
PendingIntentidentity ignores extras. Two notifications with request code0and the same targetIntentcollapse into onePendingIntent, and withoutFLAG_UPDATE_CURRENTthe first extras win. Users tap the second order's notification and see the first order. -
Mutability on newer Android. Targeting Android 12 or later, you must declare
FLAG_IMMUTABLEorFLAG_MUTABLEexplicitly. Omitting it throws. - No parent. If the app was not running, the tracker becomes the root of a fresh task. Back leaves the app instead of going Home.
The fix, warm path
Declare the screen so that arriving intents are reusable:
<activity
android:name=".tracking.TrackingActivity"
android:launchMode="singleTop"
android:parentActivityName=".MainActivity"
android:exported="false" />
Then build the PendingIntent with a distinct request code, matching flags, and the update flag:
fun trackingPendingIntent(context: Context, orderId: String): PendingIntent {
val intent = Intent(context, TrackingActivity::class.java).apply {
putExtra(TrackingActivity.EXTRA_ORDER_ID, orderId)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
}
return PendingIntent.getActivity(
context,
orderId.hashCode(), // one PendingIntent per order, not one per app
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
Why this works: NEW_TASK finds Parcelo's existing task, CLEAR_TOP removes any details or chat screens stacked above the tracker, and SINGLE_TOP makes the surviving instance receive onNewIntent() instead of being destroyed and rebuilt.
The fix, cold path
If the process is gone and no task exists, the tracker becomes the root. Give Back somewhere sensible to go:
// Registered in onCreate, before setContent. Dispatcher callbacks run
// last-registered-first, so a Compose BackHandler added later still wins.
onBackPressedDispatcher.addCallback(this) {
if (isTaskRoot) {
// Evaluated at press time, not once at registration.
startActivity(Intent(this@TrackingActivity, MainActivity::class.java))
finish()
} else {
// Not the root: step aside and let default back handling run.
isEnabled = false
onBackPressedDispatcher.onBackPressed()
}
}
Checking isTaskRoot inside the callback matters: passing it as the enabled argument would freeze its value at registration time. The else branch disables the callback and re-dispatches, which is safe here because default handling finishes the activity; if you keep the activity alive after Back, re-enable the callback afterwards. Confirm the dispatcher call against the androidx.activity version you ship.
Alternative: rebuild the task
TaskStackBuilder.addNextIntentWithParentStack(...) constructs a synthetic Home-then-Tracking stack, which fixes "Back exits the app." Be aware the resulting PendingIntent starts the first activity with task-clearing flags, so tapping it recreates the task and loses whatever the user was doing. That is correct for "open this order from nowhere" and wrong for "refocus the screen I'm already on." Choose deliberately.
One more platform restriction worth remembering: for apps targeting Android 12 (API 31) or higher, a notification tap can no longer route through a Service or BroadcastReceiver that then starts an activity (a "notification trampoline"); the start is blocked. Apps targeting older SDKs are not subject to it yet, but you should not build new code on that. The PendingIntent should target the activity directly.
6. Deep links and task affinity
Task affinity is a string on each activity naming the task it prefers to live in. By default it is your package name, so all your activities share one. A task also takes its affinity from its root activity, which is how the system decides which existing task a new launch belongs to, and that in turn shapes how tasks appear grouped in Recents. Affinity is actively consulted mainly when a launch uses NEW_TASK, when an activity is singleTask, or when allowTaskReparenting is set, so those are the cases where a surprising affinity bites hardest.
Deep links make affinity visible, because the caller is another app:
- If the caller doesn’t use
NEW_TASK, your screen is pushed into the caller's task. Back returns to the caller, and your app has no Home screen underneath. Some users find that natural; others think the app "has no way in." - If the caller uses
NEW_TASKand the affinity matches, the system targets your existing task. You then fall back into the launch-mode rules above.
The panels in the graphic are illustrative. Browsers, messengers and launchers choose their own flags, so test your deep links from at least one browser, one messenger and the launcher, not only from adb shell am start, which behaves differently.
Practical guidance:
- Don’t set a custom
taskAffinityunless you want a separate Recents entry. Accidentally giving one activity a different affinity is a well-known way to create a "ghost" second app card. - If a deep-linked screen is meant to be a standalone entry point (a share target, say), make sure there is a defined “up” destination. Back and Up don’t have to behave the same, but neither should dump the user in the wrong app.
- Treat
exported="true"activities as public API: whatever launch mode they have is now a promise to other apps.
7. Single-activity apps and Navigation
In a single-activity architecture the picture changes shape. The system task holds one entry, and the real back stack lives in NavController.
This is why launch mode matters more for your one activity, not less. Every external entry point funnels into it. With standard, a notification tap while the app is open can stack a second MainActivity, which creates a second NavController and a second copy of your whole UI.
A sound default for a single-activity app:
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="parcelo.example" android:pathPrefix="/track" />
</intent-filter>
</activity>
Then make sure new intents reach navigation. Recent Navigation Compose releases already forward them for ComponentActivity hosts, so in the common case you need no extra code, and adding the listener below on top of the built-in behavior risks handling the same intent twice. Use the explicit version only when you own the controller wiring or have confirmed that the version you ship does not forward new intents (check by logging a deep link that arrives while the app is open):
@Composable
fun ParceloApp(activity: ComponentActivity) {
val navController = rememberNavController()
DisposableEffect(navController) {
val listener = Consumer<Intent> { navController.handleDeepLink(it) }
activity.addOnNewIntentListener(listener)
onDispose { activity.removeOnNewIntentListener(listener) }
}
NavHost(navController, startDestination = "home") {
composable("home") { HomeScreen() }
composable(
route = "tracking/{orderId}",
deepLinks = listOf(navDeepLink { uriPattern = "https://parcelo.example/track/{orderId}" }),
) { entry -> TrackingScreen(entry.arguments?.getString("orderId")) }
}
}
Failure modes specific to this setup:
- Replay on recreation. The controller records that it has handled a given deep-link intent, but reusing one intent object in your own code (re-handing it after rotation, say) can still navigate twice. Handle new intents once, from the callback.
-
Nav’s synthetic back stack vs. the activity’s.
NavDeepLinkBuilderproduces aPendingIntentthat rebuilds the task with parent destinations, using the same task-clearing machinery asTaskStackBuilder. It is great for cold opens and destructive for "refocus the current screen." -
singleTaskas a reflex. It stops duplicates, but it also finishes any other activity aboveMainActivity, such as a camera or picker flow you launched as a separate activity. PrefersingleTopand handle navigation explicitly unless you have a concrete reason.
Checklist you can paste into a code review
- For every
exportedactivity and everyPendingIntent: what happens if this fires twice? From a cold process? While the user is on a different screen of the same app? - Is every
PendingIntentgiven an explicit mutability flag, a unique request code per payload, andFLAG_UPDATE_CURRENTwhen extras change? - Does every activity that can be reused implement
onNewIntent(or a listener), and does it callsetIntent? - Does
CLEAR_TOPappear withoutSINGLE_TOPanywhere? If so, is the recreation intentional? - Is there a custom
taskAffinity? Can you name the Recents entry it produces? - Have you tested entry points from outside your app, not just from within it?
The back stack stops feeling like a haunted house once you treat each launch as a three-way negotiation between the target’s manifest, the caller’s flags and the existing stack. Draw the stack before and after, and the surprise nearly always shows up on paper first.
📘 Master Your Next Technical Interview
Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.
- E-book: Available on Google Play
- Kindle Edition: Available on Amazon
- Topmate: Available on Topmate
- Also available in Paperback & Hardcover.
Originally published on Medium.










Top comments (0)