The Senior Signal Explained
BEHAVIORAL › Soft Skills
Senior Android interviews rarely ask harder syntax questions. They ask the same kinds of questions as a mid-level loop, then listen for something different. A mid-level answer implements a known solution. A senior answer explains why this solution given these constraints, names an alternative that was rejected and why, and describes what happens after the code ships.
Five threads run through almost every senior-signal question, in migrations, performance, module design, and production health alike. Ownership: you drove a change, you did not just execute a ticket someone else fully specified. Trade-off reasoning: you weighed options against the real constraints of the situation, not a rule you always follow. Influence: you moved a team's practice, not just your own code. Comfort with ambiguity: you made a call when the spec was incomplete. Production impact: you can point at a concrete metric that moved. Everything that follows in this lesson is these five threads applied to a real interview topic.
Take a classic senior-signal question: would you adopt MVI for this feature? A mid-level answer either declares a favorite pattern or refuses to commit to one. A senior answer reasons from the actual feature in front of them.
A strong answer sounds like this: MVI's unidirectional state helps most when a screen has several async sources fighting over one piece of state, search plus filters plus pagination, where bugs usually come from inconsistent intermediate states. A simple settings screen with one toggle does not have that problem, so the same machinery there is overhead with no payoff. The senior instinct is never 'MVI is just better,' it is 'here is the specific complexity in this screen that MVI's structure buys me something for.'
That is the shape of every good trade-off answer: name the option, name a real alternative you considered, and tie the choice to a concrete constraint of this situation.
Migration questions, XML to Compose, Java to Kotlin, RxJava to coroutines, are really asking one thing: can you de-risk a large change without freezing the team's ability to ship features? The answer is always incremental, never big-bang. Freezing feature work to do a full rewrite is itself a risk, and a big-bang rewrite is usually a bigger gamble than the problem it is meant to solve, because you lose months of delivery and bet everything on one branch merging cleanly.
For a 200-screen XML app moving to Compose, the practical path is interop, not a rewrite. Compose and the View system talk to each other in both directions: ComposeView lets you drop a new Compose screen into an existing XML layout, and AndroidView lets you host a legacy View inside a Compose tree when you need to go the other way. You migrate leaf screens first, the ones with the fewest dependents, keep a shared design system so the app doesn't look stitched together, and ship each migrated screen behind a flag so a bad screen can be turned off without a new release.
// Embed a new Compose screen inside a legacy XML layout
composeView.setContent {
MaterialTheme { LeafFeatureScreen() }
}
// Embed a legacy View inside Compose, the other direction
@Composable
fun LegacyMapWidget() {
AndroidView(factory = { ctx -> MapView(ctx) })
}
Java to Kotlin migrations get asked because they test the same de-risking instinct. Kotlin and Java interoperate inside the same module, a Kotlin class can call Java classes directly and Java can call Kotlin back, so there is no requirement to convert a whole module in one pass. The practical path is file by file: start with new code and with classes that already have good test coverage, and leave stable, working Java files alone unless there is a real reason to touch them.
The IDE's Java-to-Kotlin converter is a starting point, not a finished result. It produces syntactically valid Kotlin that is usually not idiomatic, platform types instead of proper nullability, Java-style getters left as functions instead of properties, so a human pass to clean it up still matters. And rewriting a stable, working Java class purely to adopt Kotlin sooner adds risk for a purely cosmetic win; that effort is better spent on code you are already changing for a real reason.
// UserDatabase.java stays untouched in the same module
// UserRepository.kt, a new Kotlin file, calls Java directly
class UserRepository(private val db: UserDatabase) {
fun getUser(id: String): User? = db.findById(id) // Java interop, no adapters needed
}
RxJava to coroutines migrations follow the identical shape. kotlinx-coroutines-rx3 ships adapters that bridge the two worlds so you never need both a full Rx implementation and a full coroutines implementation of the same stream at once: asFlow turns an Observable into a Flow, await turns a Single into a suspend call at a call site, and rxSingle wraps a suspend function back into a Single for callers that haven't converted yet. That lets you migrate one layer, say the repository layer, while the ViewModel above it and the RxJava code below it keep working unmodified.
The two approaches that look tempting but aren't: rewriting every stream in one giant pull request, which is exactly the big-bang risk migrations are supposed to avoid, and wrapping every Observable in runBlocking to fake a suspend call, which blocks a thread and defeats the entire point of switching to a suspending, cooperative model.
// Bridge Observable to Flow without rewriting the Rx layer
val flow: Flow<Event> = rxObservable.asFlow()
// Bridge Single to a suspend call site
val result: String = rxSingle.await()
// Expose a migrated suspend fun back to unconverted Rx callers
val backToRx: Single<String> = rxSingle(Dispatchers.IO) { fetchData() }
Production awareness means treating a release as a controlled, observable process rather than a single on-or-off switch, and it means keeping three tools straight because interviewers will conflate them on purpose to see if you do too.
Crash-free rate is the top-line stability KPI: the percentage of users, or sessions, that experienced zero crashes in a window. Teams gate releases on it, often requiring 99.5 percent or higher before widening a rollout. A staged rollout controls who has the binary: you ship a new version to a small slice of users first, watch crash-free rate and ANR counts, and only widen if those numbers hold. A feature flag controls behavior independent of the binary: the code can already be installed on every device, and a server-side toggle turns the behavior off instantly, no new build, no waiting on store review. The rollout answers 'who has this code,' the flag answers 'is this code allowed to run,' and those are genuinely different questions with different response times.
Say you're mid staged-rollout and crash-free users drops from 99.7 to 98.9 percent. The number is still technically 'high,' which is exactly the trap: a senior response looks at the direction of change and the fact that a rollout's entire purpose is to catch this early, not at whether the absolute number clears some arbitrary bar. The right move is to pause the rollout where it is, triage the top crash by volume and severity, and then either roll back to the last good version or ship a fix forward, only widening again once the rate recovers.
Pushing to 100 percent to 'collect more data faster' inverts the purpose of a staged rollout, it turns a contained problem into one that reaches every user. And a small dip from 99.7 is not something to wave off just because it is still above some threshold; the trend, not the snapshot, is the signal a senior engineer reacts to.
Now the mirror case: a feature already at 100 percent rollout starts crashing, and it happens to sit behind a server-controlled flag. This is where the flag versus rollout distinction pays off directly. Halting the rollout in Play Console does nothing here, the binary is already on every device, there is nothing left to halt. Shipping a new build and waiting for Play review is safe but slow, users keep crashing for hours while you wait. The fastest safe mitigation is to flip the server-side flag off: the behavior stops immediately for every user without shipping anything or touching the store, and then you fix the underlying bug at normal speed and re-enable once it's verified.
This is the practical payoff of building flags into risky features in the first place: they turn an emergency that needs a release into an emergency that needs one API call.
Senior engineers watch p95 and p99 latency, not the average, and the reasoning is worth being able to say out loud. A mean is dominated by the fast majority: if 95 out of 100 requests take 100ms and 5 take 5 seconds, the average looks fine while one in twenty users is having a miserable experience. Percentiles are built to surface exactly that tail: p95 tells you the latency that the slowest 5 percent of requests exceed, p99 the slowest 1 percent. Those tail requests are disproportionately the ones that generate support tickets, timeouts, and ANRs, because they're the ones a real user actually notices and remembers.
This is the same instinct as the crash-free story: don't judge a system by a single number that hides the part that's actually breaking for someone.
Performance questions test whether you optimize from evidence or from a hunch. The senior order is always measure, then fix: profile with system tracing, Android vitals' jank stats, or in Compose specifically, recomposition counts, before changing a single line aimed at making something faster. Guessing which remember call to add, sprinkling key everywhere, or moving initialization around because a screen 'feels slow' routinely optimizes the wrong thing, because the actual cause is often something you would never have guessed, an unstable lambda passed as a parameter, a list without a key, a layout doing unnecessary work every frame.
For a jank report on a Compose list specifically, the first move is to instrument it: log or trace how often the list is recomposing and why, before touching remember, keys, or a rewrite. Only once you can see the actual recomposition count and where the frame time is going do you know which fix, a stable key, hoisting state, skipping a badly-scoped remember, will actually move the number.
@Composable
fun ItemList(items: List<Item>) {
// Fires on every recomposition, watch this count before optimising anything
SideEffect { Log.d("Recompose", "ItemList recomposed") }
LazyColumn {
items(items, key = { it.id }) { // stable key, once you know it actually helps
ItemRow(it)
}
}
}
A cold-start regression is the same measure-first instinct applied to app launch. Macrobenchmark measures real cold-start time from a separate test process, giving you a number you can trust and track release over release, and it's the tool to reach for first when p95 start time creeps up. From there, check whether your Baseline Profile is current: a stale profile means the app is falling back to slower interpreted or JIT-compiled code paths for hot methods instead of the ahead-of-time compiled ones, and regenerating it after a few releases of change is often the single biggest win available. Only after measuring do you go looking inside Application.onCreate for the actual regression, an SDK someone added, a network call, a database open, that's running eagerly on the startup path.
The two moves that look like fixes but aren't: adding a longer splash animation, which hides the number without changing it, and moving more work eagerly into onCreate to 'get it out of the way early,' which almost always makes cold start worse, not better.
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val rule = MacrobenchmarkRule()
@Test
fun coldStart() = rule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
startupMode = StartupMode.COLD,
iterations = 5
) {
pressHome()
startActivityAndWait() // measures time-to-first-frame
}
}
A screen that leaks memory on every rotation is a classic senior diagnostic question because the tempting fixes are all mitigations, not diagnoses. Calling System.gc() in onDestroy doesn't reliably force anything, the garbage collector still won't collect an object something else is holding a live reference to, it just makes the symptom slightly less visible for a moment. Setting android:largeHeap buys headroom, not a fix, the leak still grows, it just takes longer to crash.
The reliable move is a heap dump, then tracing the retained object's reference chain back to its GC root, the thing that's actually keeping it alive. That chain almost always points at a static field or a long-lived listener holding an Activity or View past its lifecycle, a Context cached in a singleton, a callback registered but never unregistered. Tools like LeakCanary automate exactly this: they take the heap dump and surface the retained chain for you, so the diagnostic step is largely built rather than hand-run.
// BAD: static Context reference keeps a destroyed Activity alive, growing on every rotation
object Cache {
var ctx: Context? = null
}
// GOOD: hold applicationContext for anything long-lived
object Cache {
lateinit var appContext: Context
fun init(app: Application) { appContext = app.applicationContext }
}
An ANR, Application Not Responding, fires when the main thread is blocked from responding to input for too long, roughly five seconds for most input dispatch, though some triggers are shorter. The common instinct is to associate ANRs with crashes or exceptions, but an ANR is not a thrown error at all, it's the system deciding the app has stopped responding and offering to close it, which can happen from something as ordinary as a synchronous network call or a slow database query running on the main thread.
In production this is tracked, not guessed at: Android vitals reports a user-perceived ANR rate, and Google sets a bad-behavior threshold around 0.47 percent of sessions before it can affect how discoverable your app is in the Play Store. That number is the same kind of gate crash-free rate is: a concrete, monitored metric a senior engineer references instead of a vague sense that 'the app feels laggy sometimes.'
// BAD: network call on main thread; input blocked about 5s, causes ANR
fun loadBad() {
val data = api.fetchBlocking()
showData(data)
}
// GOOD: offload the blocking work with coroutines
fun loadGood() {
viewModelScope.launch {
val data = withContext(Dispatchers.IO) { api.fetchBlocking() }
showData(data) // resumes on the Main dispatcher
}
}
Module boundaries get asked because the wrong answer sounds reasonable: more modules must mean faster builds, so split everything. In practice, boundaries that don't map to real ownership or responsibility make things worse, more modules means more build configuration and dependency graph overhead, and if features depend on each other directly instead of on a shared core, you get the exact coupling multi-module was supposed to remove, now with extra ceremony on top.
The senior answer defines boundaries by responsibility: a core or domain module holds shared logic and models, feature modules depend on core, and feature modules never depend directly on each other. That one-directional dependency graph is what actually buys you the benefits, parallel compilation, smaller incremental rebuilds, and a real seam that stops one team's change from silently breaking another team's feature module. Structure isn't neutral: done well it speeds builds and reduces merge contention, done by rule of thumb, split everything, or share nothing, it just adds friction without the payoff.
Ownership shows up most clearly when a problem isn't technically anyone's ticket. A mid-level engineer waits for a fully-specified assignment. A senior engineer notices two features keep reaching into each other's internals, proposes a boundary, and drives it through review even though nobody filed a ticket for it. That's the shape behind most 'tell me about a time' prompts: someone noticed a systemic problem outside their assigned scope, an undocumented legacy flow, a flaky pipeline, a leaking abstraction, and took it to resolution instead of working around it and moving on.
Mentoring questions test the same influence thread from a different angle: can you scale beyond your own keyboard. The senior signal there isn't heroics, quietly taking over a junior's pull request to 'just get it right' teaches nothing and doesn't scale. It's leverage: raising the bar through code-review standards, writing a short design doc before a risky change so the team can weigh in ahead of time, pairing to unblock someone rather than solving it for them. The honest test for a mentoring story: did the team or the system get measurably better after you, a checklist, a doc, a review habit that outlived the one interaction, not just the single ticket you personally shipped.