Complete edition · 2026.1
The Staff Android Interview: depth, trade-offs, and scale
A book-length preparation guide for engineers interviewing at Staff, Senior Staff, Principal, and Mobile Architect level — organised around the thing that actually separates those loops from a Senior loop: not harder recall, but defensible judgment under scale, failure, and organisational constraint.
How to use this book
This volume is written for an engineer with 8–12+ years of experience who already ships production Android. It assumes you know what a ViewModel is. It does not assume you can defend, in front of a skeptical hiring committee, why a 200-screen app should or should not have one per screen.
Three habits make the material work:
- Answer at a level, deliberately. Major questions are graded L1–L5. Write your own answer before reading the model one; the gap is your study plan.
- Chase the second and third follow-up. Staff loops rarely fail on the opening question. They fail three questions deep, when the candidate runs out of mechanism and starts repeating vocabulary.
- Run the thirteen lenses. Correctness, reliability, scalability, performance, maintainability, testability, security, observability, operability, cost, developer experience, business impact, organisational impact.
The answer quality rubric
Every graded question uses the same ordinal, cumulative scale. An L4 answer contains everything in L3 and adds a dimension.
| Level | Character | What moves an answer up to this level |
|---|---|---|
| L1 Weak | Definitional recall | Repeats documentation. Correct vocabulary, no mechanism. Stops at "it's lifecycle-aware." |
| L2 Acceptable | Correct but bounded | Accurate happy-path mechanism plus a concrete usage. Still framed as "the right way", no alternatives. |
| L3 Senior | Practically grounded | Production experience: what went wrong, how it was debugged, what the fix cost. Names an alternative and why it lost. |
| L4 Staff | Systemic | Behaviour under concurrency, failure and 10× load; explicit decision criteria; testability and observability; cost to 100 engineers; the condition that inverts the choice. |
| L5 Principal | Strategic | Reversibility and option value, organisational and hiring consequences, migration and deprecation path, measurable success criteria, cost in money and headcount, how it is governed over years. |
Worked calibration — "Why would you expose Flow from a repository?"
Flow is cold and re-executes per collector; a suspend function is the better fit for a one-shot read.stateIn with WhileSubscribed(5_000) to survive rotation without leaking a collector.Flow<Result<T>> leaks transport concerns into the domain; what five collectors of one upstream do and how shareIn changes the failure model; Turbine testing cost; and the written rule so 100 engineers don't each decide differently.Complete contents
Front matter
Staff Engineer competency matrix
Loops are calibrated against a ladder, not a topic list. Read the columns horizontally: the same question, asked of three candidates, produces three materially different conversations. Most rejected Staff candidates give excellent Senior answers.
| Dimension | Senior L3 | Staff L4 | Principal L5 |
|---|---|---|---|
| Scope of impact | A feature or surface, delivered independently and correctly. | A system or domain spanning teams. Owns the seams. | The platform and multi-year direction, including what the org will not build. |
| Problem framing | Solves the problem as stated. | Interrogates it, surfaces the unstated constraint, sometimes redefines it. | Chooses which problems deserve the org's attention this year. |
| Technical depth | Knows the API and its correct usage. | Knows the mechanism, failure modes and cost model underneath. | Knows where the abstraction breaks at 10× and has the migration sketched. |
| Trade-offs | Names pros and cons. | Names decision criteria, weights them for this context, states what would change the answer. | Reasons about reversibility and option value: which doors are one-way. |
| Failure thinking | Handles the errors the API surfaces. | Designs for partial failure, retries, idempotency, degraded modes by default. | Designs blast radius: what fails, who notices, how fast, how the org recovers. |
| Scale reasoning | Optimises the hot path in front of them. | Reasons about 10× users, data, engineers and screens. | Reasons about cost, headcount and coordination, not just throughput. |
| Architecture | Applies the team's architecture consistently. | Defines it, its boundaries, and the mechanism that keeps it true. | Establishes governance that survives their departure without becoming a bottleneck. |
| Code & review | Clean, tested code; reviews for correctness. | Reviews for boundary integrity, failure behaviour, and precedent. | Shapes what the org considers reviewable: standards, linters, defaults. |
| Testing posture | Writes good unit and UI tests. | Designs a strategy with an explicit cost/confidence budget and flake policy. | Ties test investment to release velocity and incident rate as measured outcomes. |
| Observability | Adds logging, reads crash reports. | Defines mobile SLIs and instruments before shipping. | Runs the app-health programme: budgets, alerting, telemetry cost, accountability. |
| Influence | Persuades their team through good work. | Builds cross-team consensus with written proposals, prototypes and data. | Changes org behaviour via strategy, councils and hiring. |
| Ambiguity | Asks for clarification, then executes. | Operates without an owner, produces the missing definition, gets it ratified. | Comfortable where there is no correct answer, only a defensible one — and commits. |
| Mentorship | Mentors one or two engineers. | Raises a team's level through review culture, docs and delegated ownership. | Builds mechanisms that grow Staff engineers without their involvement. |
| Business framing | Understands the feature's goal. | Connects decisions to retention, conversion, cost and time-to-market. | Negotiates roadmap and quality with executives in money and risk. |
| Incident behaviour | Fixes their team's bug quickly. | Runs the response and drives a blameless postmortem to closure. | Removes the class of incident and changes the systems that allowed it. |
The most reliable Staff tell is unprompted constraint discovery: naming a constraint the interviewer did not mention, explaining why it dominates, and designing to it. The second is stating what would change your mind. Neither needs a whiteboard.
Front matter
Study roadmap
Three tracks by available runway. All assume 8–12 hours a week and existing working knowledge of Android — this is preparation for a level, not a bootcamp.
Track A — twelve weeks, full depth
| When | Focus | Coverage |
|---|---|---|
| Weeks 1–2 | Foundations and framing | Part I and the rubric. Self-assess against the matrix. Draft six STAR stories before touching technical material — most candidates over-prepare recall and under-prepare framing. |
| Weeks 3–4 | Language and concurrency | Parts II and III, plus the concurrency problems in Part XX. The densest block; concurrency is where Staff candidates are most often caught because the failures only appear under load. |
| Weeks 5–6 | Platform, Jetpack, Compose | Parts IV, V, VI. Internals rather than usage: aim to explain the mechanism behind every recommendation you make. |
| Weeks 7–8 | Architecture and scale | Parts VII, VIII, XVIII, XXII, XXIV. Write two ADRs from the Part XXII prompts and have a peer critique them. |
| Weeks 9–10 | System design intensive | Parts XVI, XVII, IX, X. Work eight designs on paper, timed at 45 minutes, before reading any model answer. |
| Week 11 | Operations | Parts XI, XII, XIII, XIV, XV, XIX. The half that distinguishes engineers who ship from engineers who operate. |
| Week 12 | Simulation | Parts XXVIII, XXVII, XXIII, XXV, XXX. Four full loops under time pressure with a peer as interviewer, then reference only. |
Track B — four weeks, targeted
Week 1: Parts I, III, VII. Week 2: Parts XVI and XVII, six designs. Week 3: Parts VI, XI, XIX, XXIV. Week 4: Parts XXVIII, XXIII, XXV, XXVII. Skip Parts II, V and XX unless self-assessment puts you below L3 there.
Track C — one week, pre-onsite
Day 1: rubric and Part I. Day 2: the Part XVI framework and three designs. Day 3: Parts III and VI, mechanism only. Day 4: Parts XXIV and XXII. Day 5: Part XXIII, eight stories rehearsed aloud. Day 6: two mock loops. Day 7: Part XXX and the 24-hour checklist. Nothing new after day 6.
Dependencies
The parts are not strictly sequential. This is what genuinely depends on what, so you can reorder around your weak spots without reading into a prerequisite you have not covered.
flowchart LR I["I · Mindset"] --> VII["VII · Architecture"] I --> XXIII["XXIII · Behavioral"] I --> XXIV["XXIV · Org scale"] II["II · Kotlin"] --> III["III · Coroutines"] III --> VI["VI · Compose"] III --> IX["IX · Networking"] IV["IV · Platform"] --> V["V · Jetpack"] V --> VI V --> VII VII --> VIII["VIII · Modules"] VII --> XVI["XVI · System design"] IX --> XVI X["X · Storage"] --> XVII["XVII · Sync"] XVI --> XVII VI --> XI["XI · Performance"] XI --> XII["XII · Memory"] XI --> XIX["XIX · Incidents"] VIII --> XIV["XIV · Build/CI"] XIII["XIII · Testing"] --> XIV XVI --> XXVIII["XXVIII · Mocks"] XXIII --> XXVIII XXIV --> XXVIII
Reading model answers is the least valuable activity in this book. Produce an answer first, out loud, timed, then read. The gap between what you recognise and what you can generate under pressure is the entire difficulty of a Staff loop.
Part I
The Staff Engineer Mindset
Before any technical content: what the level actually is, and why strong Senior answers get down-levelled. Everything after this part is read through it.
Chapter 1What a Staff Android Engineer actually does
Job descriptions describe Staff work as "technical leadership across teams," which is true and useless. The four archetypes below (following Will Larson's taxonomy, adapted for mobile organisations) are more actionable, because each one produces a different interview loop and a different set of stories you need ready.
| Archetype | What the job is | What the loop emphasises |
|---|---|---|
| Tech Lead | Owns delivery of a large mobile programme with 2–4 teams. Roadmap, sequencing, unblockers. | Project decomposition, estimation under uncertainty, stakeholder management, conflict between teams. |
| Architect | Owns the shape of the app: module graph, state model, platform APIs, the paved road. | Architecture review, modularization, migration strategy, governance without bottlenecks. |
| Solver | Dropped onto the highest-risk problem: a startup regression, a sync engine that loses data, an ANR crisis. | Debugging depth, performance investigation, incident narrative, comfort with incomplete information. |
| Right Hand | Extends a director's reach: strategy, org-wide standards, cross-org negotiation. | Written communication, prioritisation across competing orgs, executive framing, saying no. |
Two consequences for interview preparation. First, find out which archetype the role is before the loop — ask the recruiter what the last two quarters of this role looked like. An Architect loop will punish you for having only delivery stories; a Tech Lead loop will punish you for having only refactoring stories. Second, your story portfolio needs coverage of at least three archetypes, because most Staff roles drift between them.
Where the time actually goes
A realistic week for a Staff Android engineer at a mid-size company: 25–35% hands-on code (usually the risky or exemplary parts, not feature volume), 20% design review and code review, 20% written artefacts — proposals, ADRs, incident reports, 15% unblocking other engineers, 10% cross-team and product negotiation. If your answer to "what do you do day to day" is 80% coding, interviewers hear Senior.
Chapter 2Senior vs Staff vs Principal vs Architect
The single most common reason a strong candidate is down-levelled: they answered every question about the code, and the interviewer was asking about the system. The ladder is not "harder questions" — it is a change in the unit of work.
| Unit of work | Senior | Staff | Principal |
|---|---|---|---|
| Delivers | Features and components | Systems and standards | Strategy and capability |
| Time horizon | Sprint to quarter | Quarter to year | One to three years |
| Optimises | Their own correctness and throughput | The team's or domain's throughput | The organisation's optionality |
| Failure they prevent | A bug | A class of bug | An architecture that cannot change |
| Artefact | A pull request | A design doc others implement | A strategy others plan against |
The four down-levelling triggers
- Solution-first answering. Jumping to "I'd use MVI with a sealed state class" before establishing constraints. Staff candidates spend the first 3–5 minutes of a design question on requirements and non-functionals, and interviewers explicitly score this.
- No inversion condition. Recommending an approach without ever saying what would make you choose differently. This reads as dogma, and dogma at Staff level is dangerous because juniors copy it.
- "We" with no "I". Behavioural answers that never isolate the candidate's own decision, or conversely claim solo credit for team outcomes. Both are calibration failures.
- No cost awareness. Proposing a migration without engineer-months, a cache without memory budget, or an abstraction without the onboarding cost it imposes on 40 engineers.
Interview question · opener in almost every Staff loop
"What's the difference between a Senior and a Staff engineer, in your view?"
Why interviewers ask this
It is a self-calibration probe. Your answer tells them what you think you are being hired for, and they will spend the rest of the loop testing whether your examples match your own definition. Candidates who define Staff as "more technical depth" then get asked increasingly deep API questions — and get down-levelled when the depth is not accompanied by scope.
Short answer
Senior is measured by the quality of what they build; Staff is measured by the quality of what the organisation builds because of them — through architecture, standards, unblocking, and the problems they choose.
Common mistakes
- Framing it as seniority or tenure ("Staff is a Senior who's been here longer").
- Framing it as management ("Staff attends more meetings") — this signals you see the level as a tax rather than leverage.
- Framing it purely as depth, which is the Senior definition of Staff.
Follow-up questions
- "Give me an example where you operated at that level but the outcome was bad."
- "How do you know your architectural influence actually helped, rather than just being adopted because you're senior?"
- "What's something you used to believe strongly about Android architecture that you changed your mind on?"
- "When did you deliberately choose not to intervene in a bad technical decision?"
- "How much of your last year was hands-on code? Was that the right amount?"
Staff-level answer L4
"The unit changes. As a Senior I owned components and was judged on whether they were correct, tested and on time. As Staff I own seams — the places where two teams' decisions collide and neither owns the outcome. Concretely: last year our checkout and account teams both cached user entitlement, with different TTLs, which produced a class of bug where a user who upgraded saw the old tier for up to an hour. No single team owned it. I wrote the entitlement-ownership proposal, built the shared source of truth, and got both teams to migrate over a quarter. The measurable outcome was that tier-mismatch support tickets went to near zero and both teams deleted code. What makes that Staff rather than Senior isn't the caching design — a Senior could do that — it's that nobody asked me to, and the hard part was the two-team agreement, not the cache."
Principal perspective L5
Adds the systemic layer: "…and the reason that bug existed is that we had no owner for cross-cutting domain concepts, so I also proposed the domain-ownership registry so the next entitlement-shaped problem has an owner on day one. That's the difference between fixing an instance and removing a class."
What the interviewer records
Scope evidence (multi-team), agency (self-initiated), measurable outcome, and — critically — whether the candidate can distinguish their own contribution from the team's.
Chapter 3Scope, ownership, and the seams between teams
Staff work concentrates in three places, all of them uncomfortable:
- Unowned surfaces. App startup, the DI graph, navigation, the design system, crash triage rotation, the release process. Everyone depends on them; no team's OKRs include them. A Staff engineer either owns one or ensures someone does.
- Contested boundaries. Two teams that disagree about where a responsibility lives. The technical answer is usually easy; the hard part is that one team must absorb work they did not plan.
- Decisions with no deadline. The migration everyone agrees is needed and nobody starts. Staff engineers convert these into sequenced, funded work with a first increment small enough to survive a sprint.
When asked "tell me about a project you owned," pick one where you created the ownership. A project handed to you demonstrates competence; a project you defined demonstrates level.
Chapter 4Decision-making under ambiguity
The most portable Staff framework is the reversibility test. Classify the decision before you agonise over it:
| Class | Examples on Android | How to decide |
|---|---|---|
| One-way door | Public SDK API shape, on-device data format, the reactive boundary of the app, KMP adoption, a sync protocol other clients implement. | Slow down. Write it up, prototype the risky part, get explicit disagreement on the record, design an exit path before committing. |
| Expensive to reverse | DI framework, module topology, navigation library, database engine. | Decide with 70% information, but isolate the choice behind an internal API so reversal is mechanical rather than architectural. |
| Cheap to reverse | Screen-level state pattern, a mapper's location, test library, code style. | Decide fast, pick a default, and stop the team from litigating it. Consistency is worth more than optimality here. |
The associated interview skill is saying, unprompted: "This is a one-way door because the data format ships to devices we can't force-update, so I'd spend a week on it. The state-management choice inside the feature is cheap to reverse, so I'd just pick one and move." That single sentence does more for your level assessment than a perfect diagram.
Writing the decision down
Staff engineers are expected to produce ADRs. Interviewers sometimes ask you to sketch one verbally. The minimum viable shape:
Context What is true today, what forces are in tension, what we measured. Decision One sentence, in the active voice. "We will …" Alternatives Two or three real options, each with why it lost — not strawmen. Consequences What becomes easy, what becomes hard, what we now owe. Reversal What signal would make us revisit, and what the exit costs. Owner/date Who is accountable, when it is re-evaluated.
The two sections weak ADRs omit are Alternatives with honest reasons and Reversal. Both are exactly what a Staff interviewer is listening for.
Chapter 5The thirteen judgment lenses
Applied to any design answer, these convert a competent technical description into a Staff one. You will not use all thirteen in a single answer; naming the three that dominate this problem, and saying why the others are secondary, is the actual skill.
| Lens | The question it forces | Android-specific form |
|---|---|---|
| Correctness | Does it produce the right result? | Including after process death, rotation, and a mid-flight cancellation. |
| Reliability | What happens when a dependency fails? | Offline, flaky cellular, backend 503, expired token, killed background work. |
| Scalability | What breaks at 10×? | 10× items in the list, 10× screens, 10× engineers touching this module. |
| Performance | What is the latency and resource cost? | Frame budget, cold-start contribution, memory footprint, bytes over cellular. |
| Maintainability | Can a stranger change it safely? | Can a new hire in week two add a field without breaking three layers? |
| Testability | Can we verify it cheaply and deterministically? | Injectable dispatchers, fakes at the boundary, no real time in tests. |
| Security | What can an attacker do? | Rooted device, hostile app on the same device, MITM proxy, tampered APK. |
| Observability | How will we know it broke? | Which metric moves, within how long, and who is paged. |
| Operability | Can we intervene without a release? | Kill switch, remote config, staged rollout halt criteria. |
| Cost | What does it cost to run? | Telemetry volume, image CDN egress, push fan-out, build minutes. |
| Developer experience | Does it make engineers faster? | Build time, boilerplate per screen, time to first commit. |
| Business impact | Why does this matter commercially? | Conversion, retention, support load, App Store rating, time to market. |
| Organisational impact | Who else must change? | How many teams must adopt it, and who pays for their migration. |
Chapter 6Influence, mentorship, and driving architectural change
The recurring Staff interview theme is change without authority. Four mechanisms, roughly in order of effectiveness:
- A working prototype. The strongest argument in engineering is a branch that demonstrably builds 40% faster. It converts an opinion debate into a measurement debate.
- A written proposal with a named cost. "Three engineer-months, sequenced over two quarters, with the first increment shipping in three weeks" is arguable; "we should modularize" is not.
- A pilot team. Prove the approach with one willing team, publish their numbers, and let the second team adopt it because it works, not because you said so.
- Mechanisms, not memos. The change that survives you is the lint rule, the module template, the CI gate, the default in the project generator. Documentation decays; enforcement does not.
Interview question · leadership
"How do you get another team to adopt an architecture change that gives them no immediate product value?"
Why interviewers ask this
It tests whether you understand that engineering-quality work competes for the same funding as features, and whether you can make that case in a language product leadership accepts. Candidates who answer with "I'd explain why it's technically better" have not yet operated at this level.
Deep answer L4
"First I'd check whether it actually should happen — 'no immediate product value' sometimes means 'no value.' Assuming it holds, I'd do four things. Convert it to a business unit: our old networking layer cost roughly 4 support escalations a week and 1.5 engineer-days per feature in workarounds, which I can express in engineer-months a year. Shrink the first increment until it fits inside work they're already doing — new screens use the new client, nobody backfills. Absorb the cost myself for the pilot: I write the migration for their first two screens so their perceived cost is a review, not a project. And attach it to something they already want, which was a flaky-test problem the new client made testable. What I would not do is make it a mandate from an architecture council; mandates without absorbed cost produce compliance theatre — teams add the wrapper and keep the old path."
Follow-up questions
- "What if after all that they still refuse?" — Escalate the decision, not the disagreement: put the cost and the risk in writing, let the accountable director choose, and then support the choice publicly even if it goes against you.
- "How do you avoid becoming the person who migrates everyone's code?" — Absorb the cost for the pilot only; the second team gets a codemod and a template, the third gets documentation and a lint rule.
- "How do you know the migration is actually working?" — A leading indicator (percentage of screens on the new path) and a lagging one (the escalation count you used to justify it). If the lagging one does not move within two quarters, stop.
- "When would you abandon the migration?" — When the pilot's numbers do not reproduce, when the team that must own it afterwards will not, or when a platform shift makes the target obsolete.
Part I checklist
- I can name which Staff archetype the role I'm interviewing for actually is.
- I have three stories where I created the ownership rather than receiving it.
- I classify decisions as one-way / expensive / cheap out loud, unprompted.
- Every design answer I give names an inversion condition.
- I can express an engineering-quality investment in engineer-months and a business metric.
- I have one story where my influence attempt failed, and what I learned.
Part II
Kotlin Mastery
Kotlin at the level where you can explain what the compiler emits, and why that matters for APIs crossing module boundaries and surviving 40 engineers.
Chapter 7Type system, null safety, and the Java boundary
Kotlin's null safety is a compile-time guarantee that stops at the JVM boundary. Every production NPE in a Kotlin codebase comes from one of four places: platform types from Java, reflection-based deserialisation, lateinit, or a !! someone added under deadline pressure.
Platform types are the biggest hole
When Kotlin calls unannotated Java, the return type is a platform type (String!): the compiler declines to enforce either nullability. Assigning it to a non-null type inserts an implicit null check that throws at the assignment site — which is why the stack trace often points at code that looks obviously safe.
// Java library, no @Nullable annotations val name: String = javaUser.getName() // platform type val len = name.length // Deserialised by a reflective parser data class Config(val apiUrl: String) // Parser can construct this with apiUrl == null, // bypassing the constructor's null check entirely.
Both compile. The first throws at the assignment with a confusing trace; the second produces a String that is null at runtime, and the NPE surfaces somewhere unrelated hours later.
// Be explicit about the boundary
val name: String = javaUser.name
?: error("User.name was null for id=${javaUser.id}")
// Model wire types as nullable, validate once, map inward
@Serializable
data class ConfigDto(val apiUrl: String? = null)
fun ConfigDto.toDomain(): Config =
Config(apiUrl = requireNotNull(apiUrl) { "apiUrl missing" })
data class Config(val apiUrl: String) // always valid
Null becomes a validated boundary concern, and the failure message identifies the record. The domain type carries a real guarantee.
Interview question · fundamentals with a Staff tail
"Kotlin is null-safe, so why do we still see NPEs in production Kotlin code?"
Why interviewers ask this
It separates people who learned Kotlin's rules from people who have debugged Kotlin at scale. The second group immediately names platform types and deserialisation.
Deep explanation
Null safety is enforced by the compiler, not the runtime. Four escape hatches: platform types from unannotated Java; reflection, which can write null into a non-null field (Gson is the classic offender; kotlinx.serialization and Moshi's Kotlin adapter respect nullability); lateinit, which throws UninitializedPropertyAccessException; and !!. Additionally, uninitialised non-null fields can be observed as null during construction if a superclass constructor calls an overridden method.
Follow-up questions
- "How would you prevent the deserialisation class across a 300-module codebase?" — Ban reflective JSON in the paved road, use kotlinx.serialization or Moshi codegen, add a Detekt rule failing on Gson imports, and validate DTO→domain mapping at the boundary.
- "When is
!!acceptable?" — When the invariant is genuinely enforced elsewhere and the alternative is noise, e.g. immediately after acontainscheck on a local map. Even then preferrequireNotNullwith a message, because the message is what you'll want at 2am. - "
lateinitvs nullable var vsby lazy?" —lazywhen the value is derivable on first access;lateinitfor framework-injected values with a clear initialisation point; nullable when absence is a legitimate state rather than a bug. - "How does this change with KMP?" — No platform types on native targets, but Objective-C interop reintroduces them on iOS; the discipline transfers.
Chapter 8Generics, variance, and reified types
Variance answers one question: if Dog is a Animal, is Repo<Dog> a Repo<Animal>? By default no (invariant), because a producer-consumer type cannot safely be either.
out T— covariant, producer.Tmay appear in return position only.List<out E>: aList<Dog>is aList<Animal>because you can only read.in T— contravariant, consumer.Tin parameter position only. AComparator<Animal>can compare dogs.*— star projection, "some unknown type"; safe to read as the upper bound, unsafe to write.
interface Mapper<T, R> { fun map(input: T): R }
// Caller has a Mapper<UserDto, User> but a
// pipeline typed Mapper<Any, Any> — no relation.
fun runAll(ms: List<Mapper<Any, Any>>) { /* ... */ }
// Forced to do this at every call site:
runAll(mappers as List<Mapper<Any, Any>>) // unchecked
Unchecked casts spread through the codebase and defeat the type system for everyone downstream.
fun interface Mapper<in T, out R> {
fun map(input: T): R
}
// Now a Mapper<UserDto, User> IS a Mapper<UserDto, Any>
// and a Mapper<Any, User> IS a Mapper<UserDto, User>.
fun <T> runAll(input: T, ms: List<Mapper<T, *>>): List<Any?> =
ms.map { it.map(input) }
Variance declared at the declaration site fixes it for every consumer, forever. This is a public-API decision: adding variance later is source-compatible, removing it is not.
Reified: what it buys and what it costs
JVM generics are erased. reified works by forcing the function inline, so the concrete type is substituted at each call site. The cost is real: every call site duplicates the body.
// 400-line inline reified function, called from
// 90 places. R8 sees 90 copies of 400 lines.
inline fun <reified T> parseAndValidateAndCache(
json: String
): T {
/* 400 lines */
}
Method-count and APK-size explosion, worse instruction-cache behaviour, and stack traces that no longer point at one place.
inline fun <reified T> parse(json: String): T =
parse(json, T::class.java) // tiny, inlined
// The real work is a normal function, compiled once.
fun <T> parse(json: String, type: Class<T>): T {
/* 400 lines, one copy */
}
Keep the inlined surface to the type-capturing line. This is the pattern the Kotlin stdlib itself uses.
Chapter 9Inline, value classes, and what the compiler emits
inline exists primarily so higher-order functions do not allocate a lambda object per call, and so non-local returns work. It is not a general speed switch — inlining a function with no lambda parameters usually earns a compiler warning because the benefit is negligible.
| Modifier | Effect | Use when |
|---|---|---|
inline | Body and lambda bodies copied to the call site; enables non-local return and reified. | Small higher-order functions called in hot paths or needing reified types. |
noinline | That specific lambda stays a real object. | You need to store the lambda, pass it on, or make it nullable. |
crossinline | Lambda is inlined but non-local return is forbidden. | The lambda is invoked from another context, e.g. inside a Runnable. |
value class | Wrapper erased to the underlying type where possible. | Type-safe IDs and units without allocation. |
fun transfer(
from: String,
to: String,
amount: Long
)
// Compiles fine, ships a bug:
transfer(to = accountA, from = accountB, amount = cents)
transfer(userId, accountId, amount) // wrong arg order
Every ID is a String, so the compiler cannot help. These bugs are found in production, usually by finance.
@JvmInline value class AccountId(val raw: String)
@JvmInline value class Cents(val raw: Long) {
init { require(raw >= 0) { "negative amount" } }
}
fun transfer(from: AccountId, to: AccountId, amount: Cents)
transfer(userId, accountId, amount) // ✗ won't compile
Zero allocation in the common case, and the entire argument-order class of bug becomes a compile error.
The wrapper is only erased sometimes. Boxing occurs when the value class is used as a generic type argument (List<Cents>), as a nullable (Cents?), or where an interface type is expected. So List<Cents> allocates exactly like List<Long> boxed. Knowing when the optimisation disappears is the L4 signal; knowing it exists is L2.
Chapter 10Sealed hierarchies, data classes, and binary compatibility
Sealed types are how you make illegal states unrepresentable. They also create a binary-compatibility hazard that only appears in modular codebases — which is exactly why it is a Staff question.
// Illegal states are impossible: no "loading with an error and data".
sealed interface CheckoutState {
data object Idle : CheckoutState
data object Submitting : CheckoutState
data class Failed(val reason: FailureReason, val retryable: Boolean) : CheckoutState
data class Succeeded(val orderId: OrderId) : CheckoutState
}data class CheckoutState(
val isLoading: Boolean = false,
val error: String? = null,
val orderId: String? = null,
val isRetryable: Boolean = false,
)
// 16 representable combinations,
// 4 of them meaningful. Every consumer
// re-derives which is which.
Each new flag doubles the state space. Bugs appear as impossible UI: a spinner over an error message.
when (state) {
Idle -> ShowForm()
Submitting -> ShowSpinner()
is Failed -> ShowError(state.reason, state.retryable)
is Succeeded -> ShowReceipt(state.orderId)
} // compiler enforces completeness
Adding a state becomes a compile error at every consumer — which is the point, and also the hazard below.
Interview question · Staff-only, modular codebases
"You add a new subtype to a sealed interface in a core module. What breaks, and when?"
Short answer
Source compatibility breaks for every module with an exhaustive when — they fail to compile, which is good. Binary compatibility also breaks: modules compiled against the old hierarchy and not recompiled will throw NoWhenBranchMatchedException at runtime. In a monorepo where everything is rebuilt together this is a compile-time inconvenience; if you ship the module as a versioned artefact to teams that update on their own schedule, it is a runtime crash.
Follow-up questions
- "How do you evolve a sealed hierarchy that is part of a published SDK?" — Do not expose it as sealed. Expose an interface plus a known set of implementations and require consumers to handle an
Unknown/else branch, or version the type. - "When is an enum better than a sealed class?" — When there is no per-case data and you need
values(), ordinal serialisation, orEnumMap-style efficiency. Enums also serialise more predictably. - "What's the risk with
data class copy()in a validated type?" —copy()bypasses yourinitvalidation logic in older Kotlin versions when the constructor is private, and it always bypasses factory-level invariants. Prefer a non-data class with explicitwithXfunctions when invariants matter. - "
data objectvsobjectin a state hierarchy?" —data objectgives you a sanetoString()and correctequalssemantics for free, which matters when states appear in logs and test assertions.
Chapter 11Delegation, delegated properties, and scope functions
Class delegation (by) is composition with compiler-generated forwarding. It is the cleanest way to add behaviour without inheritance — and it has one trap that appears in interviews constantly.
interface Repo {
fun load(): List<Item>
fun loadFirst(): Item? = load().firstOrNull()
}
class Base : Repo {
override fun load() = fetchFromNetwork()
}
class Cached(private val b: Base) : Repo by b {
override fun load() = cache ?: b.load()
}
// Cached().loadFirst() does NOT use the cache.
The generated forwarder calls b.loadFirst(), and Base's default implementation calls its own load(). Overriding a member of the delegate does not affect calls made inside the delegate. Same class of surprise as `this` escaping in Java.
class Cached(private val b: Repo) : Repo {
private var cache: List<Item>? = null
override fun load(): List<Item> =
cache ?: b.load().also { cache = it }
override fun loadFirst(): Item? =
load().firstOrNull() // explicit
}
When the interface has default methods that call other members, delegate explicitly or keep the interface free of defaults.
A defensible scope-function house style
| Function | Receiver / return | Use for |
|---|---|---|
let | it / lambda result | Null-guarded transformation: value?.let { transform(it) }. |
run | this / lambda result | Computing a value from several members of one object. |
apply | this / receiver | Configuring a mutable object being built (mostly framework/Java types). |
also | it / receiver | Side effects in a chain — logging, caching — where the value passes through. |
with | this / lambda result | Grouping several calls on a non-null receiver you already have. |
Nesting scope functions more than one level deep, or using apply where the object is already immutable, are the two cases worth blocking in review. Beyond that, consistency matters more than which one is "correct" — this is a cheap-to-reverse decision (Chapter 4) and should not consume team debate.
Chapter 12Collections, sequences, and allocation
Every collection operator on an Iterable allocates a new list. A chain of five operators over 10,000 items allocates five lists of 10,000. Sequence makes the pipeline lazy and element-at-a-time, allocating once — but it adds per-element overhead, so on small collections it is slower.
// 50k rows, called on every DB emission
val visible = rows
.map { it.toUiModel() } // 50k allocs
.filter { it.isVisible } // new list
.sortedBy { it.rank } // new list
.take(20) // new list
Four intermediate lists and 50k mapper calls to display 20 rows. This shows up as GC churn in a trace and as jank when it runs on the main thread.
// Best: let SQLite do it (index + LIMIT)
@Query("SELECT * FROM rows WHERE visible = 1 " +
"ORDER BY rank LIMIT 20")
suspend fun visibleTop(): List<Row>
// If it must be in memory:
val visible = rows.asSequence()
.filter { it.isVisible } // cheap predicate first
.sortedBy { it.rank } // terminal-ish, unavoidable
.take(20)
.map { it.toUiModel() } // only 20 mapper calls
.toList()
Order matters more than laziness: mapping after filtering and taking removes 49,980 conversions. And the real Staff answer is that this belongs in the query.
sorted is a stateful operation — it must materialise the whole sequence, so laziness buys nothing across it. Candidates who claim "sequences are always faster for big data" get caught here. The honest rule: sequences win when the chain is long, the source is large, and the terminal operation is short-circuiting (first, take, any).
Chapter 13Java interoperability
Most large Android codebases are mixed for years. The Staff-level skill is designing Kotlin APIs that are pleasant from Java without contorting the Kotlin.
object AnalyticsClient {
fun track(
name: String,
props: Map<String, Any> = emptyMap(),
immediate: Boolean = false,
) { }
}
// From Java:
// AnalyticsClient.INSTANCE.track("x", map, false);
// default args unusable, INSTANCE noise
Java callers must pass every argument and navigate INSTANCE. Nullability is also unspecified for Java's tooling if the types come from generics.
object AnalyticsClient {
@JvmStatic
@JvmOverloads
fun track(
name: String,
props: Map<String, Any> = emptyMap(),
immediate: Boolean = false,
) { }
}
// From Java: AnalyticsClient.track("x");
@JvmStatic, @JvmOverloads, @JvmName, @Throws and explicit nullability annotations on the boundary make the API usable without changing the Kotlin.
Other interop facts worth having ready: Kotlin's checked-exception freedom means Java callers get no compiler warning unless you add @Throws; internal is public in the bytecode with a mangled name, so it is not a security boundary; and Kotlin's List is java.util.List at runtime, so a Java caller can mutate your "immutable" list.
Chapter 14DSLs, operators, and the overengineering line
Type-safe builders are Kotlin's most abused feature. A DSL is justified when the domain has genuine nesting or repetition and the DSL will be used in dozens of places by people who did not write it. It is not justified to make a three-field configuration read like English.
screen {
+header { "Title" }
body {
row { col(2) { text("a") }; col(4) { text("b") } }
}
} bindTo viewModel via mapper
// unary plus, infix chains, custom operators:
// nobody can grep for this, IDE help is poor,
// and errors surface as "expected Unit"
Cleverness has a maintenance cost paid by everyone else. Operator overloading that is not arithmetic-shaped is the strongest smell.
@DslMarker annotation class NavDsl
@NavDsl class GraphBuilder {
fun screen(route: String, block: ScreenBuilder.() -> Unit)
}
navGraph {
screen("home") { deepLink("app://home") }
screen("cart") { requiresAuth() }
}
@DslMarker prevents accidentally calling an outer builder's method from an inner scope — the single most confusing DSL bug. Names are ordinary functions, so IDE completion and grep work.
Part II rapid recall
- Platform types, reflection,
lateinitand!!are the four NPE sources; validate at the DTO boundary. out= produces = return position;in= consumes = parameter position. Declaration-site variance is an API decision.reifiedforces inlining — keep the inlined body one line thick.- Value classes box under generics, nullability, and interface positions.
- Adding a sealed subtype breaks binary compatibility for separately-compiled consumers.
- Overriding a delegate's member does not affect calls made inside the delegate.
- Sequences win on long chains over large sources with short-circuiting terminals;
sorteddefeats laziness. internalis not a security boundary; Kotlin'sListis mutable from Java.
Part III
Coroutines, Flow & Concurrency
The highest-yield technical part of the book, and where strong Senior candidates most reliably reveal a ceiling — because concurrency failures only appear under load, cancellation, and error, which is exactly what interviewers probe.
Chapter 15Structured concurrency from first principles
Structured concurrency means every coroutine has a parent, and no parent completes before its children. Three properties follow, and they are the entire reason the model exists:
- No leaks. Cancelling a scope cancels everything beneath it, transitively.
- Error propagation. A failing child cancels its siblings and notifies the parent — failures cannot be silently dropped.
- Completion is meaningful. When
coroutineScope { }returns, all work it started is genuinely finished.
flowchart TD VMS["viewModelScope (SupervisorJob + Main.immediate)"] --> A["launch: load profile"] VMS --> B["launch: load orders"] A --> A1["async: avatar"] A --> A2["async: badges"] B --> B1["async: page 1"] A1 -. "throws" .-> A A -. "cancels sibling" .-> A2 A -. "SupervisorJob stops here" .-> VMS VMS --> B
class SyncRepository {
fun sync() {
GlobalScope.launch {
val data = api.fetch()
db.save(data)
}
}
}
No owner, so no cancellation: the work outlives the screen, the user, and sometimes the login session. Exceptions go to the default handler and crash the process, or vanish. Untestable — a test has no handle to await. And it is invisible in review because it looks like the surrounding code.
class SyncRepository(
private val api: Api,
private val db: Db,
) {
suspend fun sync() { // suspend, not launch
val data = api.fetch()
db.save(data)
}
}
// Caller decides the lifetime:
viewModelScope.launch { repo.sync() } // screen-scoped
appScope.launch { repo.sync() } // app-scoped, deliberate
A repository should expose suspend functions and let the caller own the scope. If work genuinely must outlive the screen (a write that must complete), inject a deliberately-scoped CoroutineScope — an application-scoped singleton with a SupervisorJob — so it is explicit, injectable, and replaceable in tests.
Interview question · asked in nearly every Staff loop
"Why can GlobalScope create architectural problems?"
Short answer
It breaks the ownership chain. Work with no owner cannot be cancelled, cannot be awaited, cannot be tested deterministically, and its failures have no defined destination.
Deep explanation
Four concrete consequences. Leaks: a coroutine capturing a ViewModel or a Composable's lambda keeps it alive past its lifecycle. Unbounded concurrency: a screen that launches on every scroll event can produce hundreds of in-flight requests with nothing to cancel them. Undefined error handling: uncaught exceptions reach the thread's default handler, which on Android usually means a crash from a stack trace with no application frames. Test flakiness: runTest controls the test scope only, so GlobalScope work escapes virtual time and either races or is silently dropped.
Real-world example
A checkout screen fired analytics via GlobalScope.launch. On a slow network, users who abandoned checkout still had "purchase intent" events flushed minutes later, from a scope holding a reference to the abandoned cart. Two consequences: a memory retention path found by LeakCanary, and a data-quality bug that skewed the funnel by ~3%.
Follow-up questions
- "When is an application-scoped coroutine correct?" — Fire-and-forget writes that must survive navigation: marking a message read, flushing an analytics batch, completing a payment confirmation. Use an injected
@ApplicationScope CoroutineScope, notGlobalScope, so it is visible in the DI graph and swappable in tests. - "How would you prevent it across 300 modules?" — A Detekt/lint rule banning
GlobalScopeat error severity, plus an approvedAppScopeinjection in the paved road so there is a sanctioned alternative. A ban without an alternative produces creative workarounds. - "What about
WorkManager?" — For work that must survive process death, a coroutine of any scope is the wrong tool; the OS can kill you at any moment. Durable work belongs inWorkManagerwith a unique-work policy. - "How do you test the app-scoped case?" — Inject a
TestScopeas the app scope so tests can advance and assert on it.
Interviewer evaluation
L2 says "it leaks." L3 adds cancellation and testing. L4 adds the enforcement mechanism and the legitimate exception, and distinguishes app-scoped coroutines from durable work.
Chapter 16Context, dispatchers, and main-safety
CoroutineContext is an immutable, indexed set of elements — Job, CoroutineDispatcher, CoroutineName, CoroutineExceptionHandler — combined with +. A child inherits the parent's context, with its own Job substituted.
| Dispatcher | Backing | Use for / caution |
|---|---|---|
Main | Android main looper | UI state updates. Main.immediate avoids a re-post when already on main — this is what viewModelScope uses. |
Default | CPU-count threads | Sorting, parsing, image maths, diffing. Saturating it blocks all CPU work app-wide. |
IO | Elastic, 64+ threads, shared pool with Default | Blocking file/network/DB calls. limitedParallelism(n) to bound a subsystem. |
Unconfined | Caller's thread until first suspend | Almost never in production. Legitimate in some test and operator-implementation cases. |
class UserRepository(private val db: UserDao) {
// Blocking, but nothing says so.
fun loadBlocking(): User = db.queryBlocking()
}
// Every call site must remember:
viewModelScope.launch {
val u = withContext(Dispatchers.IO) {
repo.loadBlocking()
}
}
// One forgotten withContext = a StrictMode
// violation, an ANR under load, or both.
Main-safety as a call-site convention fails at scale — you cannot enforce it in review across 40 engineers.
class UserRepository(
private val db: UserDao,
private val io: CoroutineDispatcher, // injected
) {
/** Safe to call from any dispatcher. */
suspend fun load(): User = withContext(io) {
db.queryBlocking()
}
}
// Call site is trivial and cannot get it wrong:
viewModelScope.launch { state = repo.load() }
The function that knows it blocks is the function that switches. Injecting the dispatcher (rather than referencing Dispatchers.IO directly) is what makes it testable with UnconfinedTestDispatcher.
Room and Retrofit's suspend functions are already main-safe — Room dispatches to its own executor, Retrofit to OkHttp's. Wrapping them in withContext(Dispatchers.IO) is a no-op that costs a context switch and signals the candidate has not read the libraries. Saying this unprompted is a strong L4 marker.
Chapter 17Cancellation semantics
Cancellation is cooperative. Cancelling a job sets its state to cancelling and, at the next suspension point, the suspending function throws CancellationException. Code that never suspends is never cancelled.
viewModelScope.launch(Dispatchers.Default) {
for (frame in frames) { // 5k iterations
process(frame) // pure CPU, no suspend
}
}
// elsewhere:
try {
api.upload(file)
} catch (e: Exception) { // catches CancellationException!
log("upload failed", e)
state = Error
}
The loop runs to completion after the screen is gone. And catching Exception swallows CancellationException, which breaks the structured-concurrency contract: the parent believes the child completed normally, and you get "error" UI on a screen the user already left.
viewModelScope.launch(Dispatchers.Default) {
for (frame in frames) {
ensureActive() // cheap cancellation check
process(frame)
}
}
try {
api.upload(file)
} catch (e: CancellationException) {
throw e // never swallow
} catch (e: IOException) {
state = Error(e.toMessage())
}
ensureActive() (or yield() if you also want to give up the thread) makes CPU work cancellable. Rethrowing CancellationException — or catching specific exception types — keeps cancellation working.
Cleanup that must run after cancellation
suspend fun uploadWithCleanup(file: File) {
val session = api.beginUpload(file.name)
try {
api.uploadChunks(session, file) // cancellable
} finally {
// Suspending calls in a cancelled scope throw immediately
// unless we opt out. Keep this block tiny and fast.
withContext(NonCancellable) {
api.abortIfIncomplete(session)
}
}
}Interview question · scenario
"The user leaves a screen while a write is in flight. What should happen?"
Why interviewers ask this
Because the naive answer ("cancel it, that's what viewModelScope is for") is wrong for writes and right for reads, and the candidate's ability to distinguish tells you whether they have shipped a real app.
Staff answer L4
"It depends on whether the operation is owned by the screen or by the user's intent. A read — loading the profile — is screen-owned: cancel it, nothing is lost. A write — submitting an order, marking a message read — is intent-owned: the user asked for it and leaving the screen is not a retraction. Cancelling it mid-flight also creates the worst possible state, because the server may already have committed.
So I'd split them. Reads go in viewModelScope. Writes go to an application-scoped coroutine, and if the write must survive process death — a payment, a post — it goes into WorkManager with a unique-work policy and an idempotency key, because an app-scoped coroutine dies with the process too. The idempotency key matters because the retry after process death must not produce a second order.
The failure mode to name: if the write is app-scoped and the ViewModel exposes the result, you now have a coroutine holding a reference to a dead ViewModel. The result has to land in a repository or database that the next screen observes, not in the ViewModel that started it."
Follow-up questions
- "How does the user find out it succeeded?" — Persisted state observed by whatever screen is current, plus a notification if the operation is long. Never a toast fired from a dead scope.
- "How do you test the process-death path?" — Instrumented test with
WorkManager'sTestDriver, plus a unit test asserting the idempotency key is stable across restarts. - "What if the user logs out mid-write?" — The queue must be user-scoped and purged on logout, otherwise you write to the wrong account. This is a real production bug class.
Chapter 18Exception propagation and supervision
The single most-asked coroutine question in Staff loops is a variant of: three concurrent requests, one fails — what happens to the other two? The answer depends entirely on the builder and the scope.
| Construct | On child failure | Where the exception surfaces |
|---|---|---|
coroutineScope { } | Cancels all siblings, then rethrows. | At the coroutineScope call, to the caller. |
supervisorScope { } | Siblings continue. | At each child's own await(), or the handler for launch. |
launch | Propagates up immediately. | Nearest CoroutineExceptionHandler, else the thread default handler (crash). |
async | Deferred until awaited… | …but in a normal scope it still cancels the parent immediately. Only under a supervisor is it truly deferred. |
suspend fun loadDashboard() = coroutineScope {
val profile = async { api.profile() }
val orders = async { api.orders() }
val promos = async { api.promos() } // optional data
Dashboard(
profile = profile.await(),
orders = orders.await(),
promos = try { promos.await() } // does NOT help
catch (e: Exception) { emptyList() }
)
}
When promos fails, async in a regular coroutineScope cancels the parent at failure time, not at await time. The whole dashboard fails even though the promo data was optional, and the catch never runs meaningfully.
suspend fun loadDashboard() = coroutineScope {
// Required data: fail together, deliberately.
val profile = async { api.profile() }
val orders = async { api.orders() }
// Optional data: isolated failure.
val promos = async {
runCatching { api.promos() }.getOrDefault(emptyList())
}
Dashboard(profile.await(), orders.await(), promos.await())
}
Handle the failure inside the child so it never propagates, or wrap the optional children in supervisorScope. Deciding which data is required and which is optional is a product decision the candidate should surface explicitly.
launch(SupervisorJob()) does not supervise anything useful — passing a Job to a builder replaces the child's job but the new job's parent is the scope's job, so failures still propagate, and worse, you have detached cancellation. Supervision comes from the scope: CoroutineScope(SupervisorJob() + dispatcher) or supervisorScope { }. Candidates who write launch(SupervisorJob()) in a coding round are demonstrating a memorised token rather than a model.
Chapter 19Shared mutable state: Mutex, atomics, confinement
Three strategies, in the order you should reach for them:
- Confinement — do not share. A single coroutine owns the state; others send it messages. Zero locking, but adds indirection.
- Atomics — for a single independent value.
AtomicInteger,MutableStateFlow.update { }(which uses a CAS loop). - Mutex — coroutine-aware lock; suspends instead of blocking a thread. Use when several values must change together.
private val _state = MutableStateFlow(UiState())
fun addItem(item: Item) {
// Two concurrent calls: both read the same
// old value, second write wins, first item lost.
_state.value = _state.value.copy(
items = _state.value.items + item
)
}
// And never this in a coroutine:
private val lock = ReentrantLock()
suspend fun save() = lock.withLock { api.save() } // blocks a thread
The first is a classic lost update — invisible in testing, reproducible only under real concurrency. The second blocks an IO thread and can deadlock if the lambda suspends.
fun addItem(item: Item) {
_state.update { it.copy(items = it.items + item) } // CAS loop
}
// When several pieces must change atomically:
private val mutex = Mutex()
private var session: Session? = null
suspend fun refresh(): Session = mutex.withLock {
session?.takeIf { it.valid } ?: api.refresh().also { session = it }
}
update { } retries on conflict, so no update is lost. Mutex.withLock suspends rather than blocking, and is safe around suspending calls. Note it is not reentrant — a nested withLock on the same mutex deadlocks.
Chapter 20Flow fundamentals: cold streams
A Flow is a suspending producer with no state of its own. The builder block does not run until collect; each collector re-runs it independently; and the whole thing is cancelled by cancelling the collecting coroutine.
Two invariants define correct Flow code:
- Context preservation. A flow must emit in the collector's context. Emitting from a different coroutine throws
IllegalStateException; change context withflowOn, never withwithContextaround anemit. - Exception transparency. A flow may not catch exceptions thrown downstream.
try/catcharoundemitviolates this; use thecatchoperator, which only sees upstream failures.
fun items(): Flow<List<Item>> = flow {
try {
withContext(Dispatchers.IO) {
emit(dao.loadBlocking()) // ✗ wrong context
}
} catch (e: Exception) { // ✗ catches downstream too
emit(emptyList())
}
}
The emit throws IllegalStateException: Flow invariant is violated, and the catch would swallow a crash originating in the collector's own code — a bug that presents as "the UI silently shows an empty list."
fun items(): Flow<List<Item>> = flow {
emit(dao.loadBlocking())
}
.flowOn(Dispatchers.IO) // upstream context only
.catch { e -> // upstream failures only
Log.w(TAG, "items failed", e)
emit(emptyList())
}
flowOn affects everything above it; catch sees only what is above it. Both compose predictably, which is why they exist as operators rather than language constructs.
Chapter 21Hot streams: StateFlow, SharedFlow, Channel
| Type | Semantics | Correct use / failure mode |
|---|---|---|
StateFlow | Always has a value; conflated; distinct-until-changed by equals. | UI state. Failure mode: it drops intermediate values, so it must never carry events. |
SharedFlow | Configurable replay and buffer; no initial value. | Events with multiple observers. Failure mode: replay > 0 re-delivers old events to a new subscriber (navigation fires twice after rotation). |
Channel | Point-to-point; each element consumed once. | One-shot events with exactly one consumer. Failure mode: an event sent while nothing collects is buffered or suspends; consumed elements are gone on rotation. |
data class UiState(
val items: List<Item> = emptyList(),
val navigateTo: Route? = null, // event in state
val errorMessage: String? = null, // event in state
)
// UI must remember to clear it:
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let {
nav.navigate(it)
vm.consumedNavigation() // easy to forget
}
}
Rotation re-emits the state and navigates again. Two errors in a row with the same message emit once because StateFlow is distinct-until-changed. Every consumer must implement a consume protocol.
// State: what the screen looks like.
val state: StateFlow<UiState> = ...
// Events: things that happen once.
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
// UI:
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(STARTED) {
vm.events.collect { e ->
when (e) {
is Navigate -> nav.navigate(e.route)
is ShowSnack -> snackbar.showSnackbar(e.text)
}
}
}
}
A Channel with receiveAsFlow gives exactly-once delivery to a single collector and buffers while the UI is stopped. Repeated identical errors both arrive. Nothing replays on rotation.
Channel-based events are not free: if the UI is destroyed before collecting, buffered events are lost, and with multiple collectors the delivery is arbitrary. SharedFlow(replay = 0, extraBufferCapacity = 1) is the multi-observer alternative but drops events with no active subscriber. There is no perfect answer — the L4 move is naming which loss you are accepting and why.
Chapter 22Backpressure, sharing, and stateIn
Coroutine flows apply backpressure by default: a slow collector suspends the producer. Operators change that trade-off explicitly.
| Operator | Behaviour | Reach for it when |
|---|---|---|
buffer(n) | Producer runs ahead into a buffer. | Producer and collector are both slow but independent. |
conflate() | Keeps only the latest, drops intermediates. | Only the current value matters: location, progress, sensor. |
debounce(t) | Emits after quiet period. | Search-as-you-type. Adds latency by design. |
sample(t) | Emits latest every interval. | Steady-rate UI updates from a firehose. |
flatMapLatest | Cancels the previous inner flow. | Query → results. The correct default for user-driven input. |
flatMapMerge | Runs inner flows concurrently. | Independent fan-out; bound it with concurrency = n. |
flatMapConcat | Sequential, ordered. | Order matters and must be preserved. |
private val query = MutableStateFlow("")
val results: StateFlow<SearchState> = query
.debounce(300) // don't search every keystroke
.map { it.trim() }
.distinctUntilChanged() // "ab " and "ab" are one query
.filter { it.length >= 2 }
.flatMapLatest { q -> // cancel the previous request
repo.search(q) // Flow<List<Result>>
.map<List<Result>, SearchState> { SearchState.Results(it) }
.onStart { emit(SearchState.Loading) }
.catch { emit(SearchState.Failed(it.userMessage())) }
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = SearchState.Idle,
)Interview question · the WhileSubscribed(5000) question
"Why WhileSubscribed(5000) and not Eagerly or Lazily?"
Short answer
The 5-second grace period keeps the upstream alive across a configuration change, so rotation does not re-run the query, while still stopping it when the user actually leaves.
Deep explanation
Eagerly starts the upstream at construction and never stops it — a location or socket flow then runs while the app is backgrounded, burning battery. Lazily starts on first collection and never stops — same problem, delayed. WhileSubscribed(0) stops immediately on unsubscribe, which means rotation tears down and re-establishes the upstream: a wasted network call and a visible flicker. The 5-second value is chosen to exceed a configuration change but be far below any meaningful idle period.
Follow-up questions
- "What does
replayExpirationMillisdo?" — Controls how long the cached value is retained after the upstream stops. Setting it to zero forces a fresh load after the grace period expires, which you want for data that goes stale (a balance) and do not want for data that does not (a profile name). - "Five screens collect the same repository flow. How many network calls?" — With
stateIn/shareInat the repository, one; the shared flow multicasts. Without sharing, five, becauseFlowis cold. This is the single most common source of duplicate requests in a Compose app. - "What happens to an exception in a shared flow?" — It terminates the shared upstream for all collectors, and with
WhileSubscribedit will restart on the next subscription. Errors must be caught and modelled as values inside the flow, not allowed to escape — otherwise one screen's failure silently kills another screen's data. - "Where should
stateInlive — repository or ViewModel?" — ViewModel by default (screen-scoped state). Repository only when the data is genuinely app-wide and shared, and then it needs an app-scopedCoroutineScopeand a deliberateSharingStartedpolicy.
Chapter 23Concurrency design problems
Request deduplication — five screens, one call
class SingleFlight<K : Any, V>(private val scope: CoroutineScope) {
private val mutex = Mutex()
private val inFlight = mutableMapOf<K, Deferred<V>>()
suspend fun run(key: K, block: suspend () -> V): V {
val deferred = mutex.withLock {
inFlight[key] ?: scope.async(start = CoroutineStart.LAZY) {
try { block() } finally { mutex.withLock { inFlight.remove(key) } }
}.also { inFlight[key] = it }
}
// await() from the *caller's* coroutine: if this caller is
// cancelled, the shared work continues for the others.
return deferred.await()
}
}Why the shared scope matters: if the work were launched in the first caller's scope, that caller navigating away would cancel the request for the other four. Hosting it in a shared scope decouples the work's lifetime from any one consumer — the trade-off being that you must remove entries in a finally or the map grows forever.
Token refresh — one refresh, not five
class AuthAuthenticator(
private val tokens: TokenStore,
private val refreshApi: RefreshApi,
) : Authenticator {
private val mutex = Mutex()
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null // stop retry loops
val stale = response.request.header("Authorization")
?.removePrefix("Bearer ")
val fresh = runBlocking { // Authenticator is a blocking API
mutex.withLock {
val current = tokens.access()
// Another thread already refreshed while we waited.
if (current != null && current != stale) return@withLock current
val new = runCatching { refreshApi.refresh(tokens.refresh()) }
.getOrElse { tokens.clear(); return@withLock null }
tokens.save(new); new.access
}
} ?: return null // force logout upstream
return response.request.newBuilder()
.header("Authorization", "Bearer $fresh")
.build()
}
}The critical line is the comparison against the stale token: five requests fail concurrently, all enter the mutex in turn, and only the first performs a refresh — the rest observe that the stored token has changed and reuse it. Without that check you get five refreshes, and with rotating refresh tokens, four of them fail and log the user out.
Part III rapid recall
- Repositories expose
suspend/Flow; callers own scopes.GlobalScopenever; injectedAppScopedeliberately;WorkManagerfor durability. - Main-safety belongs to the function that blocks, with an injected dispatcher. Room and Retrofit
suspendfunctions are already main-safe. - Cancellation is cooperative:
ensureActive()in CPU loops, never catchCancellationException, cleanup infinallywithNonCancellable. asyncin a plaincoroutineScopefails the parent at throw time, not at await time.- Supervision comes from the scope, not from passing a
SupervisorJobto a builder. StateFlowfor state,Channel/SharedFlowfor events — never events inside state.WhileSubscribed(5000)survives rotation; unshared cold flows produce N calls for N collectors.- Deduplicate with a shared-scope
Deferredmap; coordinate refresh by comparing against the stale token.
Part IV
The Android Platform
Internals rather than usage. Every section answers three questions: how does this work below the API, what breaks in production, and what does it imply for design.
Chapter 24Process model, zygote, and Binder
Your app is a process forked from zygote, a warm VM with the framework classes and shared resources already loaded and pages shared copy-on-write. That is why an Android process starts in tens of milliseconds rather than seconds, and why anything you add to Application.onCreate is paid on every cold start with no sharing.
Binder is the IPC mechanism behind essentially every framework call that touches another process — PackageManager, ActivityManager, ContentResolver, starting activities, showing notifications. Three consequences worth naming in an interview:
- Transactions are size-limited. The per-process Binder buffer is about 1 MB, shared across all in-flight transactions. Exceeding it throws
TransactionTooLargeException— most often from a largeBundleinonSaveInstanceStateor an oversizedIntentextra. - Binder calls are synchronous and finite. The thread pool has ~16 threads; a system_server under load makes your "cheap" call slow. Binder contention is a common and under-diagnosed ANR cause.
- Binder can block the main thread.
ContentResolver.queryon main is a blocking IPC, not a local call.
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelableArrayList(
"feed", ArrayList(viewModel.allLoadedItems) // 2,000 items
)
}
// TransactionTooLargeException, usually only on
// devices where users scroll a lot — so it reaches production.
Saved state travels to system_server over Binder. It is for identifiers and scroll positions, not for data.
// In the ViewModel:
val listState = savedStateHandle.getStateFlow("firstVisible", 0)
val query = savedStateHandle.getStateFlow("query", "")
// Data comes back from the local database / Paging,
// which survives process death anyway.
val items = query.flatMapLatest { repo.page(it) }
Saved state carries the minimum needed to reconstruct — a query, an ID, a scroll index. The data itself is re-derived from disk, which is faster and unbounded.
Chapter 25The main thread: Looper, MessageQueue, Choreographer
The main thread is a loop: Looper.loop() pulls Message objects from a MessageQueue ordered by target timestamp and dispatches them to Handlers. Everything — lifecycle callbacks, input events, view invalidation, your post {} — is a message.
Choreographer registers for the display's VSYNC signal and, once per frame, runs callbacks in a fixed order: input → animation → insets → traversal (measure, layout, draw) → commit. At 120 Hz you have 8.3 ms; at 60 Hz, 16.7 ms. Anything that overruns delays the next frame, which the user sees as jank.
sequenceDiagram participant D as Display VSYNC participant C as Choreographer participant M as MessageQueue participant A as App code D->>C: VSYNC (every 8.3ms @120Hz) C->>M: post frame callback (async, front of queue) M->>A: INPUT callbacks M->>A: ANIMATION callbacks M->>A: TRAVERSAL — measure / layout / draw A-->>M: your 40ms JSON parse runs here Note over D,A: next VSYNC missed → dropped frame
"Why does a 40 ms operation on the main thread drop three frames at 60 Hz rather than one?" Because the frame it lands in is missed, and the two subsequent VSYNC pulses arrive while the thread is still busy — the messages queue up and are processed late. This is also why the frame-timeline metric to watch is consecutive dropped frames, not the total: three isolated drops are invisible, three consecutive ones are a visible stutter.
Chapter 26Activities, tasks, launch modes, and the back stack
| Launch mode | Behaviour | Where it bites |
|---|---|---|
standard | New instance every time, in the caller's task. | Duplicate screens stacked from repeated notification taps. |
singleTop | Reuses the instance if it is already at the top; delivers onNewIntent. | Forgetting to handle onNewIntent means the new intent's data is silently ignored. |
singleTask | One instance per task; clears everything above it on re-launch. | Wipes the user's back stack. Common cause of "the app lost my place" after a deep link. |
singleInstance | Alone in its own task. | Breaks back navigation between your own screens; almost never correct. |
The Staff framing: launch modes are a legacy multi-activity concern. The modern paved road is a single activity plus a navigation graph, where the back stack is application state you control rather than an OS structure you negotiate with. When an interviewer asks about launch modes, the strongest answer covers the mechanism and says "in a single-activity app, this reduces to how onNewIntent feeds the nav controller, which is a much smaller surface to get right."
<activity android:name=".MainActivity"
android:launchMode="singleTask">
<intent-filter> ... </intent-filter>
</activity>
// User is 4 screens deep in checkout, taps a
// marketing push → task cleared, checkout lost.
Silent data loss, and it correlates with revenue. It reproduces only when the app is already open, so it survives QA.
<activity android:name=".MainActivity"
android:launchMode="singleTop" />
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val route = deepLinks.resolve(intent) ?: return
when (route.policy) {
// Interruptible content: push on top of the stack.
Policy.PUSH -> navController.navigate(route.dest)
// Session-critical flow in progress: defer or confirm.
Policy.DEFER -> if (navController.inCheckout()) pending = route
else navController.navigate(route.dest)
}
}
The routing policy is a product decision made explicit in code, not an emergent property of a manifest attribute.
Chapter 27Lifecycle, configuration change, and process death
There are three tiers of state, and confusing them causes most "it works on my phone" bugs.
flowchart TD
E["Event"] --> R{"What happened?"}
R -- "rotation / locale / dark mode" --> CC["Activity destroyed, process alive"]
R -- "system reclaims memory" --> PD["Process killed, task record kept"]
R -- "user swipes away / force-stop" --> FS["Task cleared"]
CC --> K1["ViewModel: kept
SavedStateHandle: kept
disk: kept"]
PD --> K2["ViewModel: GONE
SavedStateHandle: restored from Bundle
singletons: reconstructed EMPTY
disk: kept"]
FS --> K3["everything in memory gone
saved state discarded
disk: kept"]
The middle branch is the one that reaches production: singletons come back empty while the UI assumes they are populated. It is also the branch nobody tests, because it needs the OS to kill a backgrounded process — constant on a low-end device, almost never on a developer's phone.
| Mechanism | Survives | Does not survive |
|---|---|---|
ViewModel (in memory) | Configuration change, back-stack navigation within its scope. | Process death, the user leaving its scope. |
SavedStateHandle / rememberSaveable | Configuration change and process death. | Force-stop by the user, "clear data", swipe-away on most OEMs. |
| Disk — Room, DataStore | Everything short of uninstall. | Uninstall, clear data. |
Interview question · the classic that separates L2 from L4
"Walk me through what survives process death, and how you'd test it."
Why interviewers ask this
Because virtually no team tests it, and virtually every app has bugs there. It reaches production because the reproduction requires the OS to kill a backgrounded process — which happens constantly on low-end devices and almost never on a developer's Pixel.
Deep explanation
When the system needs memory it kills your process but keeps the task record. When the user returns, Android recreates the activity and delivers the saved Bundle. Your ViewModel is gone; your SavedStateHandle is repopulated from that bundle; your singletons are freshly constructed with default state; any in-flight coroutine is gone without notice; static caches are empty.
The bug class this produces: state held in a singleton that was initialised during login (a session object, a feature-flag cache, an in-memory user), which after process death is empty while the UI assumes it is populated — presenting as a logout, a crash, or an empty screen on resume from background.
How to test it
# 1. Put the app in the background (Home, not Back).
# 2. Kill the process without clearing the task record:
adb shell am kill com.example.app
# 3. Return via Recents. Note: `am force-stop` is WRONG —
# it clears the task and does not exercise restoration.
# In CI, exercise the saved-state path directly:
@Test fun surviving_process_death() {
val handle = SavedStateHandle(mapOf("query" to "shoes"))
val vm = SearchViewModel(handle, repo)
assertEquals("shoes", vm.state.value.query)
}
# Instrumented: StateRestorationTester (Compose) or
# recreate() plus a simulated bundle round-trip.Follow-up questions
- "Why is
am force-stopthe wrong command?" — It clears the task, so you get a fresh launch rather than a restoration, which is precisely the path you are trying to test. - "Developer options has 'Don't keep activities' — is that equivalent?" — No. It destroys the activity but keeps the process, so ViewModels and singletons survive. It tests configuration-change-shaped restoration only. Both tests are needed and they cover different bugs.
- "How would you make this class of bug impossible rather than tested?" — Forbid mutable app state in singletons by convention and lint; make the session a persisted, observable source of truth so a fresh process reads it from disk rather than assuming it was populated.
- "What's the cost of putting everything in
SavedStateHandle?" — It goes over Binder into system_server, so it is size-capped and slow; oversizing it producesTransactionTooLargeException. Save keys, not payloads.
Chapter 28Context, and leak topology
| Need | Correct context | Why |
|---|---|---|
Inflating a view, theming, dialogs, startActivity without a flag | Activity | Requires the themed, windowed context. |
| Singletons, DI graph, WorkManager, DataStore, database | Application | Lifetime matches the object holding it. |
| Broadcast registration in a UI | Activity, unregistered in the mirrored callback | Application-context registration outlives the screen — a leak plus wasted work. |
object ImageCache {
private lateinit var context: Context
fun init(c: Context) { context = c } // Activity passed in
}
class MainActivity : Activity() {
override fun onCreate(b: Bundle?) {
ImageCache.init(this) // leaks the Activity forever
}
}
A static field holding an Activity retains its entire view hierarchy — often several megabytes — for the process lifetime, and every rotation adds another.
@Singleton
class ImageCache @Inject constructor(
@ApplicationContext private val context: Context,
)
DI makes the lifetime explicit and reviewable. context.applicationContext is the manual equivalent when DI is unavailable.
Chapter 29Intents, PendingIntents, and package visibility
A PendingIntent is a token granting another process permission to execute an intent as your app. That is why mutability flags are mandatory on modern Android and why getting them wrong is a real vulnerability.
val pi = PendingIntent.getActivity(
ctx, 0,
Intent(ACTION_HANDLE), // implicit
PendingIntent.FLAG_MUTABLE // fillable by the receiver
)
A mutable PendingIntent wrapping an implicit intent can be filled in by a malicious app and redirected to a component of your app that it should not reach — with your app's permissions. This is the intent-redirection class of vulnerability.
val pi = PendingIntent.getActivity(
ctx, requestCode,
Intent(ctx, DetailActivity::class.java).apply {
putExtra(EXTRA_ID, id)
},
PendingIntent.FLAG_IMMUTABLE or
PendingIntent.FLAG_UPDATE_CURRENT
)
Use FLAG_MUTABLE only where the platform requires it (direct-reply notifications, some Bubbles APIs), and then always with an explicit component.
Chapter 30Background execution in the restriction era
Modern Android's default position is that a backgrounded app should not run. The design question is therefore never "how do I run in the background" but "what class of work is this, and what guarantee does it actually need?"
| Work class | Mechanism | Guarantee / cost |
|---|---|---|
| Deferrable, must eventually happen | WorkManager with constraints | Survives process death and reboot. Timing is not guaranteed — Doze can delay it hours. |
| User-visible, ongoing, now | Foreground service with a declared type | Runs while visible; requires a justified foregroundServiceType and survives Play review scrutiny. |
| Short, tied to a visible screen | Coroutine in viewModelScope | No durability. Dies with the screen and the process. |
| Must run at an exact wall-clock time | AlarmManager exact alarms | Requires a special permission; only alarms and calendar-type use cases are policy-defensible. |
| Server-initiated | FCM high-priority message | Wakes the app even in Doze; abuse degrades your delivery priority. |
fun scheduleSync() {
WorkManager.getInstance(ctx).enqueue(
OneTimeWorkRequestBuilder<SyncWorker>().build()
)
}
// Called from onStart(). After ten launches,
// ten sync workers race each other.
Duplicate concurrent syncs cause write conflicts, duplicate analytics, and battery drain. Extremely common in real codebases.
fun scheduleSync() {
WorkManager.getInstance(ctx).enqueueUniqueWork(
"sync",
ExistingWorkPolicy.KEEP, // or REPLACE — decide deliberately
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints(requiredNetworkType = NetworkType.CONNECTED)
)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, SECONDS)
.build()
)
}
KEEP means "a sync is already pending, that's enough"; REPLACE means "the new request supersedes it". Choosing consciously — and saying why in the interview — is the L4 signal.
Chapter 31Permissions and the modern privacy surface
- Runtime permissions are revocable at any time, including while your app is backgrounded. Every permission-gated call needs a "not granted right now" path, not just a request-at-startup flow.
- Notifications are permission-gated from Android 13. Requesting at first launch produces the worst grant rates; request in context, after the user has done something that makes the value obvious.
- Scoped storage and the photo picker removed the legitimate reasons to request broad media permissions. Asking for them now is both a UX cost and a Play review risk.
- Background location requires a separate, later grant and a policy justification. Designing a feature that needs it is a product decision with a compliance cost, and a Staff engineer should say so before the design is committed.
- Package visibility means
queryIntentActivitiesreturns a filtered list unless you declare<queries>. A "share sheet is empty on Android 11+" bug is almost always this.
Chapter 32Notifications, deep links, and app links
Deep-link routing in a 200-screen app should be a first-class subsystem, not a manifest of intent filters. The Staff-level design has four properties:
- One resolver. A single component maps URI → typed route, so links are testable without launching an activity.
- Validated input. Any ID or URL in the link is untrusted. A link that opens an in-app WebView at an arbitrary URL is a phishing vector; allow-list the host.
- Auth-aware. Routes declare whether they require a session; the router handles the login-then-continue flow centrally rather than each screen re-implementing it.
- Verified App Links. With a hosted
assetlinks.json, your app opens the URL without a disambiguation dialog — and no other app can claim it.
sealed interface Route {
val requiresAuth: Boolean
data class Product(val id: ProductId) : Route { override val requiresAuth = false }
data class Order(val id: OrderId) : Route { override val requiresAuth = true }
}
class DeepLinkResolver(private val allowedHosts: Set<String>) {
fun resolve(uri: Uri): Route? {
if (uri.host !in allowedHosts) return null // untrusted
val seg = uri.pathSegments
return when {
seg.size == 2 && seg[0] == "p" -> ProductId.parse(seg[1])?.let(Route::Product)
seg.size == 2 && seg[0] == "o" -> OrderId.parse(seg[1])?.let(Route::Order)
else -> null
}
}
}
// Unit-testable in milliseconds, no emulator:
@Test fun rejects_foreign_host() =
assertNull(resolver.resolve(Uri.parse("https://evil.com/p/123")))Part IV rapid recall
- Binder transactions share a ~1 MB per-process buffer; saved state travels over it.
- Choreographer runs input → animation → traversal per VSYNC; a 40 ms block drops consecutive frames.
singleTaskclears the stack above it — the cause of "the deep link lost my checkout."- ViewModel survives rotation;
SavedStateHandlesurvives process death; only disk survives everything. adb shell am killtests process death;force-stopdoes not.PendingIntentmust beFLAG_IMMUTABLEwith an explicit component unless the platform demands otherwise.- Durable work is
WorkManagerwith unique-work policy and backoff — never a bare coroutine. - Deep links are untrusted input; validate host and IDs in one testable resolver.
Part V
Jetpack in Depth
Why each library exists, what it was created to fix, and — the part most candidates skip entirely — when not to use it.
Chapter 33ViewModel and state ownership
ViewModel exists for exactly one reason: to hold state across configuration changes without leaking the Activity. Everything else attributed to it — "it's the presentation layer", "it's where business logic goes" — is convention, not mechanism.
The mechanism: ViewModelStore is retained across recreation by the ComponentActivity's NonConfigurationInstance. onCleared() is called when the store's owner is finished, not when it is recreated. This is why holding a Context or a View in a ViewModel leaks: the ViewModel outlives them by design.
| Scope | Lives as long as | Use for / hazard |
|---|---|---|
| Activity / Fragment | That component | Default. Simple and predictable. |
| Nav graph | The graph is on the back stack | Multi-screen flows: checkout, onboarding. Correct answer to "how do three screens share state?" |
| Activity-scoped shared | The whole activity | Convenient and dangerous: becomes a global mutable object that no screen owns. The most common cause of the 2,000-line ViewModel. |
Interview question · architecture, asked constantly
"Your ViewModel has grown to 2,000 lines. What do you do?"
Why interviewers ask this
Everyone has seen this file. The question is whether you reach for a mechanical split (which usually makes it worse) or diagnose why it grew.
Deep answer L4
"First I'd find out why, because the fix differs. There are four common causes and only one of them is solved by splitting the class.
- It's a shared activity-scoped ViewModel doing six screens' work. Fix: give each screen its own, and move genuinely-shared state into a nav-graph-scoped ViewModel or a repository. This is the most common cause.
- Business logic that belongs in the domain layer. Pricing rules, eligibility checks, validation. Fix: extract to plain, testable classes with no Android dependency. This shrinks the file and makes the logic unit-testable in milliseconds.
- Orchestration complexity that's genuinely there. A screen that really does coordinate eight sources. Fix: model it as an explicit state machine, or split into independently-testable state producers that the ViewModel combines. Do not split arbitrarily by line count — you get two files that must be read together, which is worse.
- Boilerplate: mapping, event plumbing, one-off UI flags. Fix: attack the pattern, not the file — a shared mapper convention or a small state-machine helper removes it from 40 ViewModels at once.
I'd also check what the file's change rate and defect rate look like. A 2,000-line ViewModel nobody touches is a low priority; a 600-line one that three teams edit weekly and that causes merge conflicts is the real problem.
What I would not do is introduce a use-case class per method to move lines out. That produces 40 one-line classes, no reduction in complexity, and an onboarding cost for everyone."
Follow-up questions
- "How do you split it without a two-week freeze?" — Extract domain logic first (pure, no behaviour change, coverable by tests before and after), then split screens, then decompose orchestration. Each step ships independently.
- "How do you stop it recurring?" — A soft limit surfaced in review, but more usefully a lint rule on ViewModel dependency count, and a template that makes the right structure the path of least resistance.
- "When is a big ViewModel acceptable?" — When the screen is genuinely complex and the alternative is scattering coherent logic across files that must be read together. Cohesion beats line count.
Chapter 34Lifecycle-aware collection
sequenceDiagram participant UI as Composable / Fragment participant L as Lifecycle participant VM as ViewModel StateFlow participant R as Repository (upstream) UI->>L: repeatOnLifecycle(STARTED) L->>VM: collect starts VM->>R: subscriptionCount 0 → 1, upstream starts Note over UI,R: user backgrounds the app L-->>VM: STOP → block cancelled, collector gone VM-->>R: WhileSubscribed(5s) grace, then upstream stops Note over UI,R: rotation instead: STOP then START inside 5s L->>VM: collect restarts, cached value replayed, no refetch
The single most consequential Jetpack detail: a coroutine launched in lifecycleScope is cancelled only at onDestroy. A flow collected there keeps running while the app is backgrounded — updating a UI nobody sees, holding a socket open, draining battery.
lifecycleScope.launch {
viewModel.state.collect { render(it) }
}
// Also wrong: launchWhenStarted, which *suspends*
// the coroutine but keeps the upstream flow active.
lifecycleScope.launchWhenStarted {
viewModel.locations.collect { ... }
}
launchWhenStarted (deprecated) pauses delivery but does not cancel the producer — so a location or socket upstream keeps running with its results buffered.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.state.collect { render(it) }
}
}
// Compose equivalent:
val state by viewModel.state
.collectAsStateWithLifecycle()
repeatOnLifecycle cancels the whole block on STOP and restarts it on START, so the upstream is genuinely torn down. Paired with WhileSubscribed(5_000) upstream, a rotation costs nothing but a background trip costs nothing either.
Chapter 35Room
Room is a compile-time-verified SQLite wrapper. The interview value is in the parts that are not obvious from the docs.
- Observable queries are table-scoped.
Flow<List<T>>re-runs on any write to any table it touches, not on a change to the returned rows. A frequently-written table therefore re-queries constantly; that is a common jank source on list screens. - Suspend DAO functions are already main-safe. Room dispatches to its own executor.
@Transactionmatters for relations. A@Relationquery issues multiple statements; without@Transactionyou can observe a torn read.- Migrations are permanent public API. Every schema version that shipped must have a path forward, from any version, forever — users skip releases.
Room.databaseBuilder(ctx, Db::class.java, "app.db")
.fallbackToDestructiveMigration() // silent data loss
.build()
// And a query that re-runs on every unrelated write:
@Query("SELECT * FROM messages ORDER BY ts DESC")
fun all(): Flow<List<Message>> // 5k rows, re-read constantly
Destructive migration in a shipped app deletes user data — including a pending offline write queue. And an unbounded observable query over a hot table can dominate your CPU profile.
Room.databaseBuilder(ctx, Db::class.java, "app.db")
.addMigrations(MIGRATION_7_8, MIGRATION_8_9)
.build()
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE messages ADD COLUMN read INTEGER NOT NULL DEFAULT 0")
db.execSQL("CREATE INDEX idx_messages_ts ON messages(ts)")
}
}
@Query("SELECT * FROM messages WHERE chat_id = :id ORDER BY ts DESC LIMIT 50")
fun page(id: String): Flow<List<Message>>
Export the schema JSON, commit it, and run MigrationTestHelper over every version pair in CI. This is one of the few places where a missing test is a data-loss incident rather than a bug.
Chapter 36DataStore vs SharedPreferences
SharedPreferences has two defects that DataStore exists to fix: commit() blocks the calling thread on disk I/O, and apply(), while async, is drained synchronously on the main thread during onPause/onStop via QueuedWork — a well-documented ANR source in apps with many preference writes. There is also no error channel: a failed write is silent.
prefs.edit().putString(KEY_TOKEN, token).commit() // blocks main
// Reading on the main thread the first time also
// blocks: the whole file is parsed on first access.
val theme = prefs.getString("theme", "dark")
The first read loads and parses the entire XML file synchronously. On a cold start with a large prefs file this is a measurable startup cost, and it is one of the top ANR signatures in Play Vitals for older apps.
val theme: Flow<Theme> = dataStore.data
.catch { e ->
if (e is IOException) emit(emptyPreferences()) else throw e
}
.map { Theme.from(it[THEME_KEY]) }
suspend fun setTheme(t: Theme) {
dataStore.edit { it[THEME_KEY] = t.name }
}
Fully asynchronous, transactional, with errors as a real signal. Note the mandatory catch for IOException — DataStore surfaces read failures rather than hiding them, and forgetting this crashes on corrupt files.
It has no multi-process support (a documented limitation), so a :widget or :pushservice process cannot share it safely. If you have multiple processes, you need a ContentProvider-fronted store or a database. Naming this unprompted is a strong signal — it is the kind of constraint that appears three months into a project.
Chapter 37WorkManager, Paging, Navigation
WorkManager guarantees, stated precisely
WorkManager guarantees the work will eventually run subject to its constraints, surviving process death and reboot. It does not guarantee when. On aggressively-optimising OEM firmware, deferred work can be delayed by hours or suppressed until the app is opened. Any product requirement of the form "this must happen within N minutes" cannot be met by WorkManager alone — it needs a server-side push as the trigger.
Paging 3 with a RemoteMediator — the shape interviewers expect
@OptIn(ExperimentalPagingApi::class)
class FeedMediator(
private val api: FeedApi,
private val db: AppDatabase,
) : RemoteMediator<Int, FeedEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, FeedEntity>,
): MediatorResult = try {
val key = when (loadType) {
LoadType.REFRESH -> null
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> db.keys().nextKeyFor(state.lastItemOrNull()?.id)
?: return MediatorResult.Success(endOfPaginationReached = true)
}
val page = api.feed(cursor = key, limit = state.config.pageSize)
db.withTransaction {
if (loadType == LoadType.REFRESH) { db.feed().clear(); db.keys().clear() }
db.feed().insertAll(page.items.map { it.toEntity() })
db.keys().insert(RemoteKey(page.items.lastOrNull()?.id, page.nextCursor))
}
MediatorResult.Success(endOfPaginationReached = page.nextCursor == null)
} catch (e: IOException) {
MediatorResult.Error(e) // Paging surfaces this as LoadState.Error
}
}Two details that separate levels: the write is inside withTransaction so a crash mid-insert cannot leave the list and the keys table disagreeing; and REFRESH clears before inserting so a server-side reorder does not produce duplicates. Cursor-based keys rather than page numbers are also the correct choice for a feed where items are inserted at the head — page-number pagination on a live feed shows duplicates and skips items, which is a classic follow-up.
Chapter 38Dependency injection
| Dimension | Hilt / Dagger | Koin | Manual |
|---|---|---|---|
| Graph validation | Compile time — a missing binding fails the build | Runtime — fails when the screen opens | Compile time (it is just constructors) |
| Build cost | Significant: kapt/KSP across modules | Negligible | None |
| Runtime cost | Near zero; generated code | Reflection-free but map lookups; measurable at startup with a large graph | Zero |
| Scoping | Rich, enforced, tied to Android components | Convention-based | Whatever you write |
| Best fit | Large multi-module apps with many teams | Small-to-medium apps, KMP-shared code | Small apps, libraries, and the composition root of any app |
The honest Staff position: the compile-time-validation argument is decisive at scale — a runtime DI failure in a rarely-visited screen reaches production, and with 40 engineers that will happen. Below roughly ten engineers, Koin's build-time saving and simplicity often wins. And for a library you publish, no DI framework at all: expose constructors and let the consumer wire them, because forcing your DI choice on consumers is a hostile API decision.
class OrderRepository {
@Inject lateinit var api: Api
@Inject lateinit var db: Dao
@Inject lateinit var clock: Clock
init { AppComponent.inject(this) } // service locator
}
// Cannot construct in a unit test without the graph.
// Dependencies are invisible at the call site.
Hidden dependencies, uninitialised-field crashes, and tests that need the whole DI graph to run.
class OrderRepository @Inject constructor(
private val api: Api,
private val db: Dao,
private val clock: Clock,
private val io: CoroutineDispatcher,
)
// Test needs no framework at all:
val repo = OrderRepository(FakeApi(), InMemoryDao(),
Clock.fixed(...), UnconfinedTestDispatcher())
Dependencies are visible, immutable, and the class is constructible without any container. Field injection is only for framework-instantiated types (Activity, Fragment, Worker) that you do not construct.
Part V rapid recall
- ViewModel exists for configuration change; it outlives the Activity, so never hold a Context or View.
repeatOnLifecyclecancels the producer;launchWhenStartedonly pauses delivery.- Room observable queries invalidate per table, not per row; bound them with
LIMITand indexes. - Every shipped schema version needs a migration path forever, tested with
MigrationTestHelper. - SharedPreferences
apply()drains synchronously atonStop— an ANR source. DataStore has no multi-process support. - WorkManager guarantees eventually, not soon; "within N minutes" requires a push trigger.
- Cursor pagination, not page numbers, for live feeds; write pages and keys in one transaction.
- Constructor injection always; field injection only for framework-constructed types.
Part VI
Jetpack Compose
The runtime, not the widget catalogue. Staff Compose questions live in composition, snapshots, stability and the three phases — because that is where the performance and correctness bugs are.
Chapter 39The runtime: slot table and composition
A composable function is not a normal function call. The Compose compiler rewrites it to accept a Composer and to emit groups into the slot table — a gap buffer that records, positionally, what was composed and what values were remembered.
Three consequences that explain most Compose behaviour:
- Identity is positional.
rememberis keyed by the call site's position in the tree, not by variable name. Move the call inside anifand it is a different slot. - Recomposition is scoped. The runtime invalidates the smallest enclosing restartable scope that read the changed state, not the whole tree. This is why reading state as late as possible is the core performance discipline.
- Composition is not the frame. Composition produces a description; layout and draw happen afterwards. A screen can be slow with almost no recomposition, because the cost is in measurement or drawing.
Column {
messages.forEach { msg ->
// Position-based identity. Insert at the head and
// every item's remembered state shifts down one.
MessageRow(msg)
}
}
LazyColumn {
items(messages) { MessageRow(it) } // no key
}
Prepending one item makes every subsequent slot mismatch: animation state, expanded/collapsed flags and scroll anchors attach to the wrong rows. In a LazyColumn it also defeats item reuse.
LazyColumn {
items(
items = messages,
key = { it.id }, // stable identity
contentType = { it.type }, // better reuse
) { MessageRow(it) }
}
// Non-lazy loops:
messages.forEach { msg ->
key(msg.id) { MessageRow(msg) }
}
Keys let the runtime move slots rather than recreate them, preserving remembered state and enabling correct item animations.
Chapter 40The snapshot system
Compose state is built on a multi-version concurrency control system. Each snapshot sees a consistent view of all state; writes are recorded per-snapshot and applied atomically. Reading a State object inside a composition registers the reading scope as an observer of that object.
Practical implications interviewers probe:
- You can safely read Compose state from a background thread inside
Snapshot.withMutableSnapshot; the changes apply atomically or conflict. snapshotFlow { }converts state reads into a coldFlow, emitting when the values read inside it change — the correct bridge from Compose state to coroutine world.- A
MutableStateholding aMutableListdoes not notify onlist.add(), because the state object itself never changed. This is the single most common "why didn't my UI update" bug.
var items by remember { mutableStateOf(mutableListOf<Item>()) }
fun add(i: Item) {
items.add(i) // no recomposition: the State never changed
}
The reference is identical, so no invalidation is recorded. Worse, it works intermittently — an unrelated recomposition later shows the new items, so it looks like a race.
// Option A: observable collection
val items = remember { mutableStateListOf<Item>() }
items.add(i) // notifies
// Option B: immutable value, replaced
var items by remember { mutableStateOf(persistentListOf<Item>()) }
items = items.add(i) // new reference
// Option C (preferred for screen state): it lives in the
// ViewModel as an immutable UiState, not in the composable.
Option C is the architectural answer: screen state belongs in a state holder, and the composable receives an immutable snapshot of it.
Chapter 41Effects — which one, and why
| API | Runs | Use for / the bug it prevents |
|---|---|---|
LaunchedEffect(key) | Coroutine on entering composition; cancelled and restarted when key changes | Suspending work tied to composition. The bug: using Unit as the key when the work depends on a parameter. |
DisposableEffect(key) | On enter; onDispose on leave or key change | Registering listeners, sensors, receivers. The bug: forgetting cleanup, which leaks. |
SideEffect | After every successful composition | Publishing state to a non-Compose object. The bug: doing it inline in the composable body, which runs on failed compositions too. |
produceState | Coroutine producing a State | Converting a callback/suspend source into state. |
rememberUpdatedState | Captures the latest value without restarting | A long-lived effect that must call the newest lambda. The bug it fixes: a stale callback in a timer. |
derivedStateOf | Recomputes only when its result changes | Cheap derivation from rapidly-changing state. Misused, it costs more than it saves. |
@Composable
fun AutoDismiss(onDismiss: () -> Unit) {
LaunchedEffect(Unit) {
delay(5_000)
onDismiss() // captured on first composition
}
}
// If the parent recomposes with a new onDismiss
// (a different navigation target), the OLD one fires.
Keying on onDismiss instead would restart the 5-second timer on every recomposition — the other half of the trap.
@Composable
fun AutoDismiss(onDismiss: () -> Unit) {
val current by rememberUpdatedState(onDismiss)
LaunchedEffect(Unit) {
delay(5_000)
current() // always the latest
}
}
The effect keeps running; the reference it calls is refreshed. This exact question appears in Compose interviews constantly.
Interview question · the derivedStateOf trap
"When can derivedStateOf make performance worse?"
Short answer
When the derivation's result changes as often as its inputs. You then pay the snapshot-observation overhead and the extra state object for no reduction in recomposition.
Deep explanation
derivedStateOf earns its cost when a high-frequency input maps to a low-frequency output — the canonical case being scrollState.firstVisibleItemIndex > 0, where the index changes on every frame of a scroll but the boolean changes twice per session. If instead you write derivedStateOf { items.filter { it.matches(query) } }, the list changes whenever the query changes, so the output frequency equals the input frequency: you have added an observation layer and an allocation with no benefit. Worse, people reach for it to avoid recomputing an expensive derivation, which it does not do — it still recomputes on every input change; it only suppresses downstream invalidation. For expensive work you want remember(key), or better, the work moved out of composition entirely.
Follow-up questions
- "So what's the rule?" — Use it when reads are frequent, the derivation is cheap, and the output is coarse relative to the input.
- "How would you verify it helped?" — Layout Inspector recomposition counts before and after, on the specific composable you expected to stop recomposing. If the count is unchanged, remove it.
- "What about filtering a big list per keystroke?" — Not a Compose problem. That belongs in the ViewModel behind
debounceandflatMapLatest, off the main thread.
Chapter 42Stability, skipping, and strong skipping
The runtime skips recomposing a composable when all its parameters are equal and stable. Stable means: the type's equals is consistent, its public properties do not change without notifying composition, and all its public property types are themselves stable.
The compiler infers stability. It cannot infer it for types it cannot see — which historically made every class from a module without the Compose compiler unstable, including List, Set and Map (the interface could be backed by a mutable implementation).
data class UiState(
val items: List<Item>, // interface: unstable
val onClick: (Item) -> Unit, // new lambda each recomposition
val config: ConfigFromOtherModule // not Compose-compiled: unstable
)
@Composable fun Screen(state: UiState) { ... }
// Never skips. Recomposes on every parent recomposition.
One unstable parameter makes the whole call unskippable, and the effect cascades down the subtree.
@Immutable
data class UiState(
val items: ImmutableList<Item>, // kotlinx.collections.immutable
val config: Config,
)
@Composable
fun Screen(
state: UiState,
onClick: (Item) -> Unit, // hoisted, remembered by caller
)
// For third-party types you cannot annotate:
// stability configuration file (Compose compiler option)
// listing them as stable.
ImmutableList is a real type the compiler can reason about. @Immutable is a promise you make and the compiler trusts — breaking it produces stale UI that is very hard to debug.
With strong skipping mode (default from Compose 1.3.x-era compiler releases onward and standard in current toolchains), composables with unstable parameters become skippable using instance equality for unstable types, and lambdas are automatically remembered. This removes most of the manual @Stable work. Two things it does not remove: unstable types still compare by reference, so a newly-allocated equal object still triggers recomposition; and it does not fix a List you mutate in place. The L4 answer notes that strong skipping reduces the tax but does not make stability irrelevant — you still model state as immutable values.
Chapter 43State hoisting and screen architecture
flowchart TD VM["CartViewModel"] -- "StateFlow<CartUiState>" --> R["CartRoute — stateful"] VM -- "Channel<CartEvent>" --> R R -- "state: CartUiState" --> S["CartScreen — stateless"] S -- "onIntent(CartIntent)" --> R R -- "viewModel::onIntent" --> VM S --> P["@Preview"] S --> T["Screenshot test"]
The split exists so the two boxes on the right are reachable: a stateless screen can be rendered from a hand-built state with no ViewModel, DI or navigation, which is the only affordable way to cover 200 screens' visual states.
// 1. State: immutable, exhaustive, no events inside.
@Immutable
data class CartUiState(
val lines: ImmutableList<CartLine> = persistentListOf(),
val total: Money = Money.ZERO,
val isSubmitting: Boolean = false,
val error: CartError? = null,
)
// 2. Stateful entry point: knows about the ViewModel.
@Composable
fun CartRoute(
onCheckout: (OrderId) -> Unit,
viewModel: CartViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.events.collect { e ->
when (e) { is CartEvent.Checkout -> onCheckout(e.orderId) }
}
}
CartScreen(state = state, onIntent = viewModel::onIntent)
}
// 3. Stateless UI: previewable, screenshot-testable, no DI.
@Composable
fun CartScreen(
state: CartUiState,
onIntent: (CartIntent) -> Unit,
modifier: Modifier = Modifier,
) { /* pure UI */ }Why this shape rather than a single composable: the stateless layer can be rendered in a @Preview and a screenshot test with a hand-built state, which is the only affordable way to test 200 screens' visual states; and the route layer isolates every framework dependency, so the UI module does not depend on Hilt or navigation.
CompositionLocal — when it is right
Legitimate uses: theme, typography, density, a locale-aware formatter — things that are ambient, rarely change, and would pollute every signature. Illegitimate: passing a ViewModel, a repository, or screen data. The test: if a reader of the composable would be surprised that the value can change the output, it should be a parameter. Implicit data flow is the thing Compose's model exists to remove.
Chapter 44The three phases: composition, layout, drawing
Each frame runs up to three phases. State read in a later phase does not invalidate the earlier ones — which is the single highest-leverage Compose performance technique.
flowchart LR S["State change"] --> C["Composition
what to show"] C --> L["Layout
measure & place"] L --> D["Drawing
render commands"] D --> F["Frame"] S -. "read in Modifier.offset { }" .-> L S -. "read in graphicsLayer { }" .-> D
The dotted paths are the point: a state value read inside a lambda-taking modifier is read during layout or draw, so a change to it re-runs only that phase. The same value read in the composable body invalidates all three.
val offset = scrollState.firstVisibleItemScrollOffset
Box(
Modifier.offset(y = (offset / 2).dp) // recomposes every frame
.alpha(1f - offset / 500f)
)
The state is read during composition, so every scroll pixel recomposes this subtree — composition, layout and draw, sixty times a second.
Box(
Modifier
.offset { IntOffset(0, scrollState.offset / 2) } // lambda: layout phase
.graphicsLayer { alpha = 1f - scrollState.offset / 500f } // draw phase
)
The lambda-taking overloads read the state inside the layout or draw phase, so only that phase re-runs. Same visual result, a fraction of the cost — and it is the standard answer to "this parallax scroll is janky."
Chapter 45Lazy layouts and a 10,000-item feed
The question "how would you optimise a LazyColumn rendering 10,000 items?" has a wrong premise, and saying so is the first point scored: you never render 10,000 items — lazy layouts compose only what is visible. If it is slow, the problem is per-item cost, key stability, or something above the list.
- Do not page the whole dataset into memory. Use Paging 3 so the list holds a window, not the corpus.
- Provide
keyandcontentType. Keys preserve state and enable animation; content types make reuse effective across heterogeneous rows. - Make item state immutable and stable, so scrolling does not recompose visible items.
- Fix the item's own cost: no allocation in the item body, no date formatting per frame, image sizes constrained so decode does not resize on the main thread.
- Never nest a scrollable in the same direction — a
LazyColumninside a vertically-scrollingColumnforces infinite-constraint measurement and composes every item. - Baseline profile the scroll path, otherwise the first scroll runs interpreted and janks on exactly the impression that matters.
items(rows, key = { it.id }) { row ->
val fmt = SimpleDateFormat("dd MMM", Locale.getDefault())
Text(fmt.format(row.date)) // alloc + parse per item per frame
AsyncImage(model = row.url) // unbounded size
}
Formatter allocation and image decode dominate the frame. This is what "the list is janky but recomposition counts look fine" actually means.
// Formatting happens once, in the mapper, off the main thread:
data class RowUi(val id: String, val dateLabel: String, val url: String)
items(rows, key = { it.id }, contentType = { it.type }) { row ->
Text(row.dateLabel)
AsyncImage(
model = ImageRequest.Builder(ctx).data(row.url)
.size(width = 320, height = 180) // decode to display size
.crossfade(false)
.build(),
modifier = Modifier.size(160.dp, 90.dp),
)
}
Move every derivation out of the item body into the state mapping. The item body should read fields and emit UI — nothing else.
Chapter 46Recomposition debugging
// build.gradle.kts
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_reports")
metricsDestination = layout.buildDirectory.dir("compose_metrics")
}
// Produces, per module:
// *-composables.txt → restartable / skippable per function
// *-classes.txt → stable / unstable per class, with the reason
//
// Read the classes file first: one unstable class usually
// explains a dozen unskippable composables.Interview question · production debugging
"A Compose screen is slow, but Layout Inspector shows a reasonable recomposition count. What now?"
Why interviewers ask this
Because the reflexive Compose answer is "reduce recomposition," and a candidate who has only that answer is stuck. The question tests whether they understand the three phases and can profile rather than guess.
Deep answer L4
"Recomposition count only measures the first phase. I'd get a system trace — Perfetto or the Studio profiler — on a physical mid-tier device with a release build, and look at where the main thread's frame time actually goes. Five candidates in rough order of frequency:
- Layout cost. Deeply nested or intrinsic-measuring layouts, or a
SubcomposeLayoutin a hot path. Intrinsics can force multiple measurement passes; nested weights compound it. - Draw cost. Overdraw, large shadows/elevation, blur, or a Canvas re-rendering complex paths every frame.
graphicsLayerwith a cached layer often fixes this. - Work off the composition path entirely. Main-thread image decode, a synchronous disk read in a mapper, a
Flowcollecting onMainand doing real work in its lambda. - Missing baseline profile. If it is slow only for the first few seconds or the first scroll, this is almost always it — JIT versus AOT.
- Recomposition cost rather than count. Twenty recompositions is fine unless each one allocates a list of 5,000 items.
The verification discipline matters as much as the fix: I'd add a Macrobenchmark for the scroll journey and gate the improvement in CI, so the regression cannot silently return. That's the difference between fixing this instance and fixing the class."
Follow-up questions
- "How do you measure jank in the field, not the lab?" —
JankStats, and Play Vitals' frame-timing metrics segmented by device tier. Lab numbers on a flagship hide the problem entirely. - "What's your device policy for performance work?" — Measure on the 25th-percentile device in your install base, not the newest. Most performance bugs are invisible on a current Pixel.
- "Would you ever move UI back to Views for performance?" — Rarely, and only with a measurement. The legitimate cases are highly specialised custom rendering; the illegitimate case is a Compose screen nobody profiled.
Chapter 47Testing, semantics, and accessibility
Compose's semantics tree is simultaneously the accessibility tree and the test tree — a design decision with a useful consequence: a screen that is hard to test is usually inaccessible. Treating them as the same problem is a Staff framing worth stating.
Box(
Modifier
.size(32.dp) // below 48dp touch target
.clickable { onDelete() } // no role, no label
) { Icon(Icons.Default.Delete, contentDescription = null) }
// Test must resort to:
composeRule.onNodeWithTag("del_3").performClick()
Screen readers announce nothing, the target fails WCAG's minimum size, and the test is coupled to an arbitrary tag that says nothing about intent.
IconButton( // 48dp target, Role.Button
onClick = onDelete,
modifier = Modifier.semantics {
contentDescription = "Delete ${item.name}"
}
) { Icon(Icons.Default.Delete, contentDescription = null) }
// Test reads like the user's intent:
composeRule.onNodeWithContentDescription("Delete Milk")
.assertHasClickAction()
.performClick()
The same annotation serves TalkBack and the test. Note contentDescription = null on the inner icon: the button already carries the label, and duplicating it makes TalkBack say it twice.
Part VI rapid recall
- Slot-table identity is positional;
keyis what makes it semantic. - Mutating a collection inside
mutableStateOfdoes not notify — usemutableStateListOfor replace the value. rememberUpdatedStatefor a fresh lambda in a long-lived effect; keying on the lambda restarts it instead.derivedStateOfonly pays when a high-frequency input yields a low-frequency output.- Strong skipping reduces the stability tax but does not replace immutable state modelling.
- Route (stateful) / Screen (stateless) split is what makes previews and screenshot tests affordable.
- Defer state reads to layout (
Modifier.offset {}) or draw (graphicsLayer {}) to skip phases. - Slow screen with low recomposition = layout, draw, main-thread work, or a missing baseline profile.
- The semantics tree is the accessibility tree; test through it, not through tags.
Part VII
Android Architecture
Architectures compared honestly, including the many cases where the more sophisticated option is the wrong one. The recurring test: can you justify a boundary by what it prevents, not by what it is called?
Chapter 48Boundaries: what they cost and what they buy
Every layer boundary buys one thing — the ability to change one side without the other — and charges three: indirection, mapping code, and a decision every engineer must make correctly ("where does this go?"). A boundary is justified when the change it isolates is likely and the cost of the change without it is high.
| Boundary | Isolates | Worth it when |
|---|---|---|
| UI ↔ state holder | Rendering from logic | Always. It is what makes the UI testable and previewable. |
| Domain ↔ data | Business rules from storage and transport | The rules are non-trivial, reused across screens, or outlive the current backend. |
| Domain models ≠ DTOs | Your model from the server's schema | The API is owned by another team, versioned, or shaped for the backend's convenience. |
| Feature ↔ feature | Teams from each other | More than ~3 teams. Below that, the coordination cost exceeds the benefit. |
If your UserDomainModel has exactly the same fields as UserDto and UserUiModel, and the mappers are field-for-field copies, that boundary is currently costing you three files per entity and buying nothing. The honest Staff position: keep the boundary where the schemas have diverged or are expected to; collapse it where they have not. "We always map" and "we never map" are both dogma.
Chapter 49Clean Architecture, honestly assessed
flowchart LR
subgraph UI["UI layer"]
C["Composable"] --> VM["ViewModel / state holder"]
end
subgraph D["Domain layer — pure Kotlin, no Android"]
UC["UseCase"] --> RI["Repository interface"]
end
subgraph DA["Data layer"]
RImpl["Repository impl"] --> R["Remote source"]
RImpl --> L["Local source"]
end
VM --> UC
RImpl -. "implements" .-> RI
The arrow that matters is the dotted one: the data layer depends on the domain's interface, not the reverse. That single inversion is 80% of the value — the domain becomes a pure-Kotlin module with no Android dependency, so its tests run in milliseconds on the JVM and it can be shared with KMP later.
| Claim | Reality at scale |
|---|---|
| "It's testable" | True, and the biggest real win: a pure domain module has no Robolectric, no emulator, no Dispatchers.Main. |
| "You can swap the database" | Almost never happens. Do not justify the architecture with this; interviewers have heard it and it signals repetition rather than experience. |
| "It scales to large teams" | True — but the mechanism is module boundaries and ownership, not the layer names. You can get most of it with fewer layers. |
| "Use cases document the domain" | True when they contain logic. False when they are one-line pass-throughs, which is what most codebases actually have. |
| Cost | 3–5 files per feature slice, a mapping tax, and a genuine onboarding cost. On a 6-screen app with 3 engineers, this is a net negative. |
class GetUserUseCase(private val repo: UserRepository) {
suspend operator fun invoke(id: String) = repo.getUser(id)
}
class GetOrdersUseCase(private val repo: OrderRepository) {
suspend operator fun invoke() = repo.getOrders()
}
// 40 of these. Each adds a file, a DI binding,
// a test that asserts delegation, and zero logic.
Pure indirection. It also actively hides the real dependency graph, because the ViewModel now depends on 8 use cases instead of 2 repositories.
/**
* Combines entitlement, local drafts and remote orders,
* applies the refund-eligibility rules, and is used by
* three screens.
*/
class GetRefundableOrdersUseCase(
private val orders: OrderRepository,
private val entitlements: EntitlementRepository,
private val clock: Clock,
) {
operator fun invoke(): Flow<List<RefundableOrder>> =
combine(orders.all(), entitlements.current()) { os, ent ->
os.filter { it.isRefundable(ent, clock.now()) }
.map { it.toRefundable() }
}
}
// Elsewhere, the ViewModel calls repo.getUser(id) directly.
The rule: introduce a use case when there is logic, multiple sources, or reuse. Otherwise let the state holder call the repository. Mixing both is not inconsistency — it is proportion.
Chapter 50MVVM vs MVI
| Dimension | MVVM (state + methods) | MVI (state + intents + reducer) |
|---|---|---|
| State | One or several observable properties | Exactly one immutable state object |
| Input | Public methods on the ViewModel | A single onIntent(Intent) entry point |
| Traceability | Harder — state can change from anywhere in the class | Excellent — every transition passes through one reducer; trivially loggable and replayable |
| Boilerplate | Low | Higher: intent, reducer, effect types per screen |
| Testing | Call method, assert state | Feed intents, assert state sequence; reducer testable as a pure function |
| Best for | Most screens: forms, detail pages, settings | Complex flows with many interacting inputs — checkout, editors, multi-step wizards, anything with concurrency |
The trap: "MVI is better" is not an answer. Both put immutable state in a holder and drive UI unidirectionally; the difference is whether input is funnelled through one channel. That funnel buys traceability and costs boilerplate. On a settings screen it is pure cost; on a checkout flow with payment, address validation and inventory reservation racing each other, the replayable transition log is worth the files.
class CheckoutViewModel(...) : ViewModel() {
private val _state = MutableStateFlow(CheckoutUiState())
val state: StateFlow<CheckoutUiState> = _state.asStateFlow()
private val _events = Channel<CheckoutEvent>(Channel.BUFFERED)
val events = _events.receiveAsFlow()
// Single input channel (MVI), but no ceremony reducer class:
fun onIntent(intent: CheckoutIntent) {
when (intent) {
is SetAddress -> _state.update { it.copy(address = intent.a, error = null) }
is Submit -> submit()
is Retry -> submit()
}
}
private fun submit() = viewModelScope.launch {
_state.update { it.copy(isSubmitting = true) }
submitOrder(_state.value.toRequest())
.onSuccess { _events.send(CheckoutEvent.Done(it.orderId)) }
.onFailure { e -> _state.update {
it.copy(isSubmitting = false, error = e.toCheckoutError()) } }
}
}Chapter 51UI state modelling and state machines
Where do loading and error live? The question is really "is this state or an event?" — and the answer follows a rule: if it should still be true after rotation, it is state; if it should happen exactly once, it is an event.
- A full-screen loading spinner: state.
- A validation error under a field: state.
- A one-off snackbar or navigation: event.
- A retryable network failure that replaces the content: state (with a retry intent), because it must survive rotation.
data class ProfileUi(
val isLoading: Boolean,
val user: User?,
val orders: List<Order>?,
val error: String?,
)
// User loads in 80ms, orders in 2s.
// The whole screen spins for 2s, and the
// "user is null but not loading" case is unrepresentable-but-reachable.
One flag for several independent sources produces either an over-eager spinner or a lying UI.
@Immutable
data class ProfileUi(
val header: Section<User> = Section.Loading,
val orders: Section<ImmutableList<Order>> = Section.Loading,
)
sealed interface Section<out T> {
data object Loading : Section<Nothing>
data class Ready<T>(val value: T) : Section<T>
data class Failed(val retryable: Boolean) : Section<Nothing>
}
Each region renders independently, partial failure degrades gracefully, and every combination in the type is legal. This is what "progressive rendering" means concretely.
Chapter 52The repository under pressure
Interview question · the most-asked architecture question
"Should repositories expose Flow or suspend functions?"
Why interviewers ask this
It looks like a style question and is actually a question about where your app's reactive boundary sits — a decision that is expensive to reverse.
Short answer
Expose Flow when the caller must observe change over time; suspend when it is a request/response. Most repositories legitimately have both.
Deep explanation L4
"The decision criterion is the source of truth. If the data lives in a local store that other actors write to — a sync worker, a push handler, another screen — then a one-shot read is a bug waiting to happen: the screen shows a value that is already stale and has no way to learn. That is a Flow, backed by an observable query.
If the operation is a command — submit an order, refresh, upload — it is a suspend function returning a result. Modelling a command as a Flow means the caller must remember to terminate it, and errors become emissions instead of exceptions, which loses structured concurrency's error propagation.
Three failure modes I'd call out. First, cold flows without sharing: five screens collecting repo.user() means five network calls, because Flow is cold — you need shareIn/stateIn at the right level or a single-source-of-truth database. Second, Flow<Result<T>> everywhere: it leaks the transport's failure vocabulary into the domain and makes every operator awkward; prefer a domain error type, and only where partial failure is a real state. Third, ownership: a Flow exposed from a singleton repository and shared with an app scope will keep running when no screen is watching unless SharingStarted says otherwise.
At the organisational level, this needs to be a written rule, not per-engineer taste. In a 300-module codebase, inconsistency here means every new engineer relitigates it in review. I'd write it as: observable state → Flow; commands → suspend; never Flow for one-shot reads; sharing configured at the ViewModel unless the data is genuinely app-wide — and back it with a lint rule where it can be mechanically checked."
Follow-up questions
- "Where should the loading state be produced?" — Not in the repository. The repository emits data or throws; the state holder converts that into
Loading/Ready/Failed, because loading is a UI concept. - "Should the repository return domain models or entities?" — Domain models. Leaking Room entities means
@Entityannotations and column names reach the UI, and a schema migration becomes a UI change. - "How do you test a repository that exposes Flow?" — Turbine, with a fake DAO backed by
MutableStateFlowso you can drive emissions deterministically, and an injected dispatcher. - "What if the backend has no push and data can only change by polling?" — Then the observability is manufactured, and you should be honest about it: a
Flowthat polls hides a cost, so make the refresh explicit and let the caller decide the cadence.
Chapter 53Architecting a 200-screen application
This is a common Staff design prompt. The trap is designing the screen architecture; what is being asked is the system that lets 40 teams build 200 screens without collision.
flowchart TD App[":app — assembly only"] --> F1[":feature:cart"] App --> F2[":feature:search"] App --> F3[":feature:orders"] F1 --> NavApi[":core:navigation-api"] F2 --> NavApi F3 --> NavApi F1 --> DomC[":core:domain-cart"] F3 --> DomO[":core:domain-orders"] DomC --> DataC[":core:data-cart"] DomO --> DataO[":core:data-orders"] DataC --> Net[":core:network"] DataO --> Net DataC --> DB[":core:database"] DataO --> DB F1 --> DS[":core:designsystem"] F2 --> DS F3 --> DS
Seven decisions that constitute the answer:
- Feature modules never depend on each other. Cross-feature navigation goes through a route contract in
:core:navigation-api; the implementation is wired in:app. This is the single most important rule and the one most often broken. - One screen contract (Route/Screen/UiState/Intent), generated from a template, so a reviewer moving between features recognises the shape instantly.
- Data ownership is explicit. Each domain concept has exactly one owning module and one source of truth. Two modules caching entitlement is the bug class from Part I.
- The design system is a real module with an API review. Otherwise 200 screens produce 200 buttons.
- Boundaries are enforced mechanically — dependency rules in the build logic, a lint rule for illegal imports. A documented rule is a suggestion.
- State scoping is defined: screen state in a ViewModel; flow state in a nav-graph ViewModel; app state in a repository with a single source of truth. No activity-scoped shared ViewModels.
- The paved road is a generator. New feature = one command producing module, DI wiring, screen contract, tests and a CI entry. Consistency at 200 screens comes from defaults, not review.
Chapter 54Multi-surface architecture
Foldables, tablets, Wear, TV and Auto do not need parallel apps; they need a shared domain and a deliberately separate presentation layer.
- Share: domain, data, sync, auth. These are surface-independent and duplicating them causes divergent bugs.
- Do not share: navigation topology, layout, input model. A list-detail on a foldable is a two-pane layout with one back-stack entry; on a phone it is two entries. Forcing one navigation model onto both produces the "back button does the wrong thing on a tablet" bug.
- Drive layout from window size classes, never from
isTabletbooleans or screen-width dp thresholds sprinkled through the code — a folding phone changes class at runtime, and a resizable window on a large screen changes it continuously. - Test the transition, not just the states. Fold/unfold mid-flow is where state loss appears, because it is a configuration change plus a layout topology change at once.
Part VII rapid recall
- Justify a boundary by the change it isolates; collapse boundaries whose mappers are field-for-field copies.
- Clean Architecture's real win is a pure-Kotlin domain with millisecond tests, not database swappability.
- Introduce a use case for logic, multiple sources, or reuse — not per repository method.
- MVI buys traceability and costs boilerplate; use it where transitions are complex, not everywhere.
- State survives rotation; events happen once. Per-section state beats one global loading flag.
- Flow for observable data, suspend for commands; configure sharing or pay N calls for N collectors.
- Features must not depend on features; route contracts plus assembly in
:app. - Multi-surface: share domain, diverge navigation; drive layout from window size classes.
Part VIII
Modularization at Scale
Module structure is an organisational instrument as much as a technical one. Every decision here is simultaneously a build-time decision, an ownership decision, and a decision about who can break whom.
Chapter 55Module taxonomy and dependency rules
| Module type | May depend on | Owns |
|---|---|---|
:app | Everything | Assembly, DI root, navigation wiring. Contains almost no logic. |
:feature:* | :core:* only — never another feature | Screens, state holders, feature-local navigation. |
:core:domain-* | Nothing Android | Entities, rules, repository interfaces. Pure Kotlin, JVM tests. |
:core:data-* | Its domain module, :core:network, :core:database | Repository implementations, DTOs, mappers, caching policy. |
:core:designsystem | Nothing app-specific | Theme, components. Reviewed like a public API. |
:core:*-api / :core:*-impl | API depends on nothing; impl depends on api | The seam that keeps the graph shallow. |
:test-fixtures | The module it fixtures | Fakes and builders shared by consumers' tests. |
// core/data-cart/build.gradle.kts
dependencies {
api(project(":core:network")) // leaks Retrofit to every consumer
api(project(":core:database")) // leaks Room entities too
api(libs.okhttp)
}
Every consumer now compiles against Retrofit and Room. An OkHttp bump recompiles the world, and a feature engineer can accidentally call the network directly from a composable.
dependencies {
// Only types that appear in this module's public signatures:
api(project(":core:domain-cart"))
implementation(project(":core:network"))
implementation(project(":core:database"))
implementation(libs.okhttp)
}
api only for types in your public API surface. This both shrinks the compile classpath — a large build-time win — and makes the illegal call a compile error rather than a review comment.
Chapter 56Breaking cycles: feature-to-feature navigation
The recurring problem: :feature:cart must open a product detail owned by :feature:catalog, and :feature:catalog must open the cart. A direct dependency in both directions is a cycle Gradle refuses.
flowchart TD
subgraph BAD["cycle — Gradle refuses"]
C1[":feature:cart"] --> C2[":feature:catalog"]
C2 --> C1
end
subgraph GOOD["contract owned by nobody's team"]
A[":app — the only module that knows both"] --> D1[":feature:cart"]
A --> D2[":feature:catalog"]
D1 --> NAV[":core:navigation-api
sealed Dest, interface Navigator"]
D2 --> NAV
end
// Everyone depends on :core:shared, which grows to // hold ProductScreen, CartScreen, the analytics client, // the date formatter and 400 other things. :feature:cart -> :core:shared :feature:catalog -> :core:shared
The cycle is technically gone and the coupling is worse: :core:shared is now a single point that every team edits and every build recompiles. This is the most common failure of a first modularization attempt.
// :core:navigation-api (depends on nothing)
sealed interface Dest {
data class Product(val id: String) : Dest
data object Cart : Dest
}
interface Navigator { fun go(dest: Dest) }
// :feature:cart — depends only on the api
class CartViewModel(private val nav: Navigator) {
fun onLineClick(id: String) = nav.go(Dest.Product(id))
}
// :app — the only module that knows both features
navController.graph {
composable<Dest.Product> { ProductRoute(...) }
composable<Dest.Cart> { CartRoute(...) }
}
Features depend on an abstraction owned by nobody's team, and :app — which already depends on everything — does the wiring. The graph stays a DAG and stays shallow.
Chapter 57Build performance and graph shape
Two graphs with the same module count can differ by minutes per build. What matters:
- Depth beats width. A deep chain serialises: nothing downstream compiles until upstream finishes. A wide, shallow graph parallelises across cores. Prefer many small leaf modules over a five-level pyramid.
- ABI stability decides incremental cost. Changing a function body recompiles one module; changing a public signature recompiles every dependent. This is why
implementationand api/impl splits pay off — they shrink the set of modules that can be invalidated. - Annotation processing is the usual bottleneck. Migrating kapt → KSP is typically the single largest available win in a Dagger/Room codebase.
- Measure before restructuring. A build scan tells you which tasks dominate; intuition is reliably wrong. Restructuring a graph on a hunch is weeks of work for a possible regression.
# 1. Where does the time actually go? ./gradlew assembleDebug --scan # task timeline, cache hits/misses # 2. Is the configuration phase the problem? (large graphs: often yes) ./gradlew help --scan # config time with no work # 3. What does a one-line change actually recompile? touch core/domain-cart/src/.../Cart.kt ./gradlew assembleDebug --scan # count invalidated modules # 4. Turn on the cheap wins before restructuring anything: org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.parallel=true
Chapter 58Convention plugins and version catalogs
// repeated in every module, drifting over time
android {
compileSdk = 35
defaultConfig { minSdk = 24 }
compileOptions { /* ... */ }
}
kotlin { jvmToolchain(17) }
dependencies {
implementation("androidx.core:core-ktx:1.13.1") // version drift
}
Upgrading the compile SDK becomes a 180-file pull request, and three modules will be missed. Version strings drift, producing duplicate transitive versions and confusing resolution failures.
// build-logic/.../AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.android")
configureKotlinAndroid()
dependencies {
add("implementation", project(":core:designsystem"))
add("implementation", libs.findLibrary("hilt").get())
}
}
}
// feature/cart/build.gradle.kts — the whole file
plugins { id("myapp.android.feature") }
One place to change the toolchain. New modules are correct by construction, which is what makes a paved road real rather than documented.
Chapter 59Exercise — a travel app, 100+ engineers
Design exercise · 45 minutes
"Design the module architecture for a travel app: flights, hotels, cars, packages, loyalty, payments. 100+ engineers across 12 teams. Current state: one 400k-line module, 25-minute builds."
Requirements to establish first
Before drawing anything, a Staff candidate pins down: how are teams organised (by vertical or by layer)? Is there a shared booking flow, or four independent ones? Is the backend one API or per-vertical? What is the release cadence, and does every team ship on the same train? These change the answer materially — if teams are organised by layer rather than vertical, module-per-vertical will fight the org chart and lose.
Target structure
:app platform team — assembly only :vertical:flights:feature-search flights team :vertical:flights:feature-booking flights team :vertical:flights:domain flights team (pure Kotlin) :vertical:flights:data flights team :vertical:hotels:* hotels team :vertical:cars:* cars team :shared:booking-flow-api platform (contract only) :shared:payments-api / -impl payments team :shared:loyalty-api / -impl loyalty team :shared:identity-api / -impl identity team :core:designsystem design-systems team, API-reviewed :core:network :core:database platform team :core:navigation-api platform team :test:fixtures-* per owning team
The five rules that make it hold
- No vertical depends on another vertical. Packages (a flight + hotel bundle) is itself a vertical that depends on the shared contracts, not on the flights and hotels features.
- Shared capabilities are api/impl pairs. Verticals compile against
payments-api; only:appseespayments-impl. A payments refactor then recompiles one module rather than twelve. - One owner per module, encoded in CODEOWNERS. A module with no owner becomes everyone's dumping ground within two quarters.
- Boundaries enforced in build logic, failing the build on an illegal dependency — not in a wiki.
- The booking flow is a contract, not a shared implementation. Each vertical implements the steps; the shared module defines the state machine and the events. This is the decision that prevents a shared 40k-line "booking" module.
Migration sequence — no big bang
- Extract leaves first: design system, network, database. Zero behaviour change, immediately parallelisable, and it proves the tooling.
- Extract one vertical end to end — pick the team most willing, not the largest domain. Publish their build-time numbers.
- Add the enforcement once one vertical is clean, so new code cannot recreate the monolith while the rest migrates.
- Migrate the remainder team by team, each owning their own extraction with the platform team providing the template and codemods.
- Delete the monolith module — and treat its deletion as the completion criterion, because a half-migrated codebase carries both costs.
Success metrics, agreed before starting
- Median incremental build after a one-line feature change: 25 min → under 3 min.
- Percentage of PRs touching more than one team's modules: target under 10%.
- Time to first commit for a new hire.
- CI wall-clock for a feature-only change (should require only affected-module tests).
Follow-up questions to expect
- "What if two verticals need the same UI component?" — It goes into the design system with an API review, or it is duplicated deliberately. Duplication between verticals is cheaper than a shared module owned by nobody; say this explicitly, because interviewers expect DRY reflexes and reward the nuance.
- "How do you handle a shared entity like
User?" — One identity domain module owning the canonical model; verticals map it into their own view if they need extra fields. Do not let verticals add fields to the shared model. - "What about dynamic feature modules?" — Only if install size is a demonstrated conversion problem. They add substantial complexity to DI, navigation and testing; the default answer is no, with data.
- "How long?" — Give a real shape: leaves in a quarter, first vertical in a quarter, the rest over three to four quarters at roughly one vertical per team per quarter alongside feature work. A candidate who says "six weeks" has not done it.
Part VIII rapid recall
implementationby default;apionly for types in your public signatures.- Cycles are broken with contracts assembled at
:app, never with a:core:sharedcatch-all. - Shallow and wide parallelises; deep serialises. ABI changes, not body changes, drive recompilation.
- kapt → KSP is usually the largest single build win available.
- Convention plugins plus version catalogs; module correctness by construction.
- One owner per module in CODEOWNERS; enforcement in build logic, not documentation.
- Deliberate duplication between verticals beats an unowned shared module.
Part IX
Networking
From TCP behaviour on a flaky cellular link to the client/server contract negotiation a Staff engineer is expected to lead. Mobile networking is a latency and failure problem, not a bandwidth problem.
Chapter 60The transport stack for mobile engineers
The facts that change client design:
- Connection setup dominates. On LTE, DNS + TCP handshake + TLS can cost 200–600 ms before a byte of your payload moves. On a cold connection, five sequential requests cost five round-trip penalties — which is why request count matters far more than payload size on mobile.
- Radio state is a battery cost. The cellular radio ramps to a high-power state for a transfer and stays there for a tail period (seconds) afterwards. Ten small requests spread over a minute cost far more battery than one batched request, even with identical bytes.
- HTTP/2 multiplexes over one connection, removing head-of-line blocking at the HTTP layer but not at TCP. HTTP/3 (QUIC) removes it at the transport layer too and survives network changes via connection IDs — genuinely valuable when a user walks from Wi-Fi to cellular.
- TLS 1.3 cuts the handshake to one round trip, and session resumption to zero. Enabling it is usually a server-side change with a measurable client-side latency win.
A useful framing for design interviews: on a typical mobile connection, assume 100–300 ms RTT, 5–15% of requests failing or timing out on cellular during movement, and a 2–5 second cost for a full cold connection setup on a poor link. Designs that assume the desktop-web reality (fast, reliable, always-on) fail on all of these.
Chapter 61REST, GraphQL, gRPC
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Payload fit | Fixed per endpoint; over-fetching is normal | Client selects fields — the strongest mobile argument | Fixed per method, but compact binary |
| Round trips | Often several per screen | One per screen, by design | Several, but cheap; streaming built in |
| Caching | HTTP caching works out of the box | No HTTP caching (POST); needs a normalised client cache | No HTTP caching; roll your own |
| Schema evolution | Versioning by convention; easy to break clients | Strongly typed, deprecation built in | Strong; field numbers make it safe |
| Tooling/debuggability | Excellent; readable in any proxy | Good, but queries are opaque in logs | Binary — needs tooling to inspect |
| Client cost | Lowest | Normalised cache is a real subsystem | Codegen plus a larger binary |
The Staff answer is per-surface, not per-company: GraphQL where a screen aggregates many resources and the field set varies by client version; gRPC for high-frequency streaming (location, telemetry) where binary size and bidirectional streams pay; REST for everything else, because its caching and debuggability are free. And name the hidden cost: a GraphQL client without a normalised cache re-fetches everything, and with one you have taken on cache-consistency bugs.
Chapter 62OkHttp and Retrofit
Interceptor placement is the detail interviewers use to check whether you have really operated this stack.
| Type | Sees | Use for |
|---|---|---|
| Application interceptor | One call, the final response; not invoked for cache hits; sees redirects as one call | Auth headers, analytics, request tagging |
| Network interceptor | Every wire request including redirects and retries; sees the raw response | Compression, cache-header rewriting, wire-level logging |
Authenticator | Only 401 responses | Token refresh with automatic replay of the original request |
EventListener | DNS, connect, TLS, request/response timings | Real per-phase latency metrics — the right source for "is it DNS or the server?" |
Chapter 63Auth, token refresh, and single-flight coordination
The most-asked networking scenario at Staff level: a token expires while five requests are in flight — design a solution that produces one refresh, not five. It is asked because the naive implementation is both obvious and wrong, and because the failure mode (users randomly logged out) is one most candidates have actually seen without diagnosing.
sequenceDiagram participant A as Request A participant B as Requests B–E participant Au as Authenticator (Mutex) participant S as Auth server A->>Au: 401, stale = T0 B->>Au: 401, stale = T0 (queued on the mutex) Au->>S: refresh(T0) — one call only S-->>Au: T1 Au-->>A: retry with T1 Note over Au,B: B–E enter the lock, see stored != T0 Au-->>B: retry with T1, no second refresh
class AuthInterceptor(...) : Interceptor {
override fun intercept(chain: Chain): Response {
var res = chain.proceed(withToken(chain.request()))
if (res.code == 401) {
val new = runBlocking { api.refresh() } // no coordination
res.close()
res = chain.proceed(withToken(chain.request(), new))
}
return res
}
}
Every concurrent 401 refreshes independently: with rotating refresh tokens, the first succeeds and the rest invalidate each other, logging the user out. Also no retry cap — a permanently-401ing endpoint loops.
class TokenAuthenticator(
private val store: TokenStore,
private val api: RefreshApi,
) : Authenticator {
private val mutex = Mutex()
override fun authenticate(route: Route?, response: Response): Request? {
if (response.priorResponseCount() >= 2) return null // give up
val stale = response.request.bearer()
val fresh = runBlocking {
mutex.withLock {
store.access()
?.takeIf { it != stale } // someone already refreshed
?: runCatching { api.refresh(store.refresh()) }
.onSuccess { store.save(it) }
.map { it.access }
.getOrElse { store.clear(); null }
}
} ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $fresh").build()
}
}
OkHttp calls Authenticator only on 401 and replays the original request for you. The stale-token comparison collapses N concurrent refreshes into one; the prior-response cap prevents infinite retry.
Chapter 64Resilience: retries, backoff, idempotency
| Failure | Retry? | Why |
|---|---|---|
| Connection failure, DNS, timeout on connect | Yes | Request almost certainly never reached the server. |
| Read timeout after the request was sent | Only if idempotent | The server may have processed it. This is the dangerous case. |
| 500 / 502 / 503 / 504 | Yes, with backoff and jitter | Transient server-side. Honour Retry-After when present. |
| 429 | Yes, respecting Retry-After | Retrying immediately makes the rate-limit worse. |
| 400 / 401 / 403 / 404 / 422 | No | Deterministic. Retrying wastes battery and hides the bug. |
suspend fun <T> retrying(
attempts: Int = 4,
baseMs: Long = 300,
maxMs: Long = 8_000,
isRetryable: (Throwable) -> Boolean,
block: suspend () -> T,
): T {
var last: Throwable? = null
repeat(attempts) { i ->
try { return block() } catch (e: CancellationException) {
throw e // never swallow
} catch (e: Throwable) {
if (!isRetryable(e) || i == attempts - 1) throw e
last = e
val ceiling = min(maxMs, baseMs shl i) // 300, 600, 1200, 2400
delay(Random.nextLong(0, ceiling)) // FULL jitter
}
}
throw last!!
}Why jitter matters at scale: a backend blip fails a million clients simultaneously. Without jitter they all retry at exactly t+300 ms, then t+900 ms — a synchronised thundering herd that keeps the backend down. Full jitter spreads the retries uniformly. This is the difference between a 30-second blip and a 20-minute outage, and it is a favourite Staff follow-up.
Interview question · the one that separates payments experience
"A payment request times out. You don't know whether the server processed it. What do you do?"
Short answer
Never blind-retry. The client generates an idempotency key before the first attempt and reuses it for every retry, so a duplicate request is recognised server-side and returns the original result.
Deep explanation L4
"This is the unknown-outcome problem, and it cannot be solved on the client alone — it is a contract with the backend, so the first thing I'd do in a design review is establish that contract.
The client generates a UUID at the moment the user commits, not per attempt, and persists it with the pending operation so it survives process death. Every retry — including one after an app restart — sends the same Idempotency-Key. The server stores key → result for a defined window and returns the original response for repeats.
What if we never get a response at all? Then we do not guess. The client shows the payment as pending, not failed, and reconciles: poll the order status by the idempotency key, or wait for a push. Showing 'payment failed' when it may have succeeded is worse than showing 'processing' — the user retries and, if the key were per-attempt, pays twice.
Things I'd want defined explicitly: how long the server retains keys (if it is 24 hours and our offline queue can be older, we have a correctness gap); whether the key is scoped per user; and what the server returns for a key replayed with a different body, which should be a hard error rather than a silent overwrite.
For observability, I'd emit a metric for 'requests completed with unknown outcome' — it is invisible in normal crash and error dashboards, and it is exactly the number that tells you this system is healthy."
Follow-up questions
- "Where is the key stored?" — In the local database alongside the pending operation, written before the network call. In memory is not sufficient; process death is the case you are defending against.
- "What if the user force-quits and reopens?" — The pending operation is still in the queue with its key; the sync worker resumes and either learns the result or retries safely.
- "How would you test it?" — A fake server that accepts the request and then drops the response, asserting the client retries with the same key and does not create a second order; plus a process-death test around the persisted queue.
- "Does this apply outside payments?" — Any non-idempotent write: posting a message, submitting a form, placing a booking. Payments is where the cost of getting it wrong is visible, not where the problem is unique.
Chapter 65Caching and conditional requests
class ProductRepo(private val api: Api) {
private val memo = mutableMapOf<String, Product>() // never invalidated
suspend fun get(id: String): Product =
memo.getOrPut(id) { api.product(id) }
}
Two caches with different lifetimes and no coordination. The user pulls to refresh, OkHttp revalidates, and this map still returns yesterday's price. Also unbounded — a leak on a long session.
// OkHttp handles conditional requests automatically when
// the server sends ETag / Last-Modified.
OkHttpClient.Builder()
.cache(Cache(File(ctx.cacheDir, "http"), 20L * 1024 * 1024))
.build()
// The app's source of truth is the database; the network
// updates it, and the UI observes only the database.
fun product(id: String): Flow<Product> = dao.observe(id)
suspend fun refresh(id: String) {
val dto = api.product(id) // 304 → served from HTTP cache, cheap
dao.upsert(dto.toEntity())
}
One place holds truth. HTTP caching handles revalidation bandwidth; the database handles offline and consistency. The two layers have distinct jobs instead of competing.
Connectivity is not a boolean
A frequent design flaw is gating requests on isConnected. Real states include: no interface; connected to Wi-Fi with no internet (captive portal); connected but metered; connected with high latency; and mid-handover between Wi-Fi and cellular where sockets die but the network is "available". The right posture is attempt and handle failure, using connectivity signals only to schedule retries and to inform the UI — never as a precondition. NetworkCapabilities.NET_CAPABILITY_VALIDATED is the closest thing to "the internet actually works," and even that lags reality.
Part IX rapid recall
- Latency and round-trip count dominate mobile networking; radio tail time makes chatty clients expensive in battery.
- Application interceptors miss cache hits; network interceptors see every wire attempt;
Authenticatoronly sees 401s and replays for you. - Refresh must be single-flight, compared against the stale token, with a retry cap.
- Retry connection failures freely; retry post-send timeouts only with an idempotency key; never retry 4xx.
- Full jitter prevents the synchronised retry herd that turns a blip into an outage.
- Idempotency keys are generated at user commit, persisted before the call, reused across process death.
- One source of truth: database for state, HTTP cache for revalidation bandwidth.
- Connectivity is not a boolean; attempt and handle failure rather than gating.
Part X
Storage & Caching
SQLite behaviour underneath Room, and cache design treated as an architectural decision rather than a utility class.
Chapter 66SQLite mechanics that matter
- WAL mode (Room's default) lets readers proceed during a write. Without it, a write blocks every reader — a classic source of jank when a sync worker writes while a list scrolls. There is still only one writer at a time.
- Transactions are the unit of durability and speed. 1,000 individual inserts mean 1,000 fsyncs; the same inserts in one transaction mean one. This is routinely a 50–100× difference and is the correct answer to "the initial sync takes 40 seconds."
- Index or scan. Without an index, a
WHEREis a full table scan. With 200 rows nobody notices; with 200,000 on a low-end device it is seconds. - Room's observable queries invalidate per table, so a write to any row of a watched table re-runs the query. Watching a hot table with an unbounded query is a self-inflicted CPU load.
suspend fun saveAll(items: List<Item>) {
items.forEach { dao.insert(it) } // 5,000 transactions
}
@Query("SELECT * FROM items WHERE category = :c ORDER BY updated DESC")
fun byCategory(c: String): Flow<List<Item>> // full scan + sort, unbounded
Initial sync takes tens of seconds and the query scans the table on every write to it. Both are invisible on a small dev dataset.
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<Item>) // one transaction
@Entity(indices = [Index(value = ["category", "updated"])])
data class ItemEntity(...)
@Query("SELECT * FROM items WHERE category = :c " +
"ORDER BY updated DESC LIMIT :limit")
fun byCategory(c: String, limit: Int = 100): Flow<List<Item>>
The composite index covers both the filter and the sort, so SQLite avoids a temporary b-tree. Verify with EXPLAIN QUERY PLAN — "SCAN TABLE" means no index was used, "SEARCH TABLE … USING INDEX" means it was.
Chapter 67Schema design and migrations
Migrations are permanent public API: users skip releases, so version 3 must be able to reach version 12. Every shipped version is a path you own forever.
// 1. Commit the exported schema JSON — it is the contract.
room { schemaDirectory("$projectDir/schemas") }
// 2. Test EVERY adjacent pair, plus a full-chain run.
@Test fun migrate_8_to_9() {
helper.createDatabase(TEST_DB, 8).apply {
execSQL("INSERT INTO messages VALUES ('m1','hello',1700000000)")
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 9, true, MIGRATION_8_9)
db.query("SELECT read FROM messages WHERE id='m1'").use {
it.moveToFirst()
assertEquals(0, it.getInt(0)) // default applied
}
}
@Test fun migrate_all_the_way() =
helper.runMigrationsAndValidate(TEST_DB, LATEST, true, *ALL_MIGRATIONS)Only for a pure cache with no user-authored data and no pending write queue, and even then guard it: if the database also holds an offline outbox, destructive migration silently deletes writes the user believes were saved. A useful rule to state in an interview: destructive migration is allowed only in modules that contain no data the user could not regenerate by pulling to refresh.
Chapter 68Cache architecture
"Should we cache?" is the wrong question. The real questions are: what is the source of truth, what staleness is acceptable, and how does a stale entry get corrected? A cache without an answer to the third is a bug generator.
| Invalidation | Mechanism | Fits / fails |
|---|---|---|
| TTL | Entry expires after N seconds | Simple and predictable. Fails when data changes unpredictably — the user sees stale content for the full TTL and cannot fix it. |
| Write-through | Every local write updates the cache | Correct for data this client owns. Cannot know about other clients' writes. |
| Event-driven | Push/socket invalidates keys | Freshest, and the right answer for multi-device. Requires backend support and a fallback for missed events. |
| Version/ETag | Revalidate cheaply, 304 if unchanged | Saves bandwidth, not round trips. Good pairing with a longer TTL. |
flowchart LR
UI["UI"] -- observes --> DB[("Room — source of truth")]
API["Network"] -- writes --> DB
HTTP["OkHttp cache"] -. "revalidation bytes only" .- API
MEM["Bounded memory cache"] -- derived from --> DB
UI -. "never reads" .-x API
The rule that makes this coherent: the network never returns data to the UI. It writes to the store, and the UI observes the store. Each tier then has one job — truth, bandwidth, speed — instead of three tiers competing to answer the same question.
// Memory map in the repository (never bounded, never invalidated) // + OkHttp disk cache (its own TTL from server headers) // + Room (written on some paths, not others) // // Result: pull-to-refresh updates two of the three, // and which value the UI shows depends on which // screen the user opened first.
Three sources of truth means an unreproducible class of "wrong data" bugs that only appear in specific navigation orders.
// Truth: Room (survives process death, offline-capable)
// Bandwidth: OkHttp cache (revalidation only)
// Speed: in-memory, bounded, derived from Room
class ProductRepository(
private val dao: ProductDao,
private val api: ProductApi,
) {
// UI observes only this.
fun product(id: String): Flow<Product> =
dao.observe(id).map { it.toDomain() }
suspend fun refresh(id: String) {
dao.upsert(api.product(id).toEntity()) // network writes to truth
}
}
The rule that makes this work: the network never returns data to the UI directly. It writes to the store, and the UI observes the store. Every consistency bug in the "avoid" column disappears structurally.
Chapter 69Media, files, and bitmap memory
Bitmap arithmetic is a favourite quick interview probe because it is exact: a bitmap costs width × height × bytes-per-pixel. In ARGB_8888 that is 4 bytes per pixel, so a 4000×3000 photo is 48 MB decoded — several times a typical app's entire heap budget, from one image.
val bmp = BitmapFactory.decodeFile(path) // 48 MB for a 12MP photo imageView.setImageBitmap(bmp) AsyncImage(model = url) // unbounded in a list
A handful of these produce OutOfMemoryError on mid-tier devices, and the crash lands on whatever allocation happens to be next — so the stack trace usually blames innocent code.
// Coil/Glide size the request to the target automatically,
// but be explicit in lists where the target is known:
AsyncImage(
model = ImageRequest.Builder(ctx)
.data(url)
.size(320, 180) // decode dimensions
.build(),
modifier = Modifier.size(160.dp, 90.dp),
)
// Manual decode: two passes.
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, opts)
opts.inSampleSize = calculateInSampleSize(opts, 320, 180)
opts.inJustDecodeBounds = false
val bmp = BitmapFactory.decodeFile(path, opts) // ~0.2 MB
Also consider RGB_565 (half the memory) for opaque thumbnails, and HARDWARE bitmaps where you never read pixels back — they live outside the Java heap.
Disk budget as a designed constraint
An app that caches media without a quota eventually gets uninstalled for "using 4 GB". The Staff-level position: give every disk cache an explicit budget, an eviction policy (LRU by access time), and a response to onTrimMemory/low-storage broadcasts. Then measure the cache-hit rate — if a 500 MB cache and a 100 MB cache have the same hit rate, you are spending the user's storage for nothing, and that is an argument you can take to a product review with data.
Interview question · synthesis
"Design the storage layer for an app that must work fully offline for a week."
Requirements to pin first
What data must be available offline versus merely nice to have? Can the user write offline, and if so which operations? How much storage may we consume? What is the acceptable staleness for each data class? Is the data sensitive enough to require encryption at rest? Answers change the design more than any technology choice.
Model answer L4
"Room as the single source of truth, with three data classes handled differently.
- Reference data (catalogue, config): synced on a schedule, TTL-based, safe to evict and re-fetch. Bounded by a row budget with LRU eviction so a week offline does not grow unboundedly.
- User data (their orders, documents): never evicted while the user is signed in; this is the data whose absence makes the app useless offline.
- Pending writes (the outbox): a durable queue table with an idempotency key, attempt count, and a terminal-failure state. Never destructively migrated, never cleared by a cache purge — the two mistakes that cause silent data loss.
Schema-wise, the outbox is the interesting part: (id, op_type, payload, idempotency_key, created_at, attempts, last_error, state). State is a real enum — pending, in-flight, failed-retryable, failed-permanent — because a permanently-failed write needs to surface to the user rather than retry forever.
For media, a separate file cache with its own quota, keyed by content hash, with the database holding only paths — you do not want a 200 MB blob inside SQLite.
Failure modes I'd design for explicitly: database corruption (detect on open, recreate reference data, but never silently discard the outbox — surface it); storage full (writes fail; the outbox must degrade gracefully and tell the user, not crash); and logout with a non-empty outbox, which needs a product decision — block logout, warn, or discard. Nobody thinks of that one until it happens.
On encryption: if the data is sensitive, SQLCipher with a Keystore-held key, accepting a measurable performance cost — roughly 5–15% on queries — and the operational reality that a lost key means unrecoverable data. That is a decision to make deliberately, not by default."
Follow-up questions
- "How do you know a week of offline data fits?" — Measure per-user row and byte growth in production telemetry, extrapolate at p95, and set the quota from that with headroom. Guessing is how you discover it on low-storage devices.
- "What happens when the outbox and server disagree after a week?" — This is Part XVII: conflict resolution, per-field merge policy, and surfacing genuine conflicts to the user rather than silently picking a winner.
- "How do you test a week offline?" — A fake clock plus a seeded database at realistic volume, run in CI; plus a manual soak on a real device with airplane mode. Volume matters — every offline bug I have seen appears only at scale.
Part X rapid recall
- WAL allows concurrent readers with one writer; batch inserts in a single transaction for 50–100× speedups.
- Verify indexes with
EXPLAIN QUERY PLAN; "SCAN TABLE" means you have none. - Room observable queries invalidate per table — bound them with
LIMITand indexes. - Every shipped schema version needs a tested path forward, forever.
- Destructive migration is only defensible where no user-authored data or outbox exists.
- One source of truth; the network writes to the store, the UI observes the store.
- Bitmap cost = w × h × 4 bytes; always decode to display size.
- Every disk cache needs a quota, an eviction policy, and a measured hit rate.
Part XI
Performance Engineering
Built around one repeatable protocol — hypothesis, metric, tool, diagnosis, fix, verification, prevention — applied to the regressions interviewers actually describe.
Chapter 70A performance methodology
The reflex that marks a Senior answer is naming a fix ("add a baseline profile"). The reflex that marks a Staff answer is naming a measurement first. The seven-step protocol used throughout this part:
- Quantify. Which metric, at which percentile, on which devices, changed by how much and when. "The app is slow" is not actionable; "p90 TTID on Android 13, mid-tier, up 1.4 s since 8.4.0" is.
- Hypothesise. Three candidate causes, ranked, each with a prediction that would distinguish it.
- Instrument or trace. Perfetto/system trace on a physical mid-tier device, release build, in the failing condition.
- Diagnose. Identify the specific frames or spans that account for the regression. Confirm it explains the whole delta, not a plausible slice.
- Fix. Smallest change that addresses the cause.
- Verify. Same measurement, same conditions, plus a field check after rollout — lab wins that do not reproduce in the field are common.
- Prevent. A CI benchmark or a Vitals alert so the regression cannot silently return. Without this you will do the whole investigation again next year.
Measure on the 25th-percentile device in your install base, not on a current flagship. Most performance bugs are simply invisible on the newest hardware, which is why they reach production: they were tested on the phone in the engineer's pocket.
| Metric | Field source | Lab source |
|---|---|---|
| TTID / TTFD | Play Vitals, Firebase Performance | Macrobenchmark StartupTimingMetric |
| Frame timing / jank | Vitals frame metrics, JankStats | Macrobenchmark FrameTimingMetric, Perfetto |
| ANR rate | Vitals ANR clusters (with traces) | StrictMode, main-thread tracing |
| Memory / OOM | Crashlytics OOM signals, Vitals | Studio Memory Profiler, LeakCanary, heap dumps |
| Battery | Vitals wakelock/wakeup excess | Battery Historian, dumpsys batterystats |
| App size | Play console size report | APK Analyzer, size diff in CI |
Chapter 71App startup
flowchart LR Z["zygote fork"] --> AC["Application.onCreate
tier 1 only"] AC --> ACT["Activity onCreate"] ACT --> M["measure / layout / draw"] M --> TTID["TTID — first frame"] TTID --> DATA["data arrives"] DATA --> TTFD["TTFD — reportFullyDrawn()"] AC -. "tier 2: appScope, off the critical path" .-> BG["Analytics, experiments"] ACT -. "tier 3: by lazy, on first use" .-> LZ["Ads, maps, video"]
Cold = process creation + Application + first Activity + first frame. Warm = process alive, activity recreated. Hot = activity resumed. Only cold start is worth optimising as a programme; it correlates with install-day retention and is the one users notice.
Two metrics: TTID (time to initial display — first frame drawn) and TTFD (time to full display — content actually usable). Optimising TTID alone produces the cheat of drawing an empty skeleton fast; report both, and call reportFullyDrawn() so TTFD is measurable.
class App : Application() {
override fun onCreate() {
super.onCreate()
Analytics.init(this) // 120 ms
CrashReporter.init(this) // 40 ms
AdsSdk.initialize(this) // 300 ms, network on main
ExperimentSdk.fetchSync() // 250 ms, blocking network!
Timber.plant(DebugTree())
preloadEverything()
}
}
// Every SDK team adds "just one more init".
A blocking network call in Application.onCreate is a startup ANR waiting for a bad network. And nobody owns the cumulative total, so it grows by 50 ms a quarter forever.
class App : Application() {
override fun onCreate() {
super.onCreate()
// Tier 1: required before the first frame. Keep tiny.
CrashReporter.init(this) // must catch startup crashes
// Tier 2: needed soon, not now.
appScope.launch(Dispatchers.Default) {
Analytics.init(this@App)
ExperimentSdk.warm() // async; use cached values meanwhile
}
}
}
// Tier 3: on first use.
val ads by lazy { AdsSdk.create(context) }
// Enforced in CI:
@Test fun startup_budget() = benchmarkRule.measureRepeated(
metrics = listOf(StartupTimingMetric()),
startupMode = StartupMode.COLD, iterations = 15,
) { pressHome(); startActivityAndWait() }
// fails the build if p50 TTID regresses > 10%
Three tiers, an owner for the total, and a CI gate. The gate is the part that makes it stick — otherwise the budget is re-spent within two quarters.
Baseline profiles
Without a baseline profile, code runs interpreted until the JIT warms up — so the first launch and first scroll, the impressions that matter most, are the slowest the app will ever be. A baseline profile ships AOT-compiled hot paths in the APK, typically improving cold start by 15–30% and largely eliminating first-scroll jank. Generate them from a Macrobenchmark journey that covers startup and the primary scroll, and regenerate when the hot paths change — a stale profile silently stops helping.
Chapter 72Rendering and jank
A frame must complete within the refresh budget: 16.7 ms at 60 Hz, 8.3 ms at 120 Hz. Note that a high-refresh-rate device makes previously-fine code janky — a common cause of "it only stutters on the newer phones."
| Symptom in a trace | Likely cause | Fix |
|---|---|---|
Long Choreographer#doFrame, time in your code | Main-thread work: parsing, mapping, formatting, disk | Move off main; precompute in the state mapper |
| Long measure/layout spans | Deep nesting, intrinsics, nested scrollables, subcomposition | Flatten; avoid intrinsic measurement in hot lists |
| Long draw, high GPU | Overdraw, shadows, blur, large unclipped layers | Reduce layers, graphicsLayer caching, remove backgrounds |
| Regular GC pauses | Per-frame allocation | Hoist allocations out of draw/item bodies |
| Binder transaction spans | IPC on main — ContentResolver, PackageManager | Cache the result; move the call off main |
| First-scroll-only jank | No baseline profile / JIT warm-up | Baseline profile covering the scroll journey |
Chapter 73ANRs
An ANR fires when the main thread cannot service an event in time: ~5 s for input dispatch, ~20 s for a foreground service start, ~10 s for a broadcast. The trap is that the stack trace shows where the main thread was when the timer expired, not what caused the delay — so the top frame is frequently innocent.
| ANR pattern | What the trace shows | Real cause |
|---|---|---|
| Lock contention | Main blocked in lock/monitor | A background thread holds the lock while doing I/O. Look at the other threads in the trace. |
| Binder wait | Main in BinderProxy.transactNative | system_server or another app's provider is slow; often device- or OEM-specific. |
| Disk I/O | Main in SharedPreferences/file read | apply() queue drained at onStop, or a first-read of a large prefs file. |
| Broadcast overrun | Main in onReceive | Work done inline in a receiver instead of handed to WorkManager. |
| Deadlock | Two threads each waiting | Ordering bug; frequently a runBlocking on main awaiting work that needs main. |
Production scenario
"ANRs jumped 30% after the last release. Walk me through it."
Investigation
"First, quantify and segment before touching code. In Play Vitals I'd check: is it one ANR cluster or a broad rise? Which Android versions, OEMs, device tiers? Did it start exactly at the rollout, or track the rollout percentage — which would confirm causation rather than coincidence? Is a specific screen or entry point over-represented?
Segmentation usually collapses the hypothesis space immediately. A 30% rise concentrated on one OEM and one Android version is almost never our own code path; a rise spread evenly across devices that tracks the rollout curve is.
Hypotheses, ranked
- New synchronous work added to a startup or resume path — the most common cause of a step change at release.
- A lock now held across I/O because a call became blocking (or a library upgraded and changed its threading).
- An SDK upgrade doing work on the main thread — frequently an ads, analytics or attribution SDK.
- A broadcast receiver doing more work, or being registered where it previously was not.
- Not us: an OS or Play Services rollout coinciding with ours. Checking whether the previous app version's ANR rate also rose over the same window distinguishes this in one query.
Diagnosis
Read the actual ANR traces, and read all the threads, not just main. If main is blocked on a monitor, find the thread holding it and see what it is doing — that is where the bug is. Then reproduce locally with StrictMode in penalty-death mode on the suspected path, and take a system trace on a mid-tier device.
Fix, verification, prevention
Fix the specific blocking call. Verify by checking the ANR rate for the patched version at equivalent rollout percentage, not overall — mixing versions hides the signal. Prevention has three layers: StrictMode with penalty-death in debug and internal builds, so main-thread I/O fails loudly during development; a Macrobenchmark on the affected journey in CI; and an ANR-rate alert with a rollout halt criterion, so the next occurrence stops at 5% of users rather than 100%.
The Staff addition
"I'd also ask why our release process let a 30% ANR regression reach full rollout. If we had a staged-rollout halt criterion on ANR rate, this would have stopped at 5%. Fixing the ANR is the ticket; fixing the release gate is the actual outcome."
Follow-up questions
- "What if you cannot reproduce it?" — Ship targeted tracing behind a flag to the affected cohort, and use the Vitals trace clustering rather than trying to reproduce a device-specific timing bug locally.
- "What's an acceptable ANR rate?" — Play's bad-behaviour threshold is 0.47% of daily sessions, and exceeding it affects store visibility. Internally, most mature apps target well under 0.2%.
- "How do you catch ANRs that Vitals under-reports?" — Vitals only covers Play-installed users with reporting enabled; supplement with a watchdog that samples main-thread responsiveness and reports long stalls with a stack.
Chapter 74Battery and background cost
- Wakelocks held past their need are the classic drain. Any wakelock should be timeout-bounded and released in a
finally. - Wakeups cost more than the work: waking the radio and CPU every minute for a 20 ms sync is far worse than one batched sync every 30 minutes. Batch, and let WorkManager coalesce.
- Location is the largest single drain available to an app. Match accuracy and interval to the actual product need, use passive/balanced modes where possible, and stop when the screen is off unless the product genuinely requires tracking.
- Attribution: Battery Historian on a dogfood build and Vitals' excessive-wakeup metrics in the field. Users cannot tell you which subsystem drained their battery, but the OS can.
// Every 60 s, forever, whether or not anything changed
PeriodicWorkRequestBuilder<SyncWorker>(15, MINUTES)
.build()
.also { wm.enqueue(it) } // duplicated on every launch
wakeLock.acquire() // no timeout
doSync() // may throw
wakeLock.release() // never reached on failure
Unbatched wakeups plus a leaked wakelock on the error path. This is the shape of "battery consumption doubled after the release."
// Server tells us when there is something to sync.
// Periodic work is a safety net, not the mechanism.
wm.enqueueUniquePeriodicWork(
"sync-safety-net",
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<SyncWorker>(6, HOURS)
.setConstraints(Constraints(
requiredNetworkType = NetworkType.UNMETERED,
requiresBatteryNotLow = true,
)).build()
)
wakeLock.acquire(30_000) // hard timeout
try { doSync() } finally { if (wakeLock.isHeld) wakeLock.release() }
Push as the trigger, periodic work as the fallback, constraints so it runs when it is cheap, and a wakelock that cannot outlive a crash.
Chapter 75App size and delivery
Install size measurably affects install conversion, particularly in markets with expensive data — which makes it a business metric, not a hygiene metric. Where the bytes go, and what actually works:
- R8 in full mode with resource shrinking. Verify it is genuinely on in release — a stray
-dontoptimizeor an over-broad keep rule from an SDK's consumer rules commonly disables most of the benefit. - App Bundle splits by density, ABI and language: typically the single largest win, and free.
- Native libraries are usually the biggest line item in a media app. Check whether you ship four ABIs when the bundle already splits them.
- Images: WebP/AVIF over PNG, vector drawables where the asset allows.
- Dynamic feature delivery only when a large, rarely-used feature is demonstrably costing conversion — it complicates DI, navigation and testing considerably.
- Gate it in CI: report the size delta on every pull request. Size regresses one library at a time, and nobody notices a 400 KB dependency until the total is 90 MB.
Chapter 76Running a performance programme
A Staff engineer is not asked to fix one screen; they are asked why the app keeps getting slower. The answer is always the same: performance has no owner and no budget, so it loses every trade-off individually while losing badly in aggregate.
- Define SLIs and targets per tier. "p90 cold start under 1.5 s on mid-tier devices" — not one number for all hardware.
- Give each team a budget for the surfaces they own, visible on a dashboard they see weekly.
- Gate in CI with Macrobenchmarks on the top journeys, so regressions are caught before rollout rather than in Vitals.
- Halt criteria on staged rollout: a startup or ANR regression stops the rollout automatically.
- A standing budget for fixes — commonly 10–20% of capacity. Without it, the ratchet only turns one way.
- Report in business terms. "Cold start down 600 ms; day-1 retention up 0.4%" funds the next quarter of work. "We reduced recompositions" does not.
Part XI rapid recall
- Measure before hypothesising; segment by version, OEM, device tier and rollout percentage.
- TTID and TTFD together;
reportFullyDrawn()or you are optimising a skeleton. - Three-tier startup init with an owner and a CI budget; no blocking network in
Application.onCreate. - Baseline profiles cover startup and the primary scroll; regenerate when hot paths change.
- ANR traces: read every thread, not just main — the cause is usually the lock holder.
- Batch wakeups, bound wakelocks with timeouts and
finally, push-trigger rather than poll. - Size: full-mode R8, bundle splits, native libs, CI size diff on every PR.
- A programme needs SLIs, per-team budgets, CI gates, rollout halt criteria, and standing capacity.
Part XII
Memory Management
Leak topology, GC behaviour, and hands-on diagnosis. Memory bugs are the ones that reach production most reliably, because they need time and real usage to become visible.
Chapter 77The runtime memory model
- ART uses a concurrent, mostly-generational collector. Short-lived objects are cheap; long-lived ones that survive several collections are promoted and become expensive to collect. Allocation itself is cheap — the cost is the collection pressure it creates.
- Your heap is capped per app (
ActivityManager.memoryClass, commonly 128–512 MB depending on device). Exceeding it isOutOfMemoryError, regardless of how much RAM the device has. - Native memory is separate. Hardware bitmaps, native libraries, and NDK allocations do not count toward the Java heap but do count toward the process's overall footprint — so a process can be killed by the low-memory killer with a nearly-empty Java heap.
onTrimMemoryis the OS asking you to release caches before it kills you. Apps that ignore it get killed sooner, which shows up as a worse warm-start rate.- GC pauses cause jank when they land inside a frame. Per-frame allocation in a scrolling list is the usual source.
Chapter 78Leak topology
flowchart LR GC(["GC root"]) --> ST["static / object / companion"] GC --> TH["live thread, Handler queue"] GC --> SVC["service, app-scoped coroutine"] ST --> LST["listener list in a singleton"] LST --> VM2["ViewModel or Fragment"] TH --> RUN["delayed Runnable"] RUN --> V["View"] V --> ACT["Activity"] ACT --> TREE["whole view hierarchy — megabytes"] VM2 --> ACT
Read it right to left: anything still reachable from a GC root cannot be collected. Every fix cuts exactly one edge — unregister the listener, cancel the Runnable, null the binding — and none of them involve nulling the Activity.
Every leak is the same shape: a long-lived object holds a reference to a short-lived one. Learning the six common paths lets you spot them in review rather than in a heap dump.
| Path | Mechanism | Structural fix |
|---|---|---|
| Static holds Context | object / companion field storing an Activity | Application context, injected |
| Unregistered listener | A singleton's callback list holds the Fragment | DisposableEffect / mirrored lifecycle callback |
| Inner class / lambda | Non-static inner class or a lambda capturing this | Pass only what is needed; make it top-level |
| Handler / Runnable | Delayed message referencing the View | removeCallbacks in the teardown callback |
| Coroutine in the wrong scope | App-scoped coroutine capturing a ViewModel | Correct scope; deliver results to a store, not a UI object |
| Fragment view lifecycle | Binding held past onDestroyView | Null the binding, or use viewLifecycleOwner |
class DetailFragment : Fragment() {
private lateinit var binding: FragmentDetailBinding
private val handler = Handler(Looper.getMainLooper())
override fun onViewCreated(v: View, s: Bundle?) {
// 1. Singleton keeps a strong ref to this fragment forever
LocationManagerSingleton.addListener(this)
// 2. Delayed runnable holds the view for 30 s past destruction
handler.postDelayed({ binding.banner.isVisible = true }, 30_000)
// 3. Binding survives onDestroyView → leaks the whole view tree
}
}
All three survive navigation. Rotate ten times and you retain ten view hierarchies — this is how an app "gets slower the longer you use it."
class DetailFragment : Fragment(R.layout.fragment_detail) {
private var binding: FragmentDetailBinding? = null
override fun onViewCreated(v: View, s: Bundle?) {
binding = FragmentDetailBinding.bind(v)
viewLifecycleOwner.lifecycle.addObserver(
LocationObserver(locationManager) // self-unregistering
)
viewLifecycleOwner.lifecycleScope.launch {
delay(30_000)
binding?.banner?.isVisible = true // cancelled on destroy
}
}
override fun onDestroyView() {
binding = null
super.onDestroyView()
}
}
A coroutine on viewLifecycleOwner.lifecycleScope replaces the Handler and is cancelled automatically. In Compose the equivalent is DisposableEffect with a real onDispose.
Chapter 79Diagnosing with heap dumps
1. Reproduce: navigate in and out of the suspect screen 10×,
with a manual GC between (Profiler → trash icon).
2. Dump: Studio Memory Profiler → capture heap dump,
or adb shell am dumpheap <pid> /data/local/tmp/h.hprof
3. Filter: group by class, sort by retained size, filter to
your package. Look for N instances of an Activity
where N should be 1.
4. Path: inspect "references to this" → find the shortest
strong path from a GC root. That path IS the bug.
5. Confirm: fix, repeat step 1–3, assert the count is 1.Retained vs shallow size is the distinction interviewers check. Shallow size is the object's own fields; retained size is everything that would be freed if it went away. An Activity has a small shallow size and an enormous retained size — which is why one leaked Activity matters and one leaked String does not.
LeakCanary in CI: beyond the debug-build toast, its detection can run in instrumented tests and fail the build on a new leak. That converts leaks from "someone notices eventually" to a gate — the Staff-level move.
A cache holding 200 bitmaps is retention by design. The question is whether it is bounded and whether it responds to memory pressure. Calling a deliberate bounded cache a leak in an interview is a small but real signal that the candidate is pattern-matching rather than reasoning.
Chapter 80Exercises — five leaks to find
Exercise 1
Find the leak
class AnalyticsBus {
companion object {
private val listeners = mutableListOf<(Event) -> Unit>()
fun subscribe(l: (Event) -> Unit) { listeners += l }
fun publish(e: Event) { listeners.forEach { it(e) } }
}
}
class CartViewModel(private val repo: CartRepo) : ViewModel() {
init {
AnalyticsBus.subscribe { event ->
if (event is PriceChanged) refresh() // captures `this`
}
}
}Answer
The lambda captures the ViewModel; the companion's list is a GC root for the process lifetime. Every screen visit adds a permanently-retained ViewModel — and each one still reacts to events, so refresh() fires N times after N visits, producing duplicate network calls as well as a leak. Fix: return a subscription handle and dispose it in onCleared(), or better, replace the bus with a SharedFlow collected in viewModelScope, which is cancelled automatically.
Exercise 2
Find the leak
@Composable
fun LocationBanner(manager: LocationManager) {
var location by remember { mutableStateOf<Location?>(null) }
LaunchedEffect(Unit) {
manager.registerListener { location = it }
}
location?.let { Text("Near ${it.name}") }
}Answer
LaunchedEffect cancels its coroutine on leaving composition, but registration is not a coroutine — nothing unregisters the listener, and the callback captures the composition's state object. Fix: DisposableEffect(manager) { val l = manager.registerListener {...}; onDispose { manager.unregister(l) } }. The general rule: LaunchedEffect for suspending work, DisposableEffect for anything with a register/unregister pair.
Exercise 3
Find the leak — and the second bug
@Singleton
class ImageLoader @Inject constructor() {
private val cache = mutableMapOf<String, Bitmap>()
fun load(url: String, into: ImageView) {
cache[url]?.let { into.setImageBitmap(it); return }
thread {
val bmp = decode(url)
cache[url] = bmp
into.post { into.setImageBitmap(bmp) }
}
}
}Answer
Three defects. Unbounded cache: a map of full-size bitmaps in a singleton grows until OutOfMemoryError — no eviction, no memory-pressure response. View capture: the background thread holds the ImageView, and therefore the Activity, until decode finishes; on a slow network that outlives the screen. Concurrency: a plain mutableMapOf written from arbitrary threads can corrupt or lose entries. Fix: use LruCache sized from memoryClass, decode to the target size, hold the view weakly (or better, use Coil, which solves all three and cancels on detach).
Exercise 4
Why does memory grow over hours with no leaked Activities?
class ChatViewModel(socket: ChatSocket) : ViewModel() {
private val _messages = MutableStateFlow<List<Message>>(emptyList())
val messages = _messages.asStateFlow()
init {
viewModelScope.launch {
socket.incoming.collect { msg ->
_messages.update { it + msg } // grows forever
}
}
}
}Answer
Not a leak — unbounded growth, which LeakCanary will never report. A busy channel accumulates every message for the session, each copy also allocating a new list (O(n²) allocation over the session, which is its own performance problem). Fix: keep a bounded window in memory and page older messages from the database; use a persistent list so appends do not copy. This is the pattern behind "memory usage increases gradually over several hours" in Part XIX.
Exercise 5
Find the leak
class MainActivity : ComponentActivity() {
private val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context, i: Intent) { refresh() }
}
override fun onCreate(s: Bundle?) {
super.onCreate(s)
applicationContext.registerReceiver(receiver, filter)
}
override fun onDestroy() {
super.onDestroy()
// unregister forgotten
}
}Answer
The receiver is an anonymous inner class capturing the Activity, registered against the application context — so it lives for the process lifetime and retains every Activity instance ever created. Registering with the Activity's own context would at least tie it to the Activity, and Android would log the "leaked receiver" warning at destruction. Fix: register in onStart, unregister in onStop, or use a lifecycle-aware wrapper — and prefer a Flow built with callbackFlow so cancellation handles it structurally.
Part XII rapid recall
- Every leak is a long-lived object referencing a short-lived one; learn the six paths and spot them in review.
- Retained size, not shallow size, is what matters — one Activity retains its whole view tree.
- Native and hardware-bitmap memory is outside the Java heap but inside the kill decision.
LaunchedEffectfor suspending work;DisposableEffectfor register/unregister pairs.- Unbounded growth is not a leak and LeakCanary will not find it — bound every in-memory collection.
- Bounded caches with memory-pressure response are design, not defects.
- Run LeakCanary in instrumented CI so a new leak fails the build.
Part XIII
Testing Strategy
Testing as an economic decision: confidence purchased per unit of authoring time, maintenance and flake. At Staff level the question is never "did you write tests" but "what is your strategy and what did it cost".
Chapter 81What to test, and what not to
Every test has a price: authoring, runtime on every CI job, and — the largest — maintenance when the code changes. A test is worth it when expected defect cost prevented exceeds that. This produces some unpopular conclusions worth stating plainly in an interview:
| Test | Verdict | Why |
|---|---|---|
| Domain rules, mappers, reducers | Always | Cheap, fast, high defect density, stable interfaces. |
| State holder / ViewModel behaviour | Always | Concurrency and state transitions are where real bugs live. |
| Repository with fakes | Usually | Cache/network interaction and error mapping. |
| Room migrations | Always | The failure mode is data loss, not a bug. |
| A composable's exact layout | Rarely | High churn, low defect density. Screenshot tests cover it better if it matters. |
Getters, data class copy, DI wiring | No | Tests the compiler and the framework, not your logic. |
| Mocked interaction verification | Mostly no | Asserts implementation, not behaviour; breaks on every refactor. |
| Full E2E of every flow | No | Slow and flaky. Reserve for a handful of revenue-critical journeys. |
Coverage is a diagnostic, not a target. A team told to hit 80% writes tests for getters. The useful version is coverage of changed lines in a PR, reviewed as information rather than enforced as a gate — and even then, treat a drop as a question, not a failure.
Chapter 82Test doubles: why fakes beat mocks at scale
@Test fun loads_user() = runTest {
val repo = mockk<UserRepository>()
coEvery { repo.getUser("1") } returns user
coEvery { repo.getPrefs("1") } returns prefs
coEvery { repo.trackView(any()) } just Runs
val vm = ProfileViewModel(repo)
vm.load("1")
coVerify(exactly = 1) { repo.getUser("1") } // implementation detail
coVerify { repo.trackView(any()) }
}
Every refactor — caching the result, batching the two calls — breaks the test without any behaviour changing. Multiply by 4,000 tests and refactoring becomes economically impossible, which is how codebases ossify.
// Shipped from the module's test-fixtures, reused by every consumer.
class FakeUserRepository : UserRepository {
private val users = MutableStateFlow(mapOf<String, User>())
var failNext: Throwable? = null
fun seed(u: User) { users.update { it + (u.id to u) } }
override fun observe(id: String) = users.map { it[id] }
override suspend fun getUser(id: String): User {
failNext?.let { failNext = null; throw it }
return users.value.getValue(id)
}
}
@Test fun shows_user_then_error_on_refresh_failure() = runTest {
val repo = FakeUserRepository().apply { seed(user) }
val vm = ProfileViewModel(repo)
vm.state.test {
assertEquals(Loading, awaitItem())
assertEquals(Ready(user), awaitItem())
repo.failNext = IOException()
vm.refresh()
assertEquals(Failed(retryable = true), awaitItem())
}
}
The fake survives refactors, is reused by every team consuming the module, and makes the test read as a description of behaviour. The maintenance saving is the whole argument.
When mocks are right: verifying that a side-effecting collaborator you do not own was called (an analytics SDK, a payment gateway), or standing in for an interface with too many methods to fake in a one-off test. The rule: fake the things you own and reuse; mock the things you neither own nor reuse.
Chapter 83Testing coroutines and Flow
class SearchViewModel(repo: Repo) : ViewModel() {
fun search(q: String) {
viewModelScope.launch(Dispatchers.IO) { ... } // not injectable
}
}
@Test fun searches() = runBlocking {
vm.search("shoes")
delay(500) // real sleep; flaky and slow
assertEquals(2, vm.state.value.results.size)
}
A hardcoded dispatcher cannot be replaced, so the test races. delay in a test is a bet on machine speed: it passes locally and fails on a loaded CI agent, which is the single largest source of flake in Android codebases.
class SearchViewModel(
private val repo: Repo,
private val io: CoroutineDispatcher,
) : ViewModel()
class MainDispatcherRule(
private val d: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(d0: Description) = Dispatchers.setMain(d)
override fun finished(d0: Description) = Dispatchers.resetMain()
}
@get:Rule val dispatcherRule = MainDispatcherRule()
@Test fun debounces_and_cancels_previous() = runTest {
val vm = SearchViewModel(repo, StandardTestDispatcher(testScheduler))
vm.state.test {
assertEquals(Idle, awaitItem())
vm.onQuery("sh"); advanceTimeBy(100)
vm.onQuery("sho"); advanceTimeBy(100)
vm.onQuery("shoe")
advanceTimeBy(300) // virtual: instant
assertEquals(Loading, awaitItem())
assertEquals(Results(shoeResults), awaitItem())
assertEquals(1, repo.callCount) // debounce actually worked
}
}
Virtual time makes a 300 ms debounce test run in microseconds and deterministically. The callCount assertion is what proves the debounce, not just that results eventually arrived.
The StateFlow/Turbine gotcha worth knowing: a StateFlow created with WhileSubscribed does not start until collected, so a test that reads .value without collecting sees only the initial value and appears broken. Inside .test { } the collection exists, which is one reason Turbine is the standard tool rather than a convenience.
Chapter 84UI, screenshot, and end-to-end tests
flowchart TD E2E["End-to-end · minutes · highest flake
a handful of revenue-critical journeys"] UIT["UI + screenshot · seconds · on device
screen states across theme, locale, font scale"] INT["Integration · tens of ms · Robolectric
repository + DAO + mappers, with fakes"] UNIT["Unit · milliseconds · JVM
domain rules, reducers, state holders"] E2E --- UIT --- INT --- UNIT
The ordering is economic, not moral: value is confidence bought divided by authoring plus maintenance plus flake, and that ratio collapses as you climb. Which is why the top tier is a few journeys rather than a suite.
| Layer | Runtime | Buys / costs |
|---|---|---|
| Unit (JVM) | ms | Logic correctness. Cannot catch integration or rendering bugs. |
| Robolectric | tens of ms | Framework interaction without a device. Some behaviours differ from real Android. |
| Compose UI test | seconds, on device | Real semantics and interaction. Needs an emulator in CI. |
| Screenshot | seconds | Visual regressions across themes, locales, font scales — cheaply, if the harness is deterministic. |
| E2E | minutes | Real confidence in a journey. Highest flake and maintenance; keep the count small and the journeys revenue-critical. |
Screenshot testing is the highest-leverage addition for a large app because it covers the combinatorial surface — light/dark, RTL, large font scale, small screen — that nobody tests manually. Its failure mode is nondeterminism: pin the font, disable animation and blinking cursors, use a fixed clock, and render off-device where possible. A flaky screenshot suite gets muted within a month, and then you are paying for nothing.
Chapter 85Contract testing the mobile/backend seam
The most expensive mobile bugs frequently originate in a backend change that no mobile test could catch: a field becomes nullable, an enum gains a value, a list becomes paginated. Both sides' tests pass; production breaks.
// 1. Parse real recorded payloads, not hand-written happy-path JSON.
@Test fun parses_production_sample() {
val dto = json.decodeFromString<OrderDto>(readResource("order_prod_sample.json"))
assertNotNull(dto.toDomain())
}
// 2. Assert the client tolerates the changes the server is allowed to make.
@Test fun unknown_enum_value_does_not_crash() {
val dto = json.decodeFromString<OrderDto>("""{"status":"QUANTUM_PENDING"}""")
assertEquals(OrderStatus.Unknown, dto.toDomain().status)
}
// 3. Schema check in CI: fail the mobile build when the published
// OpenAPI/GraphQL schema removes a field the client still reads.
// This is the one that actually prevents the incident.The Staff framing: this is an organisational contract, not a test file. Somebody must own the rule "the mobile client's supported schema range is checked in the backend's CI" — because mobile cannot force-update installed clients, so the backend is always the party that must remain compatible.
Chapter 86Strategy for a large organisation
Interview question · Staff-level, frequently the whole round
"Our test suite takes 90 minutes and 15% of runs fail for unrelated reasons. Engineers have stopped trusting it. What do you do?"
Why interviewers ask this
It is the most common real state of a large Android codebase, and the answer reveals whether the candidate thinks in terms of test counts or test economics.
Answer L4
"A 15% unrelated failure rate is worse than having no tests, because it trains engineers to re-run until green — which means real failures get re-run too. So flake is the first problem, not duration.
Immediate (week one): instrument the suite. Record every test's pass/fail history per commit; a test that fails and then passes on the same commit is flaky by definition. That gives a ranked list, and typically 20 tests cause 80% of the flake.
Quarantine policy: a test identified as flaky is moved out of the blocking suite within 24 hours and assigned to its owning team with a deadline. Not deleted — quarantined, visible on a dashboard, with an expiry after which it is deleted. The critical part is that quarantine is automatic and cheap, because a process requiring a meeting will not be used.
Root causes, in the order I'd expect them: real-time delays instead of virtual time; shared state between tests, especially a singleton or a real database; tests depending on execution order; animations and emulator timing in UI tests; and genuine product race conditions — which are the valuable ones, because a flaky test there is telling you the truth.
Duration: tier the suite. Pre-merge runs unit tests plus the tests affected by the changed modules — with a proper module graph that is usually a few minutes. Post-merge runs everything. Nightly runs E2E and screenshot suites. Add test sharding and remote caching. Most of the 90 minutes is usually unaffected modules being re-run.
Measuring whether it worked: not test count or coverage. Median PR wall-clock time, flake rate, and — the outcome that justifies it — change failure rate and escaped-defect count. If tests are not reducing escaped defects, the suite is a cost centre and I would say so.
The organisational half: ownership. Every test file needs an owning team via CODEOWNERS, otherwise flaky tests belong to nobody and the quarantine list grows forever. And I'd publish the flake dashboard where engineers already look, because visibility does more than policy."
Follow-up questions
- "What if a team refuses to fix their quarantined tests?" — The expiry does the work: after the deadline the test is deleted and the coverage loss is recorded. That converts an argument into a visible, owned trade-off.
- "How do you decide what to run pre-merge?" — Module-graph-based test impact analysis: changed module plus its dependents. It requires the modularization from Part VIII, which is a good moment to note that these investments compound.
- "Is 100% pre-merge ever right?" — For a small codebase with a fast suite, yes — simplicity wins. The tiering only pays above roughly 15–20 minutes.
- "How do you measure test quality?" — Mutation testing on the domain layer gives a real signal where it is affordable; escaped-defect analysis (was there a test that should have caught this?) is cheaper and works everywhere.
Part XIII rapid recall
- Test economics: authoring + runtime + maintenance versus defect cost prevented.
- Fake what you own and reuse; mock what you neither own nor reuse. Verify behaviour, not calls.
- Inject dispatchers; use
runTestvirtual time; neverdelayin a test to wait for something. - Turbine for flows; a
WhileSubscribedStateFlow does not start until collected. - Screenshot tests cover the theme/locale/font-scale combinatorics — but only if deterministic.
- Contract checks belong in the backend's CI, because mobile cannot force-update clients.
- Flake is worse than absence: automatic quarantine, owner, expiry, dashboard.
- Measure PR wall-clock, flake rate and change failure rate — not coverage.
Part XIV
Build Systems, CI/CD & Release
Build and release treated as a product with its own users — your engineers — and its own metrics. This is the part of Staff work that is invisible until it is terrible.
Chapter 87Gradle mechanics
Gradle runs in phases, and knowing which phase your cost is in is most of the diagnosis:
- Initialisation — settings, which projects exist.
- Configuration — every build script runs, every task is created. On a 300-module project this can be 30+ seconds before any work, and it happens on every invocation unless the configuration cache is on.
- Execution — tasks run, subject to up-to-date checks and the build cache.
// Runs on EVERY build, including `./gradlew tasks`
val gitSha = "git rev-parse HEAD".execute().text.trim()
val buildTime = System.currentTimeMillis()
android {
defaultConfig {
buildConfigField("String", "GIT_SHA", "\"$gitSha\"")
buildConfigField("long", "BUILD_TIME", "${buildTime}L")
}
}
A process fork per module per build, and buildTime changes every invocation — so BuildConfig is never up to date and everything downstream recompiles. It also breaks the configuration cache.
val gitSha: Provider<String> = providers.exec {
commandLine("git", "rev-parse", "--short", "HEAD")
}.standardOutput.asText.map { it.trim() } // evaluated on demand, cached
android {
defaultConfig {
buildConfigField("String", "GIT_SHA", "\"${gitSha.get()}\"")
// No timestamp: it would defeat every cache. If you need one,
// inject it at packaging time only for release builds.
}
}
Provider APIs defer the work and participate in the configuration cache. Removing volatile inputs is what makes up-to-date checks and the build cache work at all.
| Feature | What it saves | Common blocker |
|---|---|---|
| Configuration cache | The entire configuration phase on repeat builds | Scripts capturing Project at execution time; older plugins |
| Build cache (local) | Re-running tasks whose inputs are unchanged | Non-deterministic task inputs — timestamps, absolute paths |
| Remote build cache | CI results reused on developer machines | Environment differences making cache keys diverge |
| Parallel execution | Wall time across independent modules | A deep dependency chain, which serialises regardless |
| KSP over kapt | Often 20–40% of annotation-processing time | A library that only ships a kapt processor |
Chapter 88Variants and build logic
Variants multiply: 3 flavours × 2 build types = 6 variants, each with its own compile, resource-merge and lint task per module. Teams routinely create dimension combinations nobody builds, and pay for them in configuration time and IDE sync.
androidComponents {
beforeVariants { variant ->
// Only `internal` needs the debuggable+staging combination.
variant.enable = !(variant.flavorName == "demo" &&
variant.buildType == "release")
}
}The Staff-level point: every variant is a build-time and test-matrix cost paid by every engineer on every sync. A dimension needs a named owner and a reason, or it should not exist.
Chapter 89Cutting build times across an organisation
Interview question · a very common Staff round
"Builds take 18 minutes. 120 engineers. What do you do?"
Frame it in money first
"120 engineers, maybe 8 builds a day each, 18 minutes — that is roughly 290 engineer-hours a day of waiting, though not all of it is idle. Even attributing a third of it as genuinely lost, that is on the order of 10–15 full-time engineers' worth of capacity. That framing is what funds the work, and it is the first thing I'd put in the proposal."
Measure before changing anything
"Build scans across the team — not my machine. I want the distribution, not the average: which task types dominate, what the cache hit rate is, how much is configuration versus execution, and crucially which build is the 18 minutes. A clean build being slow matters far less than an incremental one-line change being slow, because that is what engineers do fifty times a day."
Sequence, cheapest first
- Configuration cache, build cache, parallel, adequate JVM heap. Days of work, often 30–50%.
- kapt → KSP wherever the library supports it. Frequently the single largest execution-phase win in a Dagger/Room codebase.
- Remote build cache seeded by CI, so a developer syncing main downloads results rather than compiling them.
- Fix the graph:
api→implementation, break the chains that serialise, split the modules everything depends on. Weeks of work, high value, but only after measurement says the graph is the problem. - Cut variants and unnecessary annotation processing.
- Hardware — genuinely the cheapest intervention per minute saved, and frequently blocked by a procurement policy rather than by engineering.
Make it stick
"Regression is the default. I'd publish a build-time dashboard (Gradle Enterprise or an equivalent), alert on p50 incremental build time, and add a CI check on configuration time. Otherwise the 18 minutes returns within three quarters, one dependency at a time — and the second time you propose the work, nobody believes it."
Follow-up questions
- "What if the remote cache has a low hit rate?" — Usually non-deterministic inputs: absolute paths, timestamps, environment variables leaking into task inputs. Gradle Enterprise's cache-miss analysis identifies the specific input that diverged.
- "How do you avoid a six-month platform project nobody sees?" — Ship in increments with published numbers after each, and pick the first increment for visibility rather than magnitude.
- "What's the metric you'd report to leadership?" — Median incremental build time and CI wall-clock per PR, converted to engineer-hours a week. Never "we enabled the configuration cache."
Chapter 90Pipelines and static analysis
flowchart LR L["Local pre-commit
seconds — format only"] --> PR["Pre-merge
<10 min — compile, lint,
affected-module tests, size diff"] PR --> PM["Post-merge
<30 min — full unit suite,
smoke tests, benchmarks"] PM --> N["Nightly
hours — E2E, screenshots,
device matrix, dep audit"] N --> REL["Release
signed bundle, mapping upload,
baseline profile, staged rollout"]
The budgets are the design. Anything slower than about ten minutes pre-merge gets routed around — engineers batch changes into larger pull requests, which are harder to review, which costs more quality than the extra checks bought.
| Stage | Budget | Contains |
|---|---|---|
| Pre-commit (local) | seconds | Formatting only. Anything slower gets bypassed with --no-verify. |
| PR / pre-merge | under 10 min | Compile, lint/Detekt, unit tests for affected modules, size and API diff. |
| Post-merge | under 30 min | Full unit suite, instrumented smoke tests, benchmark checks. |
| Nightly | hours | E2E, screenshot suite, full device matrix, dependency and licence audit. |
| Release | — | Signed bundle, mapping upload, baseline profile, staged rollout. |
Static analysis as architecture enforcement
The highest-value custom rules are the ones encoding decisions from other parts of this book — this is how an architectural agreement becomes real rather than aspirational:
ban GlobalScope → Part III ban Dispatchers.IO referenced directly → inject the dispatcher ban feature-to-feature imports → Part VIII boundary rule ban android.* imports in :core:domain-* → keeps the domain pure warn Composable with > 6 parameters → hoisting smell ban runBlocking outside tests/main() ban PendingIntent without FLAG_IMMUTABLE→ Part IV / XV ban Gson reflective adapters → Part II null-safety hole
Signal-to-noise is the whole game. A ruleset with 400 warnings is a ruleset everyone ignores. Introduce rules at error severity with a baseline file for existing violations, so new code is clean and legacy debt is visible but non-blocking.
Chapter 91Release engineering
The defining constraint of mobile release: you cannot recall a binary. A bad server deploy is reverted in minutes; a bad app release is on users' devices until they update, and some never will. Every release practice follows from this.
1. Merge everything ready on Friday 2. Build, upload, 100% rollout 3. Watch Crashlytics over the weekend 4. If it's bad: hotfix, 2-day review anxiety, staged panic
No blast-radius control, no halt criteria, and the recovery path costs days. Any serious defect reaches the full user base before anyone is awake.
1. Release train: branch cuts on a fixed schedule.
Missing the train is cheap; blocking it is expensive.
2. Internal → closed → 1% → 5% → 20% → 50% → 100%,
with a soak at each step.
3. Automated halt criteria evaluated per step:
crash-free sessions < baseline − 0.1% → halt
ANR rate > baseline + 20% → halt
p90 cold start > baseline + 15% → halt
checkout conversion < baseline − 2% → halt
4. Every risky feature behind a remote flag, so
mitigation does not require a new binary.
5. Hotfix path rehearsed: cherry-pick, expedited
review, targeted rollout. Practised, not improvised.
The kill switch is the most important line. It converts "we need an emergency release" into "we turned it off," which is minutes instead of days.
"How do you roll back a mobile release?" is a trick question, and the L4 answer starts by saying so. You cannot un-install a version from users' devices. Play's halt-rollout stops further distribution but does not touch existing installs; publishing the previous version under a higher version code is possible but disruptive and does not help users who already updated. The real answers are, in order: a server-side kill switch or remote config; a server-side compatibility fix; and only then a forward hotfix. Designing for this in advance — feature flags on anything risky, and server tolerance for old clients — is the actual competence being assessed.
Part XIV rapid recall
- Know which phase your cost is in; configuration work runs on every invocation.
- Volatile inputs (timestamps, absolute paths) silently disable caching.
- Enable configuration cache, build cache, parallel, KSP before restructuring anything.
- Frame build time in engineer-hours; publish a dashboard or it regresses.
- Tier pipelines by budget; pre-merge under 10 minutes or engineers route around it.
- Encode architecture rules in lint at error severity with a baseline for legacy.
- Release trains, staged rollout, automated halt criteria, feature flags on anything risky.
- There is no rollback — only kill switches, server-side fixes, and forward hotfixes.
Part XV
Security
Threat modelling first, mitigations second. The organising principle for the whole part: obfuscation ≠ encryption ≠ security, and the client is hostile territory.
Chapter 92Threat modelling a mobile client
The foundational premise, and the sentence that most improves a security answer: the client runs on hardware the attacker controls. Everything shipped in the APK is readable; everything computed on-device can be altered; every network call can be observed and replayed. Security decisions follow from what that does and does not mean.
| Attacker | Capability | What actually defends against it |
|---|---|---|
| Casual, on-device | Reads shared storage, logs, backups | Keystore-backed encryption, no secrets in logs, correct backup rules |
| Malicious app, same device | Exported components, intent redirection, clipboard, accessibility abuse | Non-exported components, permission-protected receivers, immutable PendingIntents |
| Network attacker | MITM proxy, DNS spoofing, replay | TLS with correct validation, pinning where justified, idempotency and nonces |
| Device owner (rooted) | Full memory read, hooking, patched APK, disabled pinning | Essentially nothing client-side — this is why enforcement must be server-side |
| Reverse engineer | Decompiles the APK, extracts strings and logic | Nothing durable. Obfuscation raises effort; it does not prevent |
Obfuscation renames symbols: it raises the cost of understanding, and R8 does it as a side effect of shrinking. Encryption makes data unreadable without a key — but a key shipped in your APK is not a secret, so "encrypted with a hardcoded key" is obfuscation with extra steps. Security is a property of the whole system, and on mobile it comes overwhelmingly from the server enforcing authorisation. Candidates who conflate these get marked down hard on any security round.
Chapter 93Keystore, crypto, and secure storage
The Android Keystore stores keys in hardware (TEE, or StrongBox on supported devices) so the key material never enters your process. You ask the Keystore to perform operations; you never hold the key. That is the entire security value, and it is why "we encrypt with a key in SharedPreferences" is not equivalent.
object Crypto {
private const val KEY = "MySuperSecretKey123!" // in the APK
fun encrypt(s: String): String =
Base64.encodeToString(xor(s.toByteArray(), KEY.toByteArray()), 0)
}
// Also common and also wrong:
prefs.edit().putString("auth_token", token).apply() // plaintext
Log.d(TAG, "Login ok, token=$token") // in logcat, in bug reports
The key is extractable with strings on the APK. The token in logs reaches crash reports, support bundles and any app with log access on older devices.
private fun getOrCreateKey(): SecretKey =
(KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.secretKey
?: KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
.apply {
init(KeyGenParameterSpec.Builder(ALIAS, PURPOSE_ENCRYPT or PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true) // biometric gate
.setInvalidatedByBiometricEnrollment(true) // new fingerprint ⇒ key dies
.setIsStrongBoxBacked(hasStrongBox)
.build())
}.generateKey()
// GCM: never reuse an IV with the same key. Store the IV with the ciphertext.
fun encrypt(plain: ByteArray): Pair<ByteArray, ByteArray> {
val c = Cipher.getInstance("AES/GCM/NoPadding")
c.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
return c.iv to c.doFinal(plain)
}
setInvalidatedByBiometricEnrollment is the line most implementations miss: without it, an attacker who can add a fingerprint gains access to the key. And IV reuse under GCM is a catastrophic, silent break.
Chapter 94Identity: OAuth2, tokens, biometrics
sequenceDiagram participant App participant CT as Custom Tab participant IdP as Identity provider participant API App->>App: generate code_verifier, derive challenge App->>CT: authorize?code_challenge=…&S256 CT->>IdP: user authenticates (App never sees credentials) IdP-->>App: redirect with authorization code App->>IdP: token(code, code_verifier) — no client secret IdP-->>App: short access token + rotating refresh token App->>App: store refresh token under a Keystore key App->>API: Bearer access token Note over App,IdP: refresh reuse detected ⇒ IdP revokes the whole family
The correct mobile flow is Authorization Code with PKCE, in a Custom Tab — not an embedded WebView. The reasons matter in interviews: an embedded WebView means your app can read the user's credentials (so an identity provider cannot trust you, and increasingly blocks it), it does not share the system's session, and users cannot verify the URL bar to detect phishing.
| Decision | Right answer | Reasoning |
|---|---|---|
| Client secret in the app | None — mobile is a public client | It is extractable; PKCE replaces its role. |
| Token storage | Keystore-encrypted, not plain prefs | Backups, rooted devices, other apps on old versions. |
| Access token lifetime | Short — minutes to an hour | Limits the window if it leaks. |
| Refresh token | Rotating, with reuse detection | A replayed old refresh token signals theft; the server revokes the family. |
| Logout | Server-side revocation, not just local deletion | Deleting locally leaves a valid token in an attacker's hands. |
| Biometrics | A gate on a Keystore key, not a boolean | A if (authenticated) check is trivially patched out; a key that will not decrypt is not. |
biometricPrompt.authenticate(promptInfo)
// onAuthenticationSucceeded:
override fun onAuthenticationSucceeded(r: AuthenticationResult) {
unlockApp() // patch this branch out: done
showBalance(cachedBalance)
}
The check and the protected data are independent. Patching the APK, or hooking the callback with Frida, bypasses it entirely because nothing cryptographic depends on the result.
val cipher = Cipher.getInstance(TRANSFORM).apply {
init(Cipher.DECRYPT_MODE, keystoreKey, GCMParameterSpec(128, iv))
}
biometricPrompt.authenticate(promptInfo, CryptoObject(cipher))
override fun onAuthenticationSucceeded(r: AuthenticationResult) {
// The cipher is only usable BECAUSE the user authenticated.
val token = r.cryptoObject!!.cipher!!.doFinal(encryptedToken)
session.restore(token)
}
Now the data is inaccessible without a successful authentication, enforced by hardware rather than by a branch in your code.
Chapter 95Transport and platform hardening
Certificate pinning — and its operational risk
Pinning defends against a compromised or user-installed CA. It also creates a way to brick your app remotely: if the pinned certificate is rotated and clients have not updated, every request fails and no app-side fix is possible. The Staff position is that pinning is a decision with an operational cost, and the answer must include: pin to an intermediate or use multiple pins including a backup key; set an expiry; have a remote-config kill switch for the pinning itself; and monitor pin-failure rates so a botched rotation is visible within minutes rather than after the app-store reviews.
// "Fixing" a staging certificate error
val trustAll = object : X509TrustManager {
override fun checkServerTrusted(c: Array<X509Certificate>, a: String) {}
override fun checkClientTrusted(c: Array<X509Certificate>, a: String) {}
override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
}
builder.hostnameVerifier { _, _ -> true }
This disables TLS entirely. It is written for staging and shipped to production more often than anyone admits — Google Play actively flags it. Every request becomes trivially interceptable.
<!-- res/xml/network_security_config.xml (debug flavour only) -->
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">staging.example.com</domain>
<trust-anchors>
<certificates src="@raw/staging_ca"/> <!-- specific, not "all" -->
</trust-anchors>
</domain-config>
</network-security-config>
<!-- release: pins with a backup and an expiry -->
<pin-set expiration="2027-01-01">
<pin digest="SHA-256">base64PrimaryKeyHash=</pin>
<pin digest="SHA-256">base64BackupKeyHash=</pin>
</pin-set>
Configuration rather than code, scoped per build type, so the permissive setting cannot leak into release. The declared expiry fails open rather than bricking the app if the pin is forgotten.
WebView
If a WebView loads any content you do not fully control: disable JavaScript unless required; never enable allowFileAccessFromFileURLs; treat @JavascriptInterface as remote code execution surface and expose the absolute minimum; and allow-list the loadable hosts. A WebView with a JS bridge and an attacker-controlled URL is the most severe vulnerability commonly found in Android apps.
Chapter 96Integrity and realistic expectations
Interview question · reveals security maturity instantly
"Product wants to prevent users on rooted devices from using the app. How would you do it?"
What is being tested
Whether you will implement a request uncritically, and whether you understand that client-side integrity checks are advisory. A candidate who lists five root-detection libraries has failed; one who reframes the requirement has passed.
Answer L4
"First I'd ask what the actual risk is, because 'block rooted devices' is a solution, not a requirement. If it is fraud, root is a weak proxy — plenty of fraud comes from unrooted devices and plenty of rooted devices are developers. If it is a compliance requirement, that changes the answer, because then we need a defensible control rather than an effective one, and I would want that stated honestly.
Mechanically: any client-side detection can be defeated, because the attacker controls the runtime — Magisk hides root specifically from these checks, and Frida can hook the check itself. So detection raises effort; it does not prevent. The strongest available signal is Play Integrity, because the verdict is signed by Google's servers and validated on our server, which puts the trust decision somewhere the attacker does not control. Client-side root detection that gates a client-side branch is worth very little by comparison.
What I would actually propose: verify the Play Integrity verdict server-side; make the server decide what a device with a weak verdict may do — perhaps allow browsing but require step-up authentication for payments, rather than a binary block; monitor the verdict distribution so we can see whether the risk is real before we degrade anyone's experience; and never enforce it purely client-side.
And I'd name the cost, because this is the part product usually has not considered: hard-blocking rooted devices generates support load and one-star reviews from a technically vocal minority, and it will not stop a determined attacker. If the goal is fraud reduction, server-side behavioural signals will almost certainly outperform it."
Follow-up questions
- "What if they insist on the hard block?" — I implement it with Play Integrity server-side enforcement, log the impact, and revisit with data after a month. Disagree and commit, with a measurement attached.
- "How would an attacker bypass Play Integrity?" — Not easily on the device, but they can attack the plumbing: replay an old verdict, or relay a verdict from a clean device. Hence nonce-binding each request and validating server-side.
- "Does R8 obfuscation help?" — Marginally, against casual analysis. It is a shrinking tool with an obfuscation side effect, not a security control, and treating it as one is the mistake this whole part exists to prevent.
Part XV rapid recall
- The client is hostile territory; authorisation is enforced server-side or not at all.
- Obfuscation ≠ encryption ≠ security. A key in the APK is not a secret.
- Keystore keys never enter your process; gate them with
setUserAuthenticationRequiredand invalidate on biometric enrolment. - Never reuse a GCM IV; store it with the ciphertext.
- Auth Code + PKCE in a Custom Tab; short access tokens, rotating refresh with reuse detection, server-side logout.
- Biometrics must unlock a key, not flip a boolean.
- Pinning needs backup pins, an expiry, a kill switch and monitoring — it is an operational risk.
- Play Integrity validated server-side beats any client-side root detection.
Part XVI
Mobile System Design
The round that most often decides a Staff offer. One repeatable framework, an explanation of how it is scored, and eighteen complete designs — each ending in the follow-ups the interviewer will actually ask.
Chapter 97A repeatable framework
Forty-five minutes, six phases. The timings are deliberate: candidates lose the round by spending twenty minutes on requirements or by drawing boxes in minute two.
| Time | Phase | What you produce |
|---|---|---|
| 0–5 min | Requirements | 3–5 functional requirements, agreed and written down. Explicitly out of scope: everything else. |
| 5–10 min | Non-functionals & scale | Users, request rates, payload sizes, offline expectations, latency targets, device mix, and the one constraint that dominates. |
| 10–15 min | API & data model | The client/server contract and the on-device schema. This is the highest-signal artefact in a mobile design. |
| 15–30 min | Client architecture | Layers, state flow, caching, sync, background work, and the component diagram. |
| 30–40 min | Failure & edge cases | Offline, partial failure, conflict, process death, auth expiry, degraded modes. |
| 40–45 min | Trade-offs & observability | What you would do differently at 10×, what you deliberately did not build, and how you would know it is broken. |
The scale estimation nobody does — and it is free marks
10M DAU · 40 messages sent per active user per day
→ 400M messages/day ≈ 4,600 msg/s average, ~15k/s peak
Per device: 20 conversations × 500 messages retained
→ 10k rows × ~400 bytes ≈ 4 MB text + media separately
→ comfortably fits SQLite; media needs its own quota
Socket: 10M DAU, ~15% concurrent → 1.5M live connections
→ that is a backend cost, and it justifies asking whether
we need a socket at all or whether push + fetch suffices
for the 85% who are not actively chatting.The point is not arithmetic accuracy. It is that the numbers drive a design decision — here, "socket only while a conversation is open, push otherwise," which is a real architectural conclusion reached from an estimate rather than from taste.
Chapter 98How mobile design interviews are scored
| Signal | Senior L3 | Staff L4 |
|---|---|---|
| Requirements | Asks a few clarifying questions | Names the dominant constraint and designs to it; states what is out of scope |
| Client focus | Draws a backend architecture with an app attached | Keeps the design on the device: state, storage, sync, lifecycle, battery |
| Offline | "We'd cache it" | Source of truth, staleness policy, write queue, conflict resolution |
| Failure | Handles errors when asked | Enumerates failure modes unprompted and designs degraded behaviour |
| Data model | Vague entities | Concrete schema with keys, indexes and a sync cursor |
| Trade-offs | Lists options | Chooses, justifies, and names the inversion condition |
| Observability | Not mentioned | Names the metric that would reveal each failure mode |
They design the backend. A mobile system design interview is about the client: what is stored on device, how state flows, what happens offline, how it behaves under process death and poor connectivity, and what it costs in battery and bytes. Spend two minutes on the server contract and twenty on the device.
Chapter 99API design and the mobile/backend contract
The asymmetry that defines this contract: the server can deploy hourly; installed clients cannot be updated. Every compatibility obligation therefore falls on the server, and a Staff engineer is expected to negotiate that explicitly.
- Additive-only changes. New optional fields are safe; removing or retyping a field breaks old clients permanently. The client should ignore unknown fields and tolerate unknown enum values by mapping them to an explicit
Unknown. - Cursor pagination, not offsets. Offsets duplicate and skip items when the underlying list changes — guaranteed on any live feed.
- Screen-shaped endpoints reduce round trips but couple the server to UI. The honest trade: aggregate where the screen is stable and latency-critical, keep resources granular where the UI churns.
- Errors need a machine-readable code, not just a message. Clients must branch on
code; a localised string is for display only. - Every mutation takes an idempotency key. Establish this once, globally, rather than per endpoint.
- Minimum supported version must be an explicit, agreed policy with a forced-upgrade mechanism — otherwise "old clients" is an unbounded liability.
GET /v1/feed?cursor=eyJ0cyI6MTcwfQ&limit=20
200 {
"items": [ { "id": "...", "type": "post", "payload": { ... } } ],
"next_cursor": "eyJ0cyI6MTY5fQ", // null ⇒ end
"server_time": "2026-08-12T10:00:00Z" // clock-skew correction
}
POST /v1/orders
Idempotency-Key: 8f14e45f-ea5c-4f2b-9d6b-7c1a2b3c4d5e
409 {
"code": "INVENTORY_UNAVAILABLE", // client branches on this
"message": "That size just sold out", // display only, localised
"retryable": false,
"details": { "sku": "A-42" }
}Chapter 100Design: ride-hailing with live tracking
Requirements: request a ride, see the driver approach in real time, trip state through pickup and drop-off, fare and receipt. Dominant constraint: the app must be correct and cheap in battery while running for 20+ minutes with the screen sometimes off and connectivity varying.
Scale: driver position at 1 Hz; a 20-minute trip is ~1,200 updates. At 1M concurrent trips that is 1M msg/s to the backend — so the rider's client should not receive 1 Hz; 2–4 second interpolated updates are indistinguishable to the user and cut socket traffic by 75%.
flowchart TD
UI["Trip screen (Compose)"] --> VM["TripViewModel — trip state machine"]
VM --> TR["TripRepository (source of truth)"]
TR --> WS["Socket client — driver position, trip events"]
TR --> API["REST — request, cancel, receipt"]
TR --> DB[("Room: trip, positions ring buffer")]
FGS["Foreground service (driver app)"] --> LOC["FusedLocation, batched"]
LOC --> UP["Upload batcher — 5s batches, compressed"]
PUSH["FCM"] -. "wake on trip event" .-> TR
Client architecture. The trip is an explicit state machine — Requesting → Matched → DriverEnRoute → Arrived → InProgress → Completed — persisted in Room, because it must survive process death mid-trip. The socket delivers position and state transitions; FCM is the fallback that wakes the app when the socket is dead. Position updates feed an interpolator so the marker animates smoothly between sparse updates rather than teleporting.
Failure modes and behaviour: socket drops → exponential reconnect with a resume token, and the UI shows last-known position with a staleness indicator rather than a frozen lie; connectivity lost entirely → trip state persists, UI degrades to "reconnecting", and on resume the client fetches trip state by ID rather than replaying missed events; app killed mid-trip → state restored from Room and reconciled with a single authoritative fetch; driver's app backgrounded → foreground service with a location type keeps uploads alive, batched to protect battery.
Trade-offs to state: socket vs polling (socket for the active trip only, because 1.5M idle sockets is a real cost); server-side vs client-side ETA (server, so all parties agree); precision vs battery on the driver side (batch uploads at 5 s, accept a small staleness).
Follow-ups
Expect these three
- "The rider's phone dies mid-trip." — Nothing about the trip's correctness depends on the rider's client; the server is authoritative. On restart the client fetches trip state by ID. The receipt arrives by push and email regardless.
- "How do you stop the marker jumping when GPS is noisy?" — Snap positions to the road network server-side, and interpolate client-side along the returned polyline rather than between raw points. Raw GPS on a phone in a car is noisy enough to look broken.
- "Battery for the driver app over an 8-hour shift?" — Batched uploads, no per-update wakeup, adaptive interval by speed, and a hard budget measured in a dogfood programme. This is a product-defining metric for driver retention, not a technical detail.
Chapter 101Design: food delivery
Dominant constraint: three actors (customer, restaurant, courier) observing one order whose state changes from outside the app. Correctness of state matters more than latency.
Design: the order is a server-authoritative state machine; the client never infers a transition locally. Push carries the transition; the client fetches the order to confirm rather than trusting the payload, so a lost or reordered push cannot corrupt state. Menu and pricing are cached with a short TTL and revalidated at cart-open and at checkout — the failure to design for is a price change between browsing and paying, which is a trust and support-cost issue.
Cart consistency: local-first with server reconciliation. The cart is a local draft; validation (item availability, price, minimum order) happens server-side at checkout and returns a structured diff the UI explains, rather than a generic failure.
Follow-ups: "Restaurant goes offline with an order in flight" — the order state machine has an explicit RestaurantUnresponsive state with a timeout and an auto-refund path; do not leave it in Preparing forever. "Courier tracking when the app is backgrounded?" — push-driven state, socket only while the tracking screen is visible.
Chapter 102Design: chat and messaging
sequenceDiagram participant U as User participant DB as Local DB (truth) participant Q as Outbox participant S as Server U->>DB: insert local_id, state = pending DB-->>U: message visible immediately, clock icon Q->>S: send(local_id, body) S-->>Q: ack(local_id → server_id, server_seq) Q->>DB: state = sent, re-sort by server_seq Note over S,DB: socket broadcast of the same message arrives S-->>DB: upsert keyed by local_id — collapses, no duplicate row
Dominant constraints: message ordering, exactly-once display, offline send, and multi-device convergence.
messages( local_id TEXT PRIMARY KEY, -- client UUID, created at send time server_id TEXT UNIQUE, -- null until acked conversation TEXT NOT NULL, sender TEXT NOT NULL, body TEXT, created_at INTEGER NOT NULL, -- client clock, for local ordering server_seq INTEGER, -- authoritative order within conversation state TEXT NOT NULL, -- pending | sent | delivered | read | failed INDEX(conversation, server_seq), INDEX(state) -- the outbox query ) conversations(id, last_seq_synced, unread_count, updated_at)
Ordering: never order by timestamp — device clocks are wrong, sometimes by hours. Order by the server-assigned monotonic server_seq per conversation, and display pending messages optimistically at the tail until they are acked and re-sorted. Deduplication: the client-generated local_id is echoed by the server, so a message that arrives both as an ack and as a socket broadcast collapses to one row.
Sync: per-conversation cursor (last_seq_synced); on reconnect, fetch the gap rather than replaying everything. If the gap exceeds a threshold, fall back to a bounded resync rather than paging through months of history.
Offline send: the message is written locally as pending and enqueued; the UI shows it immediately with a clock icon. WorkManager drains the queue. A permanently-failed message must reach a failed state with a retry affordance — silently retrying forever is the worse bug.
Follow-ups
The three that always come
- "Same account on two devices — how do read receipts converge?" — Read state is a per-conversation high-water mark (
last_read_seq), not per message. Max wins, which is conflict-free and idempotent. - "How do you show unread counts without a full sync?" — The server sends the count in the conversation list payload and in push data. Deriving it locally requires full history, which you do not have on a fresh install.
- "End-to-end encryption?" — Changes the design materially: the server can no longer order by content or generate previews, key exchange and multi-device key distribution become the hard problem, and search must be local-only. Say this explicitly rather than adding "and it's encrypted."
Chapter 103Design: an Instagram-style feed
Dominant constraint: scroll performance with heavy media, and cursor stability on a list that changes underneath you.
Pagination: cursor-based, with the cursor encoding a snapshot boundary so newly-inserted posts do not cause duplicates mid-scroll; new content surfaces as a "new posts" pill rather than being spliced in, which also avoids scroll-position jumps. Storage: Paging 3 with a RemoteMediator into Room, so the feed is available offline and survives process death with scroll position intact.
Media pipeline: server returns multiple renditions; the client requests the one matching its display size — never the original. Prefetch two screens ahead on unmetered connections only. Memory cache sized from memoryClass, disk cache with a hard quota.
Optimistic interactions: a like writes locally, updates the UI immediately, and enqueues the mutation with an idempotency key. On failure it reverts with a quiet indicator. Never block the UI on a like round-trip.
Follow-ups: "Ranking changes between pages, and the user sees a duplicate" — the snapshot cursor is the answer; if the backend cannot provide one, dedupe client-side by ID and log the rate, because a high rate means the backend contract is wrong. "Memory during fast scroll?" — bounded caches, decode to display size, and verify with a Macrobenchmark scroll journey, not by inspection.
Chapter 104Design: video streaming
Constraints: start-up latency, rebuffer ratio, and data cost. Those three are the product.
Design: adaptive bitrate over HLS/DASH with ExoPlayer/Media3; start at a conservative rendition for fast first-frame and step up once the buffer is healthy. Buffer policy differs by network: larger buffer on unmetered, smaller on cellular to avoid wasting data the user never watches. Prefetch only the first few seconds of likely-next items, and only on Wi-Fi.
Downloads: WorkManager with an unmetered constraint, DRM licence acquisition and renewal handled separately from the media (a downloaded file with an expired licence is a common support case), and playback-position sync so resume works across devices.
Observability is the differentiator here: the metrics that matter are join time, rebuffer ratio, average bitrate delivered, and playback failure rate, segmented by network type and device tier. Say those unprompted.
Follow-ups: "Network drops mid-playback" — keep the buffered content playing, retry segment fetches with backoff, and only surface an error when the buffer is exhausted. "How do you avoid wasting the user's data?" — quality caps on cellular by default, an explicit user setting, and never prefetching on metered connections.
Chapter 105Design: a subscription streaming app (multi-surface)
Requirements: browse a catalogue, play on phone/tablet/TV, download for offline, multiple profiles per account, resume position synced across devices. Dominant constraint: the same account is used on several surfaces with different input models and wildly different network conditions, and entitlement must be correct without making the app unusable during a brief outage.
Entitlement. Checked server-side at playback start — a client-side subscription boolean is trivially bypassed and is the first thing an attacker patches. But a hard server dependency means a backend blip stops all playback, so the client caches a signed entitlement token with a bounded grace period (typically hours, not days) and degrades to cached rights if the check fails. Downloads carry their own offline licence with an explicit expiry, which is why "I downloaded it but it won't play" is almost always a licence-renewal bug rather than a media bug — worth naming, because interviewers who work on streaming have all seen it.
Profiles are a schema decision, not a UI one. Continue-watching, recommendations, maturity rating and download ownership are all per profile, so every relevant table is keyed by profile_id and every query filters on the active profile. The bug class to design out: switching profiles without clearing in-memory caches, so the previous profile's continue-watching row briefly appears — embarrassing, and on a kids' profile it is a policy violation rather than a cosmetic bug. The safe pattern is to treat a profile switch as a scoped teardown, closer to a logout than to a filter change.
profiles(id, account_id, name, is_kids, maturity_rating)
watch_state(profile_id, title_id, position_ms, updated_at_server_seq,
PRIMARY KEY(profile_id, title_id))
downloads(profile_id, title_id, file_path, licence_expires_at, bytes,
PRIMARY KEY(profile_id, title_id))
catalogue(title_id, ...) -- shared, not profile-scopedResume position across devices is a convergence problem with a forgiving conflict policy: highest position_ms per (profile, title) does not work — a user who rewatches from the start would have their position dragged forward. Use a server sequence number, last-write-wins by server order, and write positions at a throttled cadence (every 15–30 seconds and on pause/stop) rather than continuously, or you generate one write per second per viewer.
Surface divergence. TV is not a layout variation — D-pad focus navigation is a different input model, with focus order, focus memory when returning to a row, and no touch affordances. Wear and Auto differ again. The design that works: share domain, data, entitlement and playback-state logic; write presentation and navigation separately per surface; and test focus traversal on TV as a first-class requirement. Saying "it's responsive" here is a Senior answer and interviewers who own a TV app will say so.
Follow-ups
Expect these
- "The user's subscription lapses while they are offline with downloads." — Offline licences expire independently; playback stops at expiry and the UI explains why. Do not silently delete the files; the user may resubscribe.
- "Four devices playing simultaneously on a two-stream plan." — Concurrency enforcement is server-side at stream start, with a clear client-side message naming which device to stop. The client cannot arbitrate this.
- "How do you keep the catalogue fresh without re-downloading it?" — Versioned catalogue with delta updates and ETags; the catalogue is reference data (evictable, TTL) while watch state is user data (never evicted).
Chapter 106Design: maps and navigation
Dominant constraint: battery and thermal, during long foreground use with the screen on and GPS active.
Design: vector tiles cached on disk with an LRU quota and explicit offline-region downloads; the route is computed server-side and returned as a polyline plus manoeuvres, so recalculation is a server call — but the client must handle the offline case by continuing along the last route and queueing the recalculation. Location at the coarsest accuracy the current mode allows, with sensor fusion for dead-reckoning in tunnels.
Foreground service with a location type, a persistent notification, and adaptive update intervals by speed. Thermal: listen to thermal status and degrade — reduce frame rate, drop map effects, lengthen GPS interval — before the OS throttles you, because uncontrolled throttling looks like a crash-quality bug to users.
Chapter 107Design: e-commerce
Key tensions: catalogue staleness versus freshness, cart consistency across devices, and checkout correctness.
- Catalogue: aggressively cached, revalidated with ETags. Stale product copy is acceptable; stale price or availability is not — so price and stock are fetched fresh on the product page and re-validated at checkout.
- Cart: server-owned when signed in (so it follows the user across devices), local draft when anonymous, with a defined merge rule at sign-in. That merge rule is a question interviewers love: union with quantity max, and surface the change rather than silently overwriting.
- Checkout: a resumable, idempotent, server-driven step machine. The client asks "what is the next step" rather than encoding the flow, because payment and compliance steps vary by market and change without an app release.
- Search: server-side, debounced, with cancellation of superseded queries (
flatMapLatest), and a local recent-searches cache for instant perceived response.
Chapter 108Design: travel booking (flights)
Distinguishing features: search is slow (multi-supplier aggregation, seconds not milliseconds), results expire, and inventory is held rather than owned.
Design: async search — POST creates a search, the client polls or subscribes and renders results progressively as suppliers respond, so the user sees something in 500 ms rather than nothing for 6 seconds. Results carry an explicit expiry; the UI shows a countdown and re-prices on selection rather than failing at payment. The booking flow is a resumable server-side state machine with a held-inventory timer, because a user who backgrounds the app mid-booking must be able to return.
Follow-ups: "The price changed between selection and payment" — a mandatory re-price step before payment, with an explicit confirmation UI. Never silently charge a different amount; it is both a trust and a regulatory problem. "Process death mid-booking?" — the booking ID and step are persisted; on relaunch the client resumes from the server's authoritative step.
Chapter 109Design: hotel booking
Requirements: search by destination and dates, filter and sort, view on a map, book, manage the reservation. Dominant constraints: availability is volatile and supplier-owned, and the result set must be presented two ways at once without the views disagreeing.
Map/list duality is the design's core. The failure everyone hits: list and map maintained as separate states, so panning the map filters one and not the other, and a property tapped on the map is missing from the list. One source of truth for the result set, two projections driven from it, and a single explicit rule for what a map pan does — either it re-queries by viewport (and the list follows) or it does not filter at all. Ambiguity here is the bug.
@Immutable
data class SearchUiState(
val query: SearchQuery, // destination, dates, guests
val results: ImmutableList<Property>, // THE source of truth
val viewport: MapViewport?, // null until the map moves
val selectedId: PropertyId?, // shared selection
val mode: Mode, // List | Map | Split (tablet)
)
// List renders `results`; the map renders the same `results` as pins.
// Selection is one field, so tapping a pin highlights the list row.Filtering is split deliberately: filters that affect correctness or price (dates, guests, refundability) are server-side, because the client cannot know availability. Filters that merely narrow already-loaded results (star rating, amenities present in the payload) are applied locally for instant feedback, with a clear indication when the local subset is incomplete. Mixing these silently is how users end up believing a property is unavailable when it was simply not in the loaded page.
Inventory and price behave as in Chapter 108: results carry an expiry, selection triggers a re-price, and booking holds inventory on a timer. The client shows the countdown rather than failing at payment.
Offline requirements are asymmetric and worth stating. Search is useless offline — do not fake it. But a confirmed booking must be fully available with no network: confirmation number, property address and phone, check-in time, cancellation policy, and a map snapshot if you can afford one. The user will open this in an airport, in another country, with no data and a nearly-flat battery. Designing the reservation record as a self-contained offline document, rather than as a view over cached API responses, is the difference between an app that works when it matters and one that shows a spinner.
Follow-ups
Expect these
- "The user changes dates while viewing a property." — Invalidate price and availability immediately and show it as unknown while re-querying. Never carry the old price forward against new dates.
- "Thousands of pins on the map." — Server-side clustering by viewport and zoom; the client renders clusters, not properties. Client-side clustering of a large set is a main-thread cost.
- "The user cancels while offline." — Queue it as a pending operation with an idempotency key, but be honest in the UI: it is requested, not cancelled, until confirmed — because cancellation windows have financial consequences.
Chapter 110Design: a news reader
Requirements: browse sections, read articles, offline reading, breaking-news alerts, read state synced across devices. Dominant constraints: prefetch economics (you are spending the user's storage and data speculatively) and content licensing, which for some publishers forbids caching at all.
Prefetch is a budget decision, not a feature. Scheduled work on unmetered connections with a hard storage quota, prioritised by the sections the user actually opens. Articles stored with a TTL and an explicit "available offline" indicator, so the offline experience is honest rather than a surprise. The metric that justifies the whole subsystem is the prefetch hit rate — the fraction of prefetched articles actually read. If it is low, you are burning storage and data for nothing, and that is an argument you can take to a product review with numbers rather than opinion.
Read state is a small, high-churn dataset: a set of article IDs with timestamps. Sync it as a delta with last-write-wins per article, and say explicitly why that policy is acceptable here — the cost of a wrong resolution is that an article shows as unread. Contrast this with the notes app in Chapter 115, where the same policy would destroy user writing. Being able to justify a cheap conflict policy is as much a signal as designing an expensive one.
Breaking news uses high-priority push, which is a finite reputational resource: abuse it for engagement and the platform degrades your delivery for the messages that matter. Practically that means a governance rule — high priority for genuine time-critical alerts, normal priority for editorial pushes — and a metric tracking the ratio.
Licensing is the constraint outsiders miss. Some content is display-only with no caching permitted, some has a retention limit, some must be removed on takedown. The data model therefore needs a per-article caching policy field, and takedown must purge local copies — which means the sync protocol needs tombstones for content removal, not just for user deletions.
Chapter 111Design: a social network
The feed itself is Chapter 103. What makes a social client distinct are four client-side problems that are each a common production bug.
Notification coherence. A notification must not persist after the user has seen the content elsewhere. That requires server-side dismissal: the server tracks notification state and pushes a dismissal to other devices, because a client cannot know what happened on a different phone. Without it, users clear the same notification on three devices — a small annoyance that generates a surprising volume of complaints.
Account switching is where the serious bugs live. Local storage must be fully partitioned per account — separate database files or a mandatory account column enforced at the DAO level — plus separate image caches, separate outboxes, and separate in-memory state. The catastrophic version of this bug is a queued post from account A being sent from account B after a switch. The safe design treats a switch as a full teardown and rebuild of the object graph, not as a variable change.
// Every user-scoped dependency is created in an account-scoped
// component that is destroyed on switch or logout.
@AccountScope
class AccountComponent(val accountId: AccountId) {
val db: AppDatabase // separate file per account
val outbox: SyncQueue // never shared
val imageCache: DiskCache // separate directory
}
fun switchAccount(to: AccountId) {
current.outbox.pause() // do NOT drain into the new account
current.close() // tear down everything user-scoped
current = AccountComponent(to)
}Privacy state must be enforced server-side. Client-side filtering of blocked users leaks the existence of the content — it is visible in the payload, in a proxy, and to anyone who patches the client. Blocking, private accounts and restricted visibility are authorisation decisions, and authorisation on the client is not authorisation.
Moderation surfaces impose an unusual requirement: removed content must disappear from local caches too. That means content removal is a synced event with a tombstone, image caches are purgeable by content ID, and there is a path for an urgent global purge. Most offline-first designs only consider user-initiated deletion; a moderation takedown is a deletion the user did not perform and cannot decline, and the sync protocol has to accommodate it.
Chapter 112Design: a banking application
Dominant constraint: never display a number that is wrong. Correctness dominates latency, which inverts most of the caching intuition from consumer apps.
- Balance: shown with an explicit "as of" timestamp when served from cache, and refreshed on foreground. A stale balance presented as current is a complaint and potentially a regulatory issue.
- Session: short, with step-up authentication for transfers and payee changes. Biometrics gate a Keystore key (Part XV), not a boolean.
- Local storage: encrypted; transaction history cached for offline viewing but pending transfers never stored as "complete" until server-confirmed.
- Screenshot and background:
FLAG_SECUREon sensitive screens, and a privacy overlay in the recents preview. - Audit: security-relevant client events (device change, biometric enrolment change, failed auth) reported server-side, because the client's own log is not trustworthy evidence.
Chapter 113Design: payments
stateDiagram-v2
[*] --> Draft
Draft --> Committed: user confirms — key generated and PERSISTED
Committed --> Submitted: POST with Idempotency-Key
Submitted --> Succeeded: 2xx
Submitted --> Failed: 4xx, deterministic
Submitted --> Unknown: timeout / no response / process death
Unknown --> Submitted: retry, same key
Unknown --> Succeeded: reconciliation says it landed
Unknown --> Failed: reconciliation says it did not
Succeeded --> [*]
Failed --> [*]
note right of Unknown
UI shows "processing", never "failed".
Showing failure here is what makes
users pay twice.
end note
This is Chapter 63's idempotency problem as a full design. The core state machine: Draft → Committed(key persisted) → Submitted → Unknown | Succeeded | Failed, where Unknown is a first-class state rather than an error.
// 1. Key is created when the USER commits, persisted BEFORE the call.
val op = PendingPayment(
id = UUID.randomUUID().toString(), // == Idempotency-Key
amount = amount, payeeId = payee.id,
state = Committed,
)
db.pending().insert(op) // survives process death
// 2. Every attempt — including after a restart — reuses op.id.
// 3. No response ⇒ state = Unknown, NOT Failed. Reconcile by polling
// status with the same key, or wait for push confirmation.
// 4. UI never shows "failed" for an Unknown; it shows "processing".3DS and app switching add a re-entry problem: the user leaves to a bank app or a browser and returns, possibly after process death. The payment state must be recoverable from the persisted key alone, and the return deep link must be validated. Follow-ups: "What if the server's key retention is shorter than your offline queue's lifetime?" — a correctness gap; either bound the queue's lifetime to match, or require reconciliation by order status rather than by key. Spotting that mismatch is a strong L4/L5 signal.
Chapter 114Design: real-time fleet tracking
The inverse of ride-hailing: the client is a producer of high-frequency data. Design: batch positions into 5–30 second windows depending on speed and battery; compress (delta-encode coordinates, which typically shrinks payloads by an order of magnitude); persist the batch locally before upload so nothing is lost to a crash; and apply client-side backpressure — when the queue exceeds a threshold, downsample rather than growing unboundedly, keeping every N-th point plus all points where direction changed sharply.
That downsampling policy is the whole answer to "the driver was in a dead zone for two hours": you cannot upload everything, so decide in advance what fidelity you sacrifice.
Chapter 115Design: an offline-first notes app
The canonical local-first design. Local database is the source of truth; the UI never waits on the network. Every mutation writes locally and appends to an outbox. Sync is a delta protocol with a server cursor.
suspend fun sync() {
// 1. Push: drain the outbox in order, idempotently.
outbox.pending().forEach { op ->
when (val r = api.apply(op.toRequest(), key = op.id)) {
is Applied -> outbox.markDone(op.id)
is Conflict -> resolve(op, r.serverVersion) // see below
is Retryable-> return // stop; try later
is Permanent-> outbox.markFailed(op.id, r.reason) // surface to user
}
}
// 2. Pull: delta since the cursor, applied in one transaction.
var cursor = meta.cursor()
do {
val page = api.changes(since = cursor, limit = 200)
db.withTransaction {
page.changes.forEach { applyRemote(it) }
meta.setCursor(page.nextCursor)
}
cursor = page.nextCursor
} while (page.hasMore)
}Conflict policy for notes: per-note last-write-wins loses text, which users notice immediately. The defensible options are three-way merge on the text (base, local, remote), or — the pragmatic one — keep both as a conflict copy and tell the user. Silently discarding a user's writing is the one outcome that is never acceptable, and saying that explicitly matters more than the algorithm you choose.
Chapter 116Design: collaborative documents
OT vs CRDT: OT needs a central server to transform and order operations, which is simpler on the client but useless offline. CRDTs converge without coordination — the right choice for a mobile client that is regularly offline — at the cost of metadata growth and a much harder debugging story.
Mobile-specific realities to raise: CRDT metadata for a long-lived document can exceed the document itself, so you need compaction or a snapshot-plus-tail model; presence and cursors go over a socket and are ephemeral (never persisted); and a large document cannot be held fully in memory on a low-end device, so you need windowed loading, which interacts badly with naive CRDT implementations. Being able to name that interaction is what distinguishes an L5 answer from a recital of "CRDTs converge."
Chapter 117Design: large-scale search
Latency budget: typeahead feels instant under ~100 ms, acceptable to ~300 ms. Since a network round trip alone is often 200 ms, the design must hide latency: show local recent and popular searches instantly, render results progressively, and never clear the current results while the next query is in flight (the flicker that makes search feel broken).
Pipeline: debounce 250–300 ms → distinct → minimum length → flatMapLatest to cancel superseded requests → results. Local index (SQLite FTS) for user-owned content, which works offline and is genuinely instant; remote for the corpus.
Instrumentation: the metrics that matter are time-to-first-result, abandonment rate, zero-result rate, and result-click position. Zero-result rate is the one that drives product change, and it is usually not instrumented.
Part XVI rapid recall
- Five minutes on requirements, ten on non-functionals and scale, twenty on the client, ten on failure and trade-offs.
- Do an estimate and let it drive a decision — that is the point of it.
- Server-authoritative state, client-confirmed: push carries the signal, the client fetches the truth.
- Cursor pagination with a snapshot boundary; never offsets on a live list.
- Order by server sequence, not device clocks.
- Idempotency key created at user commit, persisted before the call, reused across process death.
Unknownis a first-class outcome for any mutation, not an error state.- Name the metric that would reveal each failure mode — unprompted.
Part XVII
Sync & Distributed Concepts
The distributed-systems subset that genuinely applies to a device that is frequently offline, occasionally wrong about the time, and one of several clients writing the same data.
Chapter 118Local-first architecture
Offline-first is not "cache the responses." It is an inversion of control: the local database is the source of truth, and the network is a background process that reconciles it with the server. The UI never awaits the network for a read, and never awaits it for a write either.
flowchart LR
UI["UI"] --> S["State holder"]
S --> R["Repository"]
R --> DB[("Local DB — source of truth")]
DB --> R
R --> OB[["Outbox"]]
OB --> SY["Sync engine (WorkManager)"]
SY --> API["Server"]
API --> SY
SY --> DB
PUSH["Push"] -. "wake" .-> SY
| Aspect | Online-first | Offline-first |
|---|---|---|
| Read path | Network → UI, cache as a fallback | DB → UI, always |
| Write path | Await server, then update UI | Write locally, enqueue, reconcile |
| Failure UX | Error screens and spinners | Degraded but functional; sync indicators |
| Complexity | Low | High — conflicts, queues, schema, reconciliation |
| When to choose | Data is inherently server-live (prices, availability, feeds) | Users create content, or connectivity is genuinely unreliable |
Offline-first roughly doubles the state space of every feature and introduces a permanent class of conflict bugs. It is the right choice when the user authors data or works in poor connectivity; it is over-engineering for a price-comparison app whose data is meaningless when stale. "Should everything be offline-first?" is a Part XXV trap question, and the answer is no.
Chapter 119Sync engines and delta synchronisation
A sync engine has four responsibilities and they must be separable in code, because they fail differently: push local changes, pull remote changes, resolve conflicts, and reconcile after failure.
val since = prefs.getLong("last_sync", 0)
val changes = api.changes(since = since)
applyAll(changes)
prefs.putLong("last_sync", System.currentTimeMillis()) // client clock!
Three defects: the client clock may be wrong or moved backwards by the user; a change written server-side during the request window is missed permanently; and if applyAll crashes halfway, the cursor advances anyway and the data is silently lost. Timestamp sync loses data quietly, which is the worst failure mode.
var cursor = meta.cursor() // opaque; the server defines it
do {
val page = api.changes(since = cursor, limit = 200)
db.withTransaction {
page.changes.forEach(::applyRemote)
meta.setCursor(page.nextCursor) // advances ONLY with the data
}
cursor = page.nextCursor
} while (page.hasMore)
The cursor and the data advance atomically, so a crash resumes exactly where it stopped. The cursor is opaque — the client must not interpret it, which lets the server change its pagination strategy without breaking installed clients.
Tombstones
Deletions must be synced as explicit tombstone records. Without them, a client that was offline during a deletion never learns about it and the row lives forever — which presents as "deleted items keep coming back," one of the most reported sync bugs. Tombstones need a retention window agreed with the backend, and a client that has been offline longer than that window must perform a full resync rather than a delta.
Chapter 120Conflict resolution
sequenceDiagram participant A as Device A (offline) participant S as Server participant B as Device B (offline) Note over A,B: both hold base version 7 A->>S: PATCH note 42, version 7 S-->>A: 200, now version 8 B->>S: PATCH note 42, version 7 S-->>B: 409 VERSION_CONFLICT + current record (v8) Note over B: resolve by the TYPE's policy —
LWW, per-field merge, server wins,
or keep both as a conflict copy B->>S: PATCH note 42, version 8 S-->>B: 200, now version 9
Device B needs three things to merge rather than overwrite: its local edit, the server's current record, and the base it started from. Clients that keep only the first two can overwrite but can never merge.
| Strategy | How it works | Right for / wrong for |
|---|---|---|
| Last-write-wins (server clock) | Highest server timestamp survives | Right for low-value, high-churn fields (read state, settings). Wrong for user-authored text — it silently deletes work. |
| Per-field merge | Merge non-overlapping field changes | Right for records with independent fields — a profile where one device changed the name and another the avatar. |
| Server arbitration | Server holds the rules and decides | Right whenever correctness is business-critical: inventory, balances, bookings. |
| Conflict copy | Keep both, surface to the user | Right for documents and notes. Ugly but honest; users prefer it to lost work. |
| CRDT | Types that merge deterministically | Right for collaborative editing and counters. Wrong where metadata growth or debuggability matters more than automatic convergence. |
The clock problem: never resolve conflicts with client timestamps. Devices have wrong clocks — users change them, timezones shift, and some devices drift by minutes. Use server-assigned versions or sequence numbers. If you must use a client timestamp for ordering, correct it against a server-provided server_time on every response and store the offset.
// Client sends the version it last saw.
PATCH /notes/42 { "version": 7, "title": "Groceries" }
// Server: if current version != 7 → 409 with the current record.
409 { "code": "VERSION_CONFLICT", "current": { "version": 9, ... } }
// Client resolves according to the type's policy:
when (val r = api.patch(note, version = local.version)) {
is Ok -> db.upsert(r.record) // version advances
is Conflict -> when (policy) {
ServerWins -> db.upsert(r.current)
FieldMerge -> db.upsert(merge(local, r.current, base))
KeepBoth -> { db.upsert(r.current); db.insert(local.asCopy()) }
}
}Chapter 121Idempotency and the unknown outcome
Distributed systems deliver at least once. Exactly-once delivery does not exist; exactly-once effect is achievable, and only through idempotency. The three places this bites a mobile client:
- Client → server writes. Solved with a persisted idempotency key (Parts IX and XVI).
- Server → client push. FCM may deliver a message more than once, or reorder it. Push handlers must be idempotent — which is another argument for treating push as a signal to fetch rather than as data to apply.
- Sync replay. Applying the same delta page twice must be a no-op, which means upserts keyed by server ID, never blind inserts.
override fun onMessageReceived(msg: RemoteMessage) {
val m = msg.data.toMessage()
db.insert(m) // duplicate on re-delivery
unread++ // and the count drifts forever
}
Duplicate delivery inserts twice; out-of-order delivery corrupts the sequence; and a payload from a stale push shows content the user already deleted.
override fun onMessageReceived(msg: RemoteMessage) {
val conversationId = msg.data["cid"] ?: return
// Idempotent by construction: fetching twice is harmless.
SyncWorker.enqueueUnique(context, conversationId)
}
The payload is a hint; the server remains the source of truth. This also solves payload size limits and means a missed push degrades to a delay rather than to missing data.
Chapter 122Real-time transport
| Transport | Strengths | Mobile-specific cost |
|---|---|---|
| WebSocket | Bidirectional, low latency | Dies in Doze and on network changes; needs reconnection, resume tokens and a heartbeat; drains battery if held while backgrounded. |
| SSE / long poll | Simpler, works through more proxies | One-directional; still a held connection with the same background problems. |
| FCM push | OS-managed, survives Doze, costs nothing to keep "open" | Latency of seconds; delivery is best-effort; high-priority quota is a finite reputational resource. |
| Poll | Trivially simple, cache-friendly | Latency versus battery trade-off; wasteful when nothing changed. Fine as a fallback. |
The standard Staff answer: hybrid. Socket only while the relevant screen is foregrounded; push as the wake signal when backgrounded; a delta fetch on every resume to close gaps. Explain the reconnection protocol — exponential backoff with jitter, a resume token so the server can replay missed events, and a bounded gap after which you resync rather than replay.
Chapter 123Multi-device consistency and client-side backpressure
Multi-device turns several single-client problems into distributed ones: read state must converge (use a monotonic high-water mark, which is conflict-free); notifications must be dismissible across devices (server-side dismissal); and logout on one device must not silently invalidate a pending write queue on another.
Logout is the underrated case. The correct behaviour needs a product decision, and a Staff candidate should force it: block logout while the outbox is non-empty, warn and discard, or complete the queue in the background before clearing. What is never acceptable is clearing local data with pending writes still queued, because the user believes their work was saved.
Client-side backpressure and rate limiting matter because your app can be the source of an outage. A client that retries aggressively on failure, multiplied by ten million devices, is a denial-of-service against your own backend. Every client needs: jittered backoff (Part IX), a circuit breaker that stops hammering a failing endpoint, a cap on queued operations with a downsampling policy, and respect for Retry-After.
Interview question · the classic offline-first opener
"Two devices edit the same record while both are offline. What happens?"
Short answer
It depends on the record's conflict policy, which must be defined per data type at design time — not discovered when the first conflict occurs in production.
Deep answer L4
"First I'd reject the idea that there is one answer for the whole app. The policy belongs to the data type, and I'd want it written down per type: read state is last-write-wins because the cost of getting it wrong is nil; a profile is per-field merge because the fields are independent; an inventory reservation is server-arbitrated because only the server knows the truth; and a note's body is either a three-way merge or a conflict copy, because silently losing someone's writing is the one outcome we never ship.
Mechanically, both devices carry the base version they last saw. Whichever syncs first advances the server version. The second gets a 409 with the current record, and applies its type's policy — for a merge it needs the base, which means the client must retain the last-synced version, not just the local mutation. That is the detail people miss: without the base you cannot distinguish 'I changed this field' from 'they changed it', so you cannot merge, only overwrite.
For observability I'd instrument the conflict rate per type. A rate that is higher than expected usually means the data model is wrong — two independent things sharing one record — and that is a design fix, not a merge-algorithm fix.
And testing: conflicts are the hardest sync case to test manually, so I'd build a deterministic harness with two simulated clients and a fake server that can be told which write lands first, and run every policy through it."
Follow-up questions
- "What if sync fails halfway through applying a page?" — The cursor advances only inside the transaction that applies the page, so it resumes exactly where it stopped. Never advance the cursor optimistically.
- "What if local storage is corrupted?" — Detect on open, preserve the outbox if readable, rebuild everything else from the server, and tell the user rather than silently resetting. If the outbox is unreadable, that is a data-loss event and it should be reported as one.
- "What if the server returns duplicate events?" — Idempotent application keyed by server ID. Blind inserts turn an at-least-once feed into duplicated rows.
- "How do you test the whole thing?" — Deterministic two-client harness for conflicts, fault injection for partial failure, a fake clock for TTLs, and a soak test at realistic data volume. Volume matters — sync bugs hide at small scale.
- "What breaks first at 10× data?" — The full-resync path and the delta page size. A user with 500k records cannot bootstrap the way a user with 500 does, which usually means a paged bootstrap with progressive availability rather than an all-or-nothing sync.
Part XVII rapid recall
- Offline-first inverts control: DB is truth, the network reconciles. It doubles the state space — choose it deliberately.
- Opaque server cursors, advanced inside the same transaction as the data.
- Deletions need tombstones and a retention window; longer offline than the window ⇒ full resync.
- Conflict policy is per data type, and the client must retain the base version to merge at all.
- Never resolve conflicts with client clocks.
- Push is a wake signal, not data — that makes handlers idempotent for free.
- Socket while foregrounded, push while backgrounded, delta fetch on resume.
- Your client can DoS your backend: jittered backoff, circuit breaker, bounded queues.
- Logout with a non-empty outbox is a product decision, and it must be made before launch.
Part XVIII
Legacy Code & Migration
Staff engineers are hired to change systems that are already running and cannot stop. Every migration here is treated as a programme with risk, rollback, adoption and metrics — never as a rewrite.
Chapter 124The case against rewrites
The rewrite argument is always the same: the old system is unknowable, the new one will be clean, and it will take one quarter. The observed outcome is also always the same. Three reasons, worth being able to articulate:
- The old system encodes years of undocumented requirements — the edge cases, the OEM workarounds, the regulatory rule added after an incident. A rewrite rediscovers them one production bug at a time.
- Two systems must be maintained during the transition, so velocity halves precisely when the organisation is being asked to be patient.
- There is no incremental value. A rewrite delivers nothing until it delivers everything, which makes it the first thing cut when priorities shift — leaving you with two half-systems, the worst possible state.
| Pattern | Mechanism | Use when |
|---|---|---|
| Strangler fig | New code intercepts at a boundary; old code shrinks until it can be deleted | Default for almost everything on mobile |
| Branch by abstraction | Introduce an interface, implement it twice, switch behind a flag | Swapping an infrastructure component (networking, DI, storage) |
| Parallel run | Both implementations execute; compare results, only one is used | High-risk logic where correctness must be proven: pricing, sync, crypto |
| New-code-only rule | Old code frozen, all new work uses the new approach | Cheap first step for any migration; buys time and stops the bleeding |
| Big-bang rewrite | Replace wholesale | Almost never. Defensible only for a genuinely small, isolated, well-specified component |
interface PaymentClient { suspend fun charge(req: ChargeRequest): ChargeResult }
class LegacyPaymentClient(...) : PaymentClient
class NewPaymentClient(...) : PaymentClient
class RoutingPaymentClient(
private val legacy: PaymentClient,
private val new: PaymentClient,
private val flags: FeatureFlags,
) : PaymentClient {
override suspend fun charge(req: ChargeRequest): ChargeResult =
if (flags.isEnabled("payments.new_client", req.userId)) {
runCatching { new.charge(req) }
.onFailure { metrics.count("new_client_failure") }
.getOrElse { legacy.charge(req) } // automatic fallback
} else legacy.charge(req)
}Three properties this gives you and a rewrite does not: rollout by percentage, instant rollback without a release, and a metric comparing the two paths. That is what makes a migration safe rather than merely planned.
Chapter 125The eight canonical Android migrations
| Migration | Interop mechanism | The trap |
|---|---|---|
| Java → Kotlin | Full bidirectional interop; convert file by file | Auto-converted code is full of platform types and !!. Converting a file without reviewing nullability makes the code less safe. |
| XML → Compose | ComposeView in layouts, AndroidView in Compose | Interop at the leaf level (a Compose item inside a RecyclerView) performs badly and complicates state. Migrate whole screens, not fragments of them. |
| RxJava → Coroutines | kotlinx-coroutines-rx3: asFlow(), rxSingle {} | Rx's error handling is not equivalent. An onError that terminated a stream becomes an exception that cancels a scope — a semantic change that surfaces as unexpected screen teardown. |
| LiveData → StateFlow | asFlow(), asLiveData() | LiveData is lifecycle-aware by default; StateFlow is not. Migrating without repeatOnLifecycle converts a safe observer into a background-running collector. |
| MVP → MVVM/MVI | Screen by screen; no shared machinery | Presenters often hold view references and imperative state. Porting the presenter verbatim into a ViewModel reproduces the problem with new names. |
| Monolith → modules | Extract leaves first (Part VIII) | Extracting features before shared infrastructure creates cycles and forces the :core:shared anti-pattern. |
| SQLite → Room | Room over the existing DB file with a matching schema and a starting version | The legacy schema often violates Room's expectations (missing primary keys, loose types). Validate with MigrationTestHelper against a real production database file, not a fresh one. |
| Manual DI → Hilt | Coexistence via @EntryPoint into the legacy graph | Attempting the whole graph at once. Migrate leaves upward, keeping the legacy container reachable from the new one. |
// Rx: error terminates this stream only; the screen survives.
repo.observeUser()
.subscribe({ render(it) }, { showError(it) })
// Naive port: an exception here cancels viewModelScope,
// killing every other collector in the ViewModel.
viewModelScope.launch {
repo.observeUser().collect { render(it) }
}
A behaviour change disguised as a syntax change. It presents in production as unrelated parts of the screen going blank after one failure.
viewModelScope.launch {
repo.observeUser()
.map<User, UserState> { UserState.Ready(it) }
.catch { emit(UserState.Failed(it.retryable)) } // contained
.collect { _state.update { s -> s.copy(user = it) } }
}
Failure becomes a value in this stream, matching the Rx semantics you are replacing. The general rule for any migration: enumerate the behavioural differences before converting a line of code.
Chapter 126Technical debt as a managed portfolio
The word "debt" is useful precisely because it implies interest. The Staff skill is quantifying that interest so it competes fairly with feature work.
| Class | Example | Carrying cost — how to measure it |
|---|---|---|
| Velocity debt | 18-minute builds; a 4,000-line God class | Engineer-hours per week; PR cycle time on affected files |
| Defect debt | An untested legacy sync path | Incidents and support tickets per quarter attributable to it |
| Risk debt | An unmaintained SDK; a pinned certificate with no rotation plan | Probability × impact; sometimes a single existential event |
| Opportunity debt | An architecture that blocks a planned product capability | Delay in quarters to the roadmap item it blocks |
| Deliberate debt | A shortcut taken to hit a launch, with a documented expiry | Legitimate — this is the only class that is a decision rather than a symptom |
When debt is the right answer: for code that is about to be deleted, for a validated-then-discarded experiment, or when time-to-market genuinely dominates. The failure is not taking the shortcut; it is taking it without recording the decision, the cost and the expiry. A candidate who says "I always pay down debt immediately" is describing a preference, not a judgment.
Chapter 127Evaluating Kotlin Multiplatform
Interview question · increasingly common at Staff level
"Should we adopt KMP? How would you decide?"
What is being tested
Whether you evaluate a technology against your organisation's actual constraints, or adopt it because it is current. Both "yes, it's the future" and "no, it's immature" are weak answers.
Answer L4
"I'd frame it as a question about where duplication is actually costing us, and start by measuring rather than assuming.
Where KMP genuinely pays: logic that is identical on both platforms and changes often — domain rules, validation, a sync engine, analytics event definitions, API models. If we have had three bugs this year where Android and iOS implemented the same business rule differently, that is a concrete number and it is the case for adoption.
Where it does not: UI. Compose Multiplatform is viable, but on iOS it costs platform fidelity and hiring flexibility, and UI is where the platforms genuinely differ. I would not share UI in a first adoption.
The costs people underestimate: build complexity and CI time; debugging across the boundary, which is meaningfully worse; library availability for anything platform-adjacent; and — the largest — team topology. Shared code needs an owner. If the Android team writes it and the iOS team consumes it, you have created a dependency that will be resented on one side and deprioritised on the other. That organisational question kills more KMP adoptions than any technical one.
How I'd decide: a time-boxed pilot on one bounded domain module with real value — I'd pick something with demonstrated cross-platform divergence bugs. Success criteria agreed in advance: iOS build time impact under a stated threshold, no reduction in iOS engineers' ability to work independently, and the divergence bugs actually stopping. Then a decision with both teams present.
And the exit path, because this is close to a one-way door: shared code must remain plain Kotlin behind interfaces, so if we reverse the decision the iOS side re-implements against a known contract rather than untangling a fused codebase. I would want that written into the pilot's ground rules before we start."
Follow-up questions
- "What if the iOS team is against it?" — Then it probably fails regardless of technical merit, and I would say so. Shared code with an unwilling consumer becomes shared code nobody maintains. The right move is to fix the willingness question first, not to mandate.
- "How does hiring change?" — The Android pool is unaffected; the iOS pool narrows if you require Kotlin. Worth quantifying in a market where iOS hiring is already hard.
- "What about Compose Multiplatform on iOS?" — Viable, and improving, but it is a separate and much larger decision than sharing domain logic. Bundling them is how a modest, defensible proposal becomes an unfundable one.
Part XVIII rapid recall
- Rewrites lose undocumented requirements, halve velocity, and deliver nothing until the end.
- Branch by abstraction with a flag gives percentage rollout, instant rollback and a comparison metric.
- Enumerate behavioural differences before converting: Rx→Flow error semantics, LiveData→StateFlow lifecycle.
- "New code only" is the cheapest first step in any migration.
- Migrate whole Compose screens, not leaves inside RecyclerViews.
- Quantify debt as carrying cost so it competes with features; deliberate debt needs a recorded expiry.
- KMP is a team-topology decision as much as a technical one; share domain, not UI, and keep an exit path.
Part XIX
Production Incidents
Twelve incidents presented the way an interviewer presents them — with incomplete information. Reason first, then read the walkthrough. The pattern being taught is the reasoning, not the answers.
Chapter 128Incident response for mobile
Mobile incidents differ from server incidents in one decisive way, and stating it early in any incident question is a strong signal: you cannot recall the binary. Everything follows from that.
| Phase | Server | Mobile |
|---|---|---|
| Detect | Seconds — metrics and alerts | Minutes to hours — Vitals and Crashlytics lag, and the signal is diluted across versions |
| Mitigate | Roll back the deploy | Halt the rollout (affects new installs only) → kill switch → server-side fix → forward hotfix |
| Blast radius | All users at once | Bounded by rollout percentage — which is why staged rollout is the primary control |
| Recovery time | Minutes | Hours (flag) to days (release), and never 100% of users |
flowchart LR D["Detect
Vitals, Crashlytics, support"] --> T["Triage
severity, blast radius, is it us?"] T --> M1["Halt the rollout
seconds, free, reversible"] M1 --> M2["Kill switch / remote config
minutes"] M2 --> M3["Server-side fix
hours"] M3 --> M4["Forward hotfix
days, never reaches everyone"] M4 --> P["Blameless postmortem
fix the class, not the instance"] T -. "not us: OS or Play Services rollout" .-> W["Watch and communicate"]
The order matters and it is the opposite of a server incident: the cheapest, most reversible mitigation is first, and the code fix is last because it is the slowest and never reaches 100% of users.
Severity, defined before you need it: SEV1 — data loss, security exposure, or a core flow broken for a large cohort. SEV2 — significant degradation with a workaround. SEV3 — contained, non-blocking. Publish the definitions, because arguing about severity during an incident is how thirty minutes disappear.
Incident commander (decides, does not debug), communications lead (updates stakeholders on a fixed cadence), and investigators. A Staff engineer in an incident is usually the commander, and the most common failure is that they get absorbed into debugging and stop coordinating.
Chapter 129Twelve incidents
Incident 1
ANRs increased 40% after a release
First move: segment. Version, OS, OEM, device tier, and whether the rise tracks the rollout curve. Check whether the previous version's ANR rate also rose over the same window — if it did, it is an OS or Play Services change, not us.
Likely causes, ranked: new synchronous work on a startup or resume path; a lock now held across I/O; an SDK upgrade doing main-thread work; a broadcast receiver doing work inline.
Diagnosis: read the ANR traces and examine every thread. Main blocked on a monitor means the bug is in whatever holds the lock. Mitigation: halt the rollout immediately — that is free and reversible — then fix. Prevention: StrictMode penalty-death in internal builds, and an ANR-rate halt criterion on staged rollout.
Incident 2
Battery consumption doubled
First move: Vitals excessive-wakeup and stuck-wakelock metrics, segmented by version. Battery complaints are also frequently misattributed by users, so confirm with data before investigating.
Likely causes: a wakelock not released on an error path; a periodic job whose interval was reduced; location updates continuing after the screen turns off; a socket held while backgrounded; a retry loop against a failing endpoint (which also explains a simultaneous data-usage rise).
Diagnosis: Battery Historian on a dogfood build reproducing the pattern; look for wakelock duration and wakeup count rather than CPU. The retry-loop case is the one people miss — a backend endpoint started failing, and the client's unbounded retry is now the battery bug. Prevention: wakelocks with timeouts and finally release; a circuit breaker; wakeup-count alerts.
Incident 3
Memory grows steadily over several hours; OOM crashes in long sessions
Key discriminator: a leak (retained garbage) versus unbounded growth (live, reachable, and by design). LeakCanary finds the first and is silent on the second — so silence from LeakCanary does not exonerate.
Diagnosis: heap dumps at 10 minutes and 2 hours, compared. Sort by retained size and diff the class histograms. Growth in your own collection classes points to unbounded accumulation; multiple Activity instances point to a leak.
Common cause: a StateFlow accumulating an ever-growing list of events, messages or log entries (Part XII, Exercise 4). Fix: bound the window and page from disk. Prevention: LeakCanary in instrumented CI plus a long-session soak test with a memory assertion.
Incident 4
Login fails intermittently for about 5% of users
Segment first: 5% is a suspiciously specific number — check whether it maps to a rollout bucket, a region, a device clock skew, an OEM, or a specific auth provider.
Ranked hypotheses: the token-refresh race (Part IX) invalidating rotating refresh tokens for users with concurrent requests; clock skew causing JWT nbf/exp validation to fail on devices with wrong time; a CDN or DNS issue in one region; certificate pinning failing after a partial rotation; or a race between session restore and the first authenticated request on cold start.
Diagnosis: the client must log a structured auth-failure reason — not "login failed." Without that, this is unsolvable, which makes it also a lesson: instrument the failure taxonomy before you need it. Prevention: single-flight refresh, server-side clock in responses with client offset correction, and pin-failure monitoring.
Incident 5
Offline users see stale data after reconnecting
Likely causes: the sync cursor advanced without the data being applied (a non-transactional sync — Part XVII); missing tombstones so deletions never arrive; the reconnect not triggering a delta fetch; or an observable query that is not invalidated because writes bypassed Room's change tracking (raw SQL, or a second database instance).
Diagnosis: reproduce with a deterministic harness — go offline, mutate server-side, reconnect — and log cursor progression. Compare the local row count and version against the server for one affected user. Prevention: cursor advanced only inside the applying transaction; a sync-lag metric (time since last successful sync, p95) alerted on.
Incident 6
A payment was charged twice
This is a SEV1: money, trust, and possibly a regulatory report. Mitigate first — disable the client-side retry path via remote config, and coordinate with the backend on reconciliation and refunds — then investigate.
Cause, almost always: the idempotency key was generated per attempt rather than per user commit, or it was held only in memory and regenerated after process death, or the server's key retention window expired before the client's offline queue drained.
Fix: key created at commit, persisted before the first call, reused forever for that operation (Part XVI, Chapter 113). Prevention: a test asserting the key is stable across process death; a server-side alert on duplicate charges to the same payee for the same amount within a short window — because that detects the class regardless of which client causes it.
Incident 7
Push notifications are delayed by 20+ minutes for some users
Likely causes: messages sent at normal priority rather than high (normal priority is deferred in Doze); the app in a restricted App Standby bucket after low engagement; OEM battery optimisation, which is aggressive on several manufacturers and is often the whole answer; FCM registration token rotation not being re-registered; or the fan-out being backlogged server-side.
Diagnosis: segment by OEM immediately — a delay concentrated on two manufacturers is a platform behaviour, not a bug in your code. Compare server send timestamp with client receipt timestamp; without that pair, you are guessing.
Reality to state: you cannot guarantee push latency. If the product requires timely delivery, the design must tolerate delay — fetch on resume, in-app fallback, and honest UX. Prevention: a send-to-receive latency metric, and a high-priority budget so engagement pushes cannot consume the reputational allowance that transactional ones need.
Incident 8
A Compose screen became extremely slow after a routine change
First check: the Compose compiler metrics diff between the two builds. One class becoming unstable — often by adding a List field or a type from a non-Compose module — can make a whole subtree unskippable.
Other candidates: a key removed from a lazy list; state read moved from a deferred lambda into composition; an expensive derivation added to an item body; a SubcomposeLayout introduced in a hot path; or a baseline profile that no longer covers the changed code path.
Diagnosis: Layout Inspector recomposition counts, then a system trace to distinguish composition cost from layout and draw. Prevention: Macrobenchmark on the screen's scroll journey gated in CI, and compiler-metrics diffing in the PR.
Incident 9
Cold start went from 700 ms to 2.5 s
Bisect by version: a step change points to one release; a gradual slope points to accumulation. Then bisect by content: a build with the new SDK disabled, a build with the new initialiser removed.
Ranked causes: a new SDK initialised eagerly in Application.onCreate (the most common by a wide margin); a blocking network or disk call added to startup; a baseline profile that stopped being generated — silently, in a build-config change; a content provider added by a dependency; or a large increase in classes loaded before the first frame.
Diagnosis: system trace of a cold start on a mid-tier device; look at the span between process fork and first frame. Prevention: the three-tier initialisation policy with an owner, and a Macrobenchmark startup budget failing the build on regression (Part XI).
Incident 10
Crashes only on the newest Android version
Typical causes: a behaviour change gated on targetSdk; a newly-enforced restriction (background start, exact alarms, foreground service type, package visibility); a hidden API that is now blocked; or a stricter permission requirement.
Diagnosis: the stack trace usually names the enforcement directly — SecurityException, MissingForegroundServiceTypeException, and similar are self-describing. Mitigation: if it is targetSdk-gated, the fastest safe mitigation may be a remote-config kill switch on the feature, not a targetSdk downgrade (which Play policy constrains anyway).
Prevention — the real answer: a preview-SDK testing programme. Every Android release ships behaviour changes months in advance; an organisation that first meets them in production has a process gap, not a bug. That framing is what makes this a Staff answer.
Incident 11
Analytics events dropped 30% with no crash-rate change
A silent-failure incident, and the hardest class to detect. Causes: a batching change losing the buffer on process death; events queued in a scope cancelled at navigation; a consent or privacy change suppressing them correctly (i.e. not a bug); a schema change rejected server-side with the client ignoring the error; or a sampling config misapplied.
The lesson worth stating: telemetry needs its own telemetry. A client-side counter of events enqueued versus server-side events received, reconciled daily, is what turns this from an accidental discovery into an alert. Most organisations discover analytics loss weeks later during a business review.
Incident 12
A third-party SDK outage is taking the app down
Symptom: the app hangs at launch or crashes for all users, correlated with a vendor incident rather than with any release of ours.
Cause: an SDK initialised synchronously at startup, performing a blocking network call with a long timeout — so a vendor's slow endpoint becomes our startup ANR. Or the SDK crashes on a malformed response and takes the process with it.
Immediate mitigation: remote-config kill switch on the SDK's initialisation, if one exists. If it does not, that is the finding. Prevention, and the Staff answer: every third-party SDK gets an integration standard — initialised off the critical path, wrapped behind an interface we own, guarded by a kill switch, with a timeout we control, and an owner responsible for evaluating it. "We depend on eleven SDKs and can disable none of them" is an architecture problem, not an incident.
Chapter 130Observability that makes incidents survivable
| Signal | What it must capture | Common gap |
|---|---|---|
| Crash-free users and sessions | Both, segmented by version, OS, device tier | Reporting only sessions hides a small cohort crashing constantly |
| Breadcrumbs | Navigation, network outcomes, key state transitions | Logging screen names but not the failure taxonomy — see Incident 4 |
| Structured error codes | Machine-readable reason on every failure path | "Something went wrong" as both the UI copy and the log line |
| Trace correlation | A request ID shared with the backend | No way to join client symptom to server cause during an incident |
| Sync and queue health | Time since last sync, outbox depth, conflict rate | Entirely absent in most apps, and the only way to see silent data problems |
| PII discipline | Allow-list what may be logged | Tokens and emails in breadcrumbs, discovered during a compliance audit |
Telemetry cost is a real budget line. A verbose analytics client can generate gigabytes per user per year, costing money on both the ingestion bill and the user's data plan. Sampling policy, event schema review, and a cost-per-event dashboard belong to whoever owns observability — and at Staff level, that is often you.
Part XIX rapid recall
- No rollback exists: halt rollout → kill switch → server-side fix → forward hotfix.
- Segment before hypothesising: version, OS, OEM, tier, rollout percentage.
- Check whether the previous version regressed too — it separates "us" from "the platform".
- Read every thread in an ANR trace; the cause is usually the lock holder.
- LeakCanary silence does not rule out unbounded growth.
- Idempotency keys are per user commit and persisted, or you will double-charge.
- Push latency cannot be guaranteed; design for delay and measure send-to-receive.
- Instrument the failure taxonomy before the incident, or the incident is unsolvable.
- Every third-party SDK needs a kill switch and an owner.
Part XX
The Coding Interview
Weighted toward the concurrency and systems problems that actually appear in Staff Android loops rather than generic puzzles. Every solution is production-shaped: cancellable, testable, and honest about its limits.
Chapter 131How Staff coding rounds are scored
A correct answer can still fail the round. Interviewers score the process, roughly in this order:
- Clarification. Bounded or unbounded? Thread-safe? What is the eviction policy? Silence here reads as an engineer who codes before understanding.
- API before implementation. Write the signatures first and ask whether they are right. This is the single highest-signal habit in a Staff coding round.
- Reasoning aloud about concurrency. "Two callers hit this line simultaneously — what happens?" said unprompted.
- Edge cases named before being asked. Empty input, cancellation mid-flight, failure of the underlying operation, capacity zero.
- Testing. What you would test, and how you would make it deterministic.
- Complexity and trade-offs, including what you would change in production.
Senior candidates implement the thing. Staff candidates implement the thing and then say: "this is correct but it allocates on every access, so under a scroll I'd instead…", or "this is fine for hundreds of entries; at hundreds of thousands I'd change the data structure because…". Volunteering the limits of your own solution is the strongest signal available in this round.
Chapter 132Problem: thread-safe LRU cache
Requirements to establish: bounded by count or by size? Accessed from multiple threads? Do we need TTL? Must reads be lock-free? For this version: bounded by count, thread-safe, O(1) get and put.
class LruCache<K : Any, V : Any>(private val maxSize: Int) {
init { require(maxSize > 0) { "maxSize must be positive" } }
// accessOrder = true makes iteration order = access order,
// so the eldest entry is the least recently *used*, not inserted.
private val map = object : LinkedHashMap<K, V>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<K, V>) =
size > maxSize
}
private val lock = Any()
fun get(key: K): V? = synchronized(lock) { map[key] }
fun put(key: K, value: V): V? = synchronized(lock) { map.put(key, value) }
fun remove(key: K): V? = synchronized(lock) { map.remove(key) }
val size: Int get() = synchronized(lock) { map.size }
}Why get must also be synchronised — the question interviewers ask to check depth: with accessOrder = true, a read mutates the linked list. An unsynchronised get can corrupt the structure or throw ConcurrentModificationException. Candidates who synchronise only writes have a subtle, intermittent bug.
Complexity: O(1) for both, with lock contention as the practical bottleneck. Follow-ups and answers: "Make reads concurrent" — segment the map, or accept approximate LRU (Caffeine's approach: buffer access records and replay them in batches). "Bound by bytes" — supply a size function and evict in a loop rather than by count. "Add TTL" — store the insert time and treat expired entries as absent on read, plus a periodic sweep so expired entries do not occupy capacity indefinitely.
Chapter 133Problem: concurrent request deduplicator
The scenario: five composables ask for the same user simultaneously on a cold start. You want one network call, five results, and correct cancellation semantics.
class RequestDeduplicator<K : Any, V>(
private val scope: CoroutineScope, // shared, NOT a caller's scope
) {
private val mutex = Mutex()
private val inFlight = mutableMapOf<K, Deferred<V>>()
suspend fun execute(key: K, block: suspend () -> V): V {
val deferred = mutex.withLock {
inFlight[key]?.takeIf { it.isActive }
?: scope.async {
try { block() }
finally { mutex.withLock { inFlight.remove(key) } }
}.also { inFlight[key] = it }
}
return deferred.await()
}
}The design decision to articulate: the work runs in a shared scope, not the first caller's. If it ran in the first caller's scope, that caller navigating away would cancel the request for the other four — a genuinely nasty production bug, because it only appears when the first subscriber leaves early. The cost of the shared scope is that the work continues even if every caller leaves; if that matters, add reference counting and cancel when the count reaches zero, but say why you did it.
Edge cases: a failure must remove the entry so the next caller retries rather than awaiting a permanently-failed Deferred (the finally handles it); isActive guards against a completed-but-not-yet-removed entry; and each caller's own cancellation only cancels its await, which is exactly what you want.
Tests: launch five concurrent calls with a TestDispatcher, assert the block ran once and all five got the value; cancel the first caller and assert the others still complete; make the block throw and assert the next call re-executes.
Chapter 134Problem: debounce and throttle as Flow operators
fun <T> Flow<T>.debounceManual(timeoutMs: Long): Flow<T> = channelFlow {
var job: Job? = null
collect { value ->
job?.cancel() // supersede the pending emission
job = launch {
delay(timeoutMs)
send(value) // only if nothing arrived meanwhile
}
}
job?.join() // let the last one through
}
// Throttle-first: emit immediately, then ignore for a window.
fun <T> Flow<T>.throttleFirst(windowMs: Long): Flow<T> = flow {
var lastEmit = 0L
collect { value ->
val now = currentCoroutineContext()[TestTimeSource]?.now() ?: System.currentTimeMillis()
if (now - lastEmit >= windowMs) { lastEmit = now; emit(value) }
}
}The distinction that gets asked: debounce waits for silence (right for search-as-you-type — you want the final query); throttle-first emits immediately then suppresses (right for a button that must not double-fire — you want the first event, and debounce would add latency to every tap). Choosing the wrong one produces either a laggy search or a double-submitted order.
The job?.join() line is what makes the trailing value survive when the upstream completes — omitting it silently drops the last query, which is the classic bug in hand-rolled debounce.
Chapter 135Problem: rate limiter (token bucket)
class TokenBucket(
private val capacity: Int,
private val refillPerSecond: Double,
private val clock: () -> Long = System::nanoTime,
) {
private val mutex = Mutex()
private var tokens = capacity.toDouble()
private var last = clock()
/** Suspends until a token is available. Cancellable. */
suspend fun acquire() {
while (true) {
val waitMs = mutex.withLock {
refill()
if (tokens >= 1.0) { tokens -= 1.0; return }
((1.0 - tokens) / refillPerSecond * 1000).toLong().coerceAtLeast(1)
}
delay(waitMs) // outside the lock — important
}
}
private fun refill() {
val now = clock()
val elapsedSec = (now - last) / 1_000_000_000.0
if (elapsedSec <= 0) return
tokens = min(capacity.toDouble(), tokens + elapsedSec * refillPerSecond)
last = now
}
}Two details that separate levels: delay is called outside the lock — holding a mutex across a delay serialises every waiter and destroys throughput; and the clock is injected, so the test uses virtual time instead of sleeping. Follow-up: "Why token bucket over a fixed window?" — a fixed window allows a double-rate burst across the boundary; token bucket permits a controlled burst up to capacity and then a smooth rate, which is what you actually want for API politeness.
Chapter 136Problem: offline sync queue
The most representative Staff Android coding problem, because it touches persistence, concurrency, retry and idempotency at once.
@Entity(tableName = "outbox")
data class Operation(
@PrimaryKey val id: String, // == idempotency key
val type: String,
val payload: String,
val createdAt: Long,
val attempts: Int = 0,
val nextAttemptAt: Long = 0,
val state: State = State.Pending, // Pending | InFlight | Failed | Done
val lastError: String? = null,
)
class SyncQueue(
private val dao: OutboxDao,
private val api: Api,
private val clock: Clock,
) {
/** Drains in FIFO order. Stops on the first retryable failure so
* ordering is preserved — critical when operations are dependent. */
suspend fun drain(): DrainResult {
while (true) {
val op = dao.nextReady(now = clock.millis()) ?: return DrainResult.Empty
dao.update(op.copy(state = State.InFlight))
when (val r = runCatching { api.apply(op.type, op.payload, key = op.id) }
.fold({ Outcome.Ok }, { it.toOutcome() })) {
Outcome.Ok -> dao.delete(op.id)
Outcome.Duplicate -> dao.delete(op.id) // idempotent replay
Outcome.Permanent -> dao.update(op.copy(
state = State.Failed, lastError = "permanent")) // surface to user
Outcome.Retryable -> {
val n = op.attempts + 1
if (n >= MAX_ATTEMPTS) {
dao.update(op.copy(state = State.Failed, attempts = n))
} else {
dao.update(op.copy(
state = State.Pending, attempts = n,
nextAttemptAt = clock.millis() + backoffWithJitter(n)))
return DrainResult.RetryLater // preserve order
}
}
}
}
}
}Design points to say aloud: the row ID is the idempotency key, so a replay after process death is safe; InFlight must be recoverable — on startup, reset stale InFlight rows to Pending, because a crash mid-request leaves them stranded; draining stops on the first retryable failure to preserve ordering when operations are dependent (if they are independent, you can parallelise, and you should say which assumption you made); and a permanently-failed operation must be visible to the user, not retried forever or silently dropped.
Follow-ups: "How do you handle operations that depend on each other?" — either FIFO with stop-on-failure as here, or an explicit dependency field. "What if the payload references a local ID the server has not seen?" — ID mapping: when the create succeeds, rewrite queued operations that reference the local ID with the server's. That is a real and commonly-missed requirement.
Chapter 137Problem: a typed state machine
stateDiagram-v2 [*] --> Idle Idle --> Loading: Search(q) / Fetch(q) Loading --> Ready: Loaded(items) Loading --> Failed: Errored(retryable) Loading --> Loading: Search(q2) / Fetch(q2) Failed --> Loading: Retry / Fetch(q) Failed --> Loading: Search(q2) / Fetch(q2) Ready --> Loading: Search(q2) / Fetch(q2)
Every edge is one line of the reducer, and every edge is one unit test with no coroutines, no mocks and no clock. Events with no edge from the current state are ignored rather than crashing — which is what makes a stale Loaded from a superseded query harmless.
sealed interface State {
data object Idle : State
data class Loading(val query: String) : State
data class Ready(val items: List<Item>, val query: String) : State
data class Failed(val query: String, val retryable: Boolean) : State
}
sealed interface Event {
data class Search(val query: String) : Event
data class Loaded(val items: List<Item>) : Event
data class Errored(val retryable: Boolean) : Event
data object Retry : Event
}
sealed interface Effect { data class Fetch(val query: String) : Effect }
/** Pure function: (State, Event) -> (State, Effects). No coroutines, no I/O. */
fun reduce(state: State, event: Event): Pair<State, List<Effect>> =
when (state) {
is State.Idle -> when (event) {
is Event.Search -> State.Loading(event.query) to listOf(Effect.Fetch(event.query))
else -> state to emptyList() // ignore impossible events
}
is State.Loading -> when (event) {
is Event.Loaded -> State.Ready(event.items, state.query) to emptyList()
is Event.Errored -> State.Failed(state.query, event.retryable) to emptyList()
is Event.Search -> State.Loading(event.query) to listOf(Effect.Fetch(event.query))
Event.Retry -> state to emptyList()
}
is State.Failed -> when (event) {
Event.Retry -> State.Loading(state.query) to listOf(Effect.Fetch(state.query))
is Event.Search -> State.Loading(event.query) to listOf(Effect.Fetch(event.query))
else -> state to emptyList()
}
is State.Ready -> when (event) {
is Event.Search -> State.Loading(event.query) to listOf(Effect.Fetch(event.query))
else -> state to emptyList()
}
}Why separate effects from state: the reducer stays pure, so every transition is a one-line unit test with no coroutines, no mocks and no time. The runtime executes the effects and feeds results back as events. This is the structure that makes complex screens testable, and being able to explain why the effect list is returned rather than executed is the point of the question.
Edge case worth naming: a stale Loaded arriving for a superseded query. Either the effect runner cancels superseded fetches (flatMapLatest), or Loaded carries the query and the reducer ignores mismatches. Say which you chose.
Chapter 138Problem: multi-level cache with coherent invalidation
Requirements to establish: which tier is the source of truth? Must a write be visible immediately to other readers? Is the memory tier shared across screens? For this version: disk is truth, memory is a bounded read-through cache, and all readers must see a write immediately.
class ProfileStore(
private val dao: ProfileDao, // source of truth
private val api: ProfileApi,
maxMemoryEntries: Int = 64,
) {
private val memory = LruCache<UserId, Profile>(maxMemoryEntries)
private val single = SingleFlight<UserId, Unit>(appScope)
/** Reads never bypass the source of truth's change stream. */
fun observe(id: UserId): Flow<Profile?> =
dao.observe(id) // ← invalidation comes free
.map { it?.toDomain() }
.onEach { it?.let { p -> memory.put(id, p) } }
/** Fast path for a non-observing caller (e.g. a list mapper). */
fun peek(id: UserId): Profile? = memory.get(id)
/** The ONLY write path. Every tier updates from here. */
suspend fun refresh(id: UserId) = single.run(id) {
val dto = api.profile(id)
dao.upsert(dto.toEntity()) // triggers observe() → memory
}
suspend fun clear(id: UserId) {
memory.remove(id)
dao.delete(id)
}
}The design point that scores: the memory tier is populated from the database's change stream, not from the network. That single decision makes incoherence structurally impossible — there is no path where the network updates memory without the database seeing it, which is the bug in almost every hand-rolled two-tier cache. SingleFlight prevents N concurrent refreshes for the same key (Chapter 133).
Follow-ups: "What if the memory cache is stale after a write from another process?" — Room's invalidation is process-local, so a multi-process app needs a ContentProvider or an explicit invalidation broadcast; say this rather than assuming. "How do you bound memory by bytes?" — an LruCache with a size function, sized from ActivityManager.memoryClass, and cleared on onTrimMemory.
Chapter 139Problem: bounded parallel execution
Scenario: upload 200 images. Unbounded concurrency exhausts connections and memory; sequential is too slow. Run at most N at a time, preserve failures individually, and cancel cleanly.
suspend fun <T, R> Iterable<T>.mapParallel(
concurrency: Int,
transform: suspend (T) -> R,
): List<Result<R>> = coroutineScope {
val gate = Semaphore(concurrency)
map { item ->
async {
// withPermit releases on cancellation as well as completion.
gate.withPermit {
runCatching { transform(item) }
.onFailure { if (it is CancellationException) throw it }
}
}
}.awaitAll()
}
// Usage: 4 at a time, one failure does not abort the rest.
val results = files.mapParallel(concurrency = 4) { uploader.upload(it) }
val failed = results.count { it.isFailure }Two details interviewers probe. withPermit rather than manual acquire/release, because a cancellation between them leaks a permit and the pool silently shrinks to zero over time — a genuinely nasty production bug. And rethrowing CancellationException out of runCatching, which otherwise captures it as a failure and breaks structured concurrency.
Follow-up: "Why not Dispatchers.IO.limitedParallelism(4)?" — That bounds threads, not in-flight operations. For suspending network calls the coroutine is not holding a thread while awaiting, so a dispatcher limit does not bound concurrency at all. This distinction is a reliable Staff discriminator.
Chapter 140Problem: merge sources with per-source failure isolation
Scenario: a dashboard combines profile, orders and recommendations. One source failing must degrade that section only — never blank the screen.
sealed interface Section<out T> {
data object Loading : Section<Nothing>
data class Ready<T>(val value: T) : Section<T>
data class Failed(val retryable: Boolean) : Section<Nothing>
}
private fun <T> Flow<T>.asSection(): Flow<Section<T>> =
map<T, Section<T>> { Section.Ready(it) }
.onStart { emit(Section.Loading) }
.catch { e ->
if (e is CancellationException) throw e
emit(Section.Failed(retryable = e.isRetryable())) // contained
}
val state: StateFlow<DashboardUi> = combine(
profileRepo.observe().asSection(),
orderRepo.observe().asSection(),
recsRepo.observe().asSection(),
) { profile, orders, recs -> DashboardUi(profile, orders, recs) }
.stateIn(viewModelScope, WhileSubscribed(5_000), DashboardUi())Why catch per source, before combine: an exception reaching combine terminates the combined flow and the whole screen goes blank. Converting failure into a value inside each branch is what makes partial degradation possible. This is the same principle as Part VII's per-section state, expressed in operators.
Follow-ups: "How does retry work per section?" — each repository exposes a refresh command; the failed section's retry button calls only that one. "What if two sources are logically dependent?" — then they are one source; combine across a dependency produces an intermediate state that is briefly inconsistent, and the fix is to model the dependency upstream rather than in the UI.
Chapter 141Further problems, with the key insight for each
| Problem | The insight being tested |
|---|---|
| Retry with exponential backoff | Jitter; rethrowing CancellationException; classifying retryable versus not (Part IX). |
| Token refresh coordination | Compare against the stale token inside the lock so N concurrent 401s cause one refresh. |
| Paginated repository with cache | Cursor keys, transactional page+key writes, and dedupe on refresh. |
| Event bus with replay | SharedFlow replay semantics, and why replay causes duplicate navigation after rotation. |
| Custom Flow operator | Exception transparency and context preservation — do not catch around emit, do not withContext around it. |
| Incremental search index | Trie or SQLite FTS; the real answer is usually "use FTS", and knowing when hand-rolling is justified. |
| Priority work queue with starvation avoidance | Ageing: a low-priority item's effective priority rises with wait time, or long uploads never run. |
| Circuit breaker | Three states (closed, open, half-open); the half-open probe is what candidates omit. |
Part XX rapid recall
- Clarify, then write the API, then implement. Reason about two concurrent callers out loud.
- LRU with
accessOrder: reads mutate, so reads must be synchronised too. - Deduplicate in a shared scope so one caller's cancellation cannot cancel everyone's work.
- Debounce waits for silence; throttle-first emits then suppresses. Do not confuse them.
- Never hold a lock across
delayor any suspension you can avoid. - Outbox: row ID is the idempotency key; recover stale
InFlighton startup; surface permanent failures. - Keep reducers pure and return effects — that is what makes them testable.
- Volunteer the limits of your own solution; it is the strongest available signal.
Part XXI
Code Review Interviews
Snippets with layered defects, and expert reviews that model not only what is wrong but review voice — because how you deliver a review is part of what is being assessed.
Chapter 142What a Staff review looks for
Junior reviewers find style issues. Senior reviewers find bugs. Staff reviewers find the precedent a change sets — because in a 300-module codebase, a pattern merged once is a pattern copied fifty times.
| Severity | Meaning | Examples |
|---|---|---|
| Blocking | Correctness, security, data loss, or a boundary violation | Race condition, leaked scope, PII in logs, feature-to-feature dependency |
| Should fix | Will cost us later; author decides timing | Untestable construction, missing failure path, unbounded collection |
| Consider | A genuine option, author's call | Naming, structure, an alternative operator |
| Note | Information, no action | "This interacts with the migration in #4412" |
Label every comment. An unlabelled review is a wall of equally-weighted opinions, and the author cannot tell what blocks the merge. In an interview, saying "these two are blocking, the rest are optional" is itself a strong signal — it demonstrates you can be reviewed by as well as review.
Chapter 143Fifteen snippets
Review 1
ViewModel with four defects
class OrderViewModel(private val context: Context) : ViewModel() {
val orders = MutableLiveData<List<Order>>()
fun load() {
GlobalScope.launch(Dispatchers.Main) {
try {
val result = withContext(Dispatchers.IO) { Api.service.getOrders() }
orders.value = result
Log.d("Orders", "Loaded for ${Session.token}")
} catch (e: Exception) {
Toast.makeText(context, "Error", Toast.LENGTH_SHORT).show()
}
}
}
}Expert review
- [Blocking]
Contextin a ViewModel. The ViewModel outlives configuration changes, so this leaks the Activity and its whole view tree. Errors belong in state; the UI decides how to display them. - [Blocking]
GlobalScope. No cancellation when the screen closes; the coroutine keeps a reference to this ViewModel. UseviewModelScope. - [Blocking] token in a log line. This reaches logcat, bug reports and crash breadcrumbs. Never log credentials — and I'd add a lint rule rather than only a comment, because this is the third time this quarter.
- [Blocking]
catch (e: Exception)swallowsCancellationException, so a cancelled load shows an error toast on a screen the user already left. - [Should fix] Mutable state exposed.
MutableLiveDatais public, so any collaborator can write to it. Expose an immutable type. - [Should fix] Singletons.
Api.serviceandSessionare static, making this untestable without the whole graph. Inject them. - [Note]
withContext(IO)is redundant ifgetOrders()is a Retrofitsuspendfunction — those are already main-safe.
Voice: lead with the two that risk user harm (leak, logged token), group the rest, and offer the mechanism (lint rule) rather than only the criticism. In an interview, say which comments you would leave as questions — "is getOrders already main-safe here?" — because an experienced reviewer does not assert what they can cheaply ask.
Review 2
Compose screen
@Composable
fun ProductList(viewModel: ProductViewModel) {
val state by viewModel.state.collectAsState()
val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
LaunchedEffect(state) { viewModel.trackScreenView() }
LazyColumn {
items(state.products) { product ->
Row(Modifier.clickable { viewModel.onClick(product) }) {
Text(product.name)
Text(formatter.format(product.releaseDate))
AsyncImage(model = product.imageUrl, contentDescription = null)
}
}
}
}Expert review
- [Blocking]
LaunchedEffect(state)re-fires on every state change, so the screen-view event is sent dozens of times. Key it onUnit, or on a stable screen identifier. - [Blocking] no
keyinitems. Item state and animations attach to positions; an insertion at the head shifts everything. Addkey = { it.id }, andcontentTypeif rows are heterogeneous. - [Should fix]
collectAsStatekeeps collecting while backgrounded. UsecollectAsStateWithLifecycle(). - [Should fix] formatter allocated per composition and used per item. Format in the state mapper so the UI receives a
String;SimpleDateFormatis also not thread-safe. - [Should fix] the composable takes the ViewModel, so it cannot be previewed or screenshot-tested. Split into a stateful route and a stateless screen (Part VI).
- [Should fix] image not sized. Constrain the request to the display size or a fast scroll will decode full-resolution bitmaps.
- [Consider]
contentDescription = null— is the image decorative? If it conveys the product, it needs a description.
Review 3
Repository
@Singleton
class UserRepository @Inject constructor(private val api: Api) {
private val cache = mutableMapOf<String, User>()
suspend fun getUser(id: String): User? {
cache[id]?.let { return it }
return try {
val user = api.getUser(id)
cache[id] = user
user
} catch (e: Exception) {
null
}
}
fun clearCache() { cache.clear() }
}Expert review
- [Blocking] the map is not thread-safe and this is a singleton. Concurrent writes from different screens can corrupt it. Use a mutex, a concurrent map, or move the cache to the database.
- [Blocking] errors collapse into
null. The caller cannot distinguish "no such user" from "network down" from "auth expired", so the UI cannot offer retry, and a token expiry silently looks like a missing user. Return a result type or let the exception propagate. - [Blocking]
catch (e: Exception)swallows cancellation. - [Should fix] unbounded cache with no invalidation. It grows for the process lifetime and never refreshes, so a user who edits their profile keeps seeing the old one. What is the intended staleness policy?
- [Should fix] no way to observe changes. If another screen or a sync worker updates this user, nothing propagates. Consider Room as the source of truth with an observable query.
- [Consider]
clearCache()suggests callers are managing invalidation manually, which never stays correct. Whose responsibility is it?
Review 4
Concurrency
class UploadManager(private val api: Api) {
private var isUploading = false
private val queue = mutableListOf<File>()
suspend fun upload(file: File) {
queue.add(file)
if (isUploading) return
isUploading = true
while (queue.isNotEmpty()) {
val f = queue.removeAt(0)
api.upload(f)
}
isUploading = false
}
}Expert review
- [Blocking] check-then-act race. Two coroutines can both read
isUploading == falseand both start draining, processing the same file twice — and uploads are rarely idempotent. - [Blocking] the list is mutated concurrently without synchronisation.
- [Blocking]
isUploadingis never reset on failure. One thrown exception leaves ittrueforever and uploads stop silently for the rest of the session — the worst kind of bug, because there is no error anywhere. - [Blocking] no durability. The queue is in memory; process death loses queued uploads the user believes are pending. Uploads belong in WorkManager or a persisted outbox (Part XX).
- [Should fix] no retry, no backoff, no cancellation handling.
The Staff comment: "Rather than fixing the mutex here, I'd replace this with the outbox pattern we already use for messages — this class is re-implementing durability and will keep acquiring the same bugs. Happy to pair on it."
Review 5
Flow usage
class FeedViewModel(private val repo: FeedRepository) : ViewModel() {
val feed: Flow<List<Post>> = repo.observeFeed()
val unreadCount: Flow<Int> = repo.observeFeed().map { it.count { p -> !p.read } }
private val _events = MutableSharedFlow<Event>(replay = 1)
val events: SharedFlow<Event> = _events
}Expert review
- [Blocking]
replay = 1on an event flow. After rotation the new collector immediately receives the last event, so navigation happens twice. Events needreplay = 0with buffered capacity, or aChannel. - [Blocking] the upstream is collected twice.
observeFeed()is cold, sofeedandunreadCounteach start their own — two database queries or two network calls. Derive both from onestateIn. - [Should fix] exposing a raw
Flowto the UI means no initial value and re-collection on every configuration change. UsestateIn(viewModelScope, WhileSubscribed(5_000), initial). - [Should fix] no error handling. An exception in the upstream cancels the collector and the screen goes blank with no message. Model failure as state.
- [Consider]
_eventsis exposed by upcast only —asSharedFlow()is clearer about intent.
Reviews 6–15
Defect catalogue for self-practice
Each of these is a realistic snippet type; the defect list is what an expert review would surface. Write your own review first.
| Snippet | Planted defects |
|---|---|
| 6. Fragment with a listener | Registered against application context; not unregistered; binding held past onDestroyView; anonymous inner class capturing the fragment. |
| 7. Worker doing sync | Not unique work (duplicates); no backoff; returns Result.failure() for transient errors; holds a wakelock without timeout; assumes it runs immediately. |
| 8. Deep-link handler | No host validation; opens a WebView at an arbitrary URL; unvalidated ID parsed straight into a query; auth check performed after the screen renders. |
| 9. Room DAO and migration | fallbackToDestructiveMigration in release; unbounded observable query; missing index on the filter column; relation query without @Transaction. |
| 10. OkHttp setup | Trust-all manager left from staging; logging interceptor with headers in release; no timeouts configured; refresh implemented in an interceptor rather than an Authenticator. |
| 11. Over-abstracted feature | Six one-line use cases; an interface per class with one implementation; a mapper per layer with identical fields; a factory for a data class. |
| 12. Test file | delay() to wait for async work; mocks verifying call counts; shared mutable state between tests; a real database with no reset; assertion on a formatted string that changes with locale. |
| 13. Custom view / Canvas | Paint and Path allocated in onDraw; no save/restore balance; invalidate called from a background thread; no onDetachedFromWindow cleanup for the animator. |
| 14. DI module | @Singleton on something holding an Activity context; scope mismatch producing two instances; field injection where constructor injection is possible; a provider doing I/O. |
| 15. Feature-to-feature import | Direct dependency between two feature modules; a shared model duplicated with drift; navigation by string route with no type safety; a boundary crossed that the build rules should have blocked. |
Chapter 144Review as a leadership instrument
At Staff level, review is one of the few mechanisms that scales your judgment. Four practices worth being able to describe:
- Encode repeated feedback. If you have left the same comment three times, it becomes a lint rule, a template, or a documented default. Repeating yourself in review does not scale and eventually reads as nitpicking.
- Review the design, not the diff, when the diff is too late. A 2,000-line PR implementing the wrong architecture cannot be fixed in review. Push the conversation earlier — a design doc or a 20-minute call before the work starts.
- Disagreeing with a senior author: state the concern, the concrete failure scenario, and the cost — then explicitly say whether it blocks. "I think this races when two screens load simultaneously; here's the interleaving. If you disagree, I'm fine merging — but I'd want a test either way."
- Review SLAs matter more than review depth. A team where PRs wait two days ships worse code overall, because authors batch changes into larger PRs that are harder to review. Advocating for fast review is a quality intervention, not a velocity one.
Part XXI rapid recall
- Label every comment blocking / should fix / consider / note.
- Lead with user harm: leaks, races, data loss, logged credentials.
- Watch for the recurring set:
GlobalScope,catch (Exception), Context in a ViewModel, missing keys, unshared cold flows, replay on event flows, unbounded caches, check-then-act. - Ask rather than assert where a question is cheaper.
- Name the precedent, not just the bug — that is the Staff signal.
- Encode repeated feedback into lint; move architecture debates before the diff.
Part XXII
Architecture Review
Ten flawed architectures to critique, with the migration plan that keeps production running — because "this is wrong" is a Senior answer and "here is the sequenced path out, and what it costs" is a Staff one.
Chapter 145A critique method
Use the same five steps every time. It stops you from listing preferences and forces you to distinguish real problems from taste.
- What breaks, concretely? Name a failure scenario, not a principle. "This violates SRP" is weak; "two teams editing this file conflict every sprint, and last quarter it caused three revert-and-reland cycles" is not.
- Who pays, and how often? Every build, every new hire, every incident, every feature.
- What would you change? The target state, in one diagram.
- How do you get there without stopping? Sequence, first increment, rollback.
- What is the trade-off of your own proposal? If you cannot name one, you have not thought about it and the interviewer will notice.
Chapter 146Ten broken architectures
Architecture 1
"Everything lives inside the ViewModel"
What breaks: business rules cannot be tested without Android or a coroutine harness; the same rule is re-implemented in three ViewModels and drifts; a 2,000-line file is a permanent merge-conflict zone; and the rules cannot be reused by a Worker or a widget.
Change: extract pure domain logic into plain Kotlin classes with no framework dependency. Not a use case per method — one per genuine rule cluster (Part VII, Chapter 49).
Migration: purely additive and safe. Extract the logic behind existing tests, delegate from the ViewModel, verify behaviour is unchanged. Ship per screen. No flag needed because there is no behaviour change — which is exactly why it is the right first step in any larger cleanup.
Trade-off: more files, and a rule about where logic belongs that new engineers must learn.
Architecture 2
"The repository exposes UI state"
interface ProductRepository {
fun observe(id: String): Flow<ProductUiState> // Loading / Error / Ready
}What breaks: the data layer now owns presentation decisions, so two screens wanting different loading behaviour cannot share it; the repository cannot be reused by a Worker (which has no UI state); errors are pre-formatted, often already localised, so testing asserts on strings; and every UI change requires a data-layer change.
Change: the repository emits domain data or throws domain errors. The state holder maps that into Loading/Ready/Failed, because loading is a UI concept — it describes the view's relationship to the data, not the data.
Migration: add the domain-typed method alongside the existing one, migrate screens one at a time, delete the old method when the last caller is gone. Standard strangler.
Architecture 3
"Every feature depends on :core, and :core has 200 files"
flowchart LR
subgraph BEFORE["before — one edit rebuilds everything"]
F1[":feature:a"] --> C1[":core (200 files, no owner)"]
F2[":feature:b"] --> C1
F3[":feature:c"] --> C1
end
subgraph AFTER["after — split by capability, one owner each"]
G1[":feature:a"] --> N[":core:network"]
G1 --> DS[":core:designsystem"]
G2[":feature:b"] --> DS
G2 --> DB[":core:database"]
G3[":feature:c"] --> N
end
What breaks: any change to :core recompiles the whole app — the single biggest build-time cost in most large Android codebases; every team edits it, so it is a permanent conflict surface; it has no owner; and it accumulates unrelated things because "shared" is not a criterion.
Change: split by capability, not by layer: :core:network, :core:database, :core:designsystem, :core:analytics-api. Each with one owner. Anything used by fewer than three modules moves to its actual consumer.
Migration: measure first — which :core files actually change often, and which are on the critical build path. Extract the highest-churn, widest-dependency pieces first. Do it incrementally; a big-bang split of :core conflicts with every in-flight PR in the company.
Trade-off: more modules, a longer configuration phase, and more build files — which is why convention plugins come first.
Architecture 4
"All modules depend on :app"
What breaks: this inverts the dependency direction, so nothing can be built or tested without the whole application. Feature modules cannot have isolated tests, cannot be built in parallel, and the module graph provides no benefit at all — you have the build cost of modularization with none of the isolation.
Usually caused by: shared resources, a navigation constant, or a DI component living in :app.
Change: invert. :app depends on everything and contains only assembly. Whatever features need from :app moves into a contract module they can depend on (Part VIII, Chapter 56).
Migration: extract the shared pieces one at a time, lowest-dependency first. This one is usually mechanical but wide; do it with codemods and land it in a quiet week.
Architecture 5
"Every screen has its own database"
What breaks: multiple sources of truth for the same entity. Screen A updates the user, screen B still shows the old one; an offline write queue exists in three places; migrations must be written N times; and the disk cost multiplies.
Change: one database, one entity per concept, one owning module. Screens observe queries; they do not own storage.
Migration: genuinely risky, because it touches user data. Sequence: (1) designate the canonical store; (2) dual-write to it while still reading from the old ones; (3) migrate reads screen by screen behind flags; (4) verify with a consistency metric comparing the stores; (5) delete the old stores and their data only after a full release cycle at 100%. Never delete data in the same release that stops writing it — you need a rollback path.
Architecture 6
"One gigantic shared module contains everything"
The endpoint of taking "don't repeat yourself" as an unconditional rule. What breaks: everything recompiles on any change; ownership is diffuse; unrelated concepts are coupled through it; and it grows monotonically because adding to it is always the path of least resistance.
Change and the counter-intuitive part: some duplication is correct. Two verticals with similar-but-independent models should each own theirs; forcing a shared model couples their release schedules and produces a type that satisfies neither. State this explicitly in an interview — interviewers expect a DRY reflex and reward the nuance.
Architecture 7
"Business logic in composables"
What breaks: logic runs inside recomposition, so it executes an unpredictable number of times — a side effect there fires repeatedly; it cannot be unit-tested without a Compose test harness; and it cannot be reused by any non-UI caller.
Change: composables read state and emit events. Anything conditional beyond presentation moves to the state holder. The heuristic worth quoting: if you would want to write a unit test for it, it does not belong in a composable.
Architecture 8
"Singletons hold mutable app state"
What breaks: after process death the singleton is reconstructed empty while the UI assumes it is populated — presenting as a spurious logout or an empty screen (Part IV, Chapter 27). It is also globally mutable from anywhere, untestable without reset hooks, and prone to races.
Change: state that matters is persisted and observable; the singleton becomes a cache in front of a store, not the store itself. Test: if this value disappeared right now, would the app misbehave? If yes, it must be persisted.
Architecture 9
"A domain layer that only forwards calls"
What breaks: pure cost. Forty use-case classes, forty DI bindings, forty tests asserting delegation, and a dependency graph that is harder to read because the real dependencies are hidden behind indirection.
Change: delete the pass-throughs; keep use cases with real logic. This is the rare architecture review where the correct recommendation is less architecture — and being willing to say so is a genuine signal, because most candidates only ever propose adding structure.
Trade-off, stated honestly: mixed conventions (some screens call repositories, some call use cases) confuse newcomers. Mitigate with a written rule — "use cases exist for logic, multiple sources, or reuse" — rather than by restoring uniform ceremony.
Architecture 10
"DI graph with runtime cycles and unclear scoping"
What breaks: a cycle resolved lazily produces initialisation-order bugs that appear only on certain navigation paths; scope mismatches silently create two instances of something intended to be single (so a cache exists twice and neither is coherent); and startup time grows because eagerly-scoped objects are constructed before they are needed.
Change: a compile-time-validated graph (Part V, Chapter 38); explicit scoping rules per object class; and a startup trace to catch anything expensive constructed eagerly.
Diagnostic worth mentioning: log or assert instance identity for objects that must be singletons. "Why is my cache empty?" is frequently two instances rather than a caching bug, and teams lose days to it.
Chapter 147Writing the ADR that makes it stick
A critique that ends in a conversation changes nothing. The artefact that changes behaviour is a short written decision, and interviewers sometimes ask you to produce one verbally.
Status Accepted · 2026-03-14 · Owner: @mobile-arch · Review: 2027-Q1
Context Three repositories emit Flow<XUiState>. Two screens now need
different loading behaviour and cannot share them. Worker code
duplicates the data path. Error strings are localised in the
data layer, so tests assert on user-facing copy.
Decision Repositories expose domain models and throw/emit domain errors.
Mapping to UI state happens in the state holder.
Alternatives
Keep as is — rejected: blocks reuse, couples layers.
UI state in domain — rejected: same coupling, moved.
Per-screen repos — rejected: multiplies sources of truth (see ADR-009).
Consequences
+ Repositories reusable by Workers and widgets; faster tests.
− One mapping layer per screen; ~15 files to migrate.
Enforcement: lint rule bans `UiState` types in :core:data-* signatures.
Reversal If mapping proves to be pure duplication across >80% of screens,
revisit with a shared mapper rather than by re-coupling layers.
Migration New code follows immediately. Existing: additive method, migrate
per screen, delete old method when callers reach zero. ~2 sprints,
no flag required (no behaviour change).The two sections that make it real are Enforcement and Reversal. Without enforcement it is a preference; without a reversal condition it is dogma. Both are what a Staff interviewer is listening for.
Part XXII rapid recall
- Critique with concrete failure scenarios and who pays, not with principles.
- Always propose the migration sequence, first increment, and rollback — not just the target.
- Name the trade-off of your own proposal or the critique reads as ideology.
- Sometimes the right answer is less architecture; be willing to say it.
- Never delete data in the same release that stops writing it.
- An ADR needs enforcement and a reversal condition, or nothing changes.
Part XXIII
Behavioral & Leadership
Where Staff offers are most often lost. Not because candidates lack the experience, but because they narrate projects instead of demonstrating judgment, scope and agency.
Chapter 148How Staff behavioral rounds are evaluated
Interviewers are filling in a rubric with roughly these rows. Everything you say is being sorted into them.
| Signal | Weak evidence | Strong evidence |
|---|---|---|
| Scope | "My team's feature" | Work spanning teams, or a system nobody owned |
| Agency | "My manager asked me to" | "I noticed, I proposed, I got it funded" |
| Judgment | Describes what happened | Explains the options considered and why this one |
| Impact | "It went well" | A number, and how it was measured |
| Self-awareness | Failures are other people's | Names their own contribution to the failure specifically |
| Collaboration | "I convinced them" | "I understood their constraint and changed my proposal" |
STAR, adapted for technical leadership
Standard STAR under-serves Staff answers because it has no slot for the two things being assessed: the alternatives you weighed and the mechanism you left behind. Use STAR-DL:
Situation 20s Context and stakes. Include the scale: users, teams, money. Task 15s What was actually yours, and why it was ambiguous or hard. Action 60s What YOU did. Include the alternatives you rejected. Result 30s A measured outcome. A number, with how you measured it. Decision 20s The key judgment call and what would have changed it. Learning 20s What you do differently now — and where you have applied it since.
Say "we" for the work and "I" for the decisions. Candidates who say "I" throughout read as credit-takers; candidates who say "we" throughout leave interviewers unable to score them and default to a lower level. The fix is one explicit sentence per story: "The part that was specifically mine was…"
Chapter 149Ten questions, fully graded
Question 1 · influence
"Tell me about a time you influenced a technical decision without authority."
What is being evaluated
Whether you can move an organisation, and whether your mechanism was evidence or seniority. Interviewers are listening for a prototype, data, or an absorbed cost — not "I explained why it was better."
Model answer L4
"Our four Android teams each had their own networking setup — different retry behaviour, different error mapping. It caused a real incident: one team's client retried 500s indefinitely and amplified a backend outage from a blip into twenty minutes.
Nobody owned it and I had no authority over those teams. I did three things. I quantified the cost: I pulled six months of incidents and found four with a networking root cause, plus roughly a day per team per quarter maintaining their own client. Then I built the shared client as a working prototype and migrated one team's screens myself — I absorbed the cost so their first experience was reviewing a PR, not doing a project. And I asked each team lead what they actually needed; the payments team had a hard requirement about not retrying certain calls, which my first design got wrong. Changing it for them was what turned a skeptic into an advocate.
Results: all four teams migrated in two quarters, zero networking-root-cause incidents in the following six months, and the retry-storm class was eliminated because the shared client has jittered backoff and a circuit breaker by default.
The judgment call was absorbing the migration cost for the pilot. It was expensive — about three weeks of my time — but a mandate without absorbed cost produces compliance theatre. What I would do differently: I should have written the ADR at the start rather than after the second team adopted it. Two engineers independently re-litigated decisions I had already made and I had nothing to point at."
Question 2 · failure
"Tell me about a significant technical failure you were responsible for."
What is being evaluated
Self-awareness and whether you fixed the system or just the bug. The two failure modes: choosing a trivially small failure (reads as evasive), and blaming circumstances.
Red flags interviewers note
- The failure is really someone else's, told from your perspective.
- "We didn't have enough time" as the root cause with no examination of your own choices.
- A failure so minor it demonstrates nothing.
- No systemic fix — the story ends with "we fixed the bug."
Model answer L4
"I designed an offline sync engine that lost user data for about 200 people. The bug: I advanced the sync cursor outside the transaction that applied the changes, so a crash between the two silently skipped a page of updates. It was invisible — no crash, no error — and we found it three weeks later from a support pattern, not from monitoring.
Immediate response: I disabled sync via remote config for affected users, wrote a reconciliation job that rebuilt state from the server, and personally reviewed the affected accounts. We contacted every user.
My contribution to the failure was more than the one-line ordering mistake. I had tested sync on the happy path and on total failure, but not on partial failure — and partial failure is the defining characteristic of the problem domain. I knew that, and I still didn't build the fault-injection harness because we were behind schedule.
The systemic fixes: a fault-injection test harness that kills the process at every await point in the sync loop, which is now used by two other teams; a sync-lag metric with alerting, so silent data problems become visible within an hour instead of three weeks; and a review checklist item for any cursor-advancing code. The last one is the cheapest and has caught it twice since.
What I carry from it: for anything involving data integrity, I now design the observability before the feature. If a failure can be silent, it will be silent for weeks."
Question 3 · conflict
"Tell me about a technical disagreement with another senior engineer. How did it resolve?"
What is being evaluated
Whether you can disagree productively and, critically, whether you can lose well. Interviewers are wary of Staff candidates who cannot be overruled.
Strong answer shape
Include: the substance of both positions stated fairly (if you cannot state theirs convincingly, you did not understand it); how you tried to make it empirical rather than a matter of taste; how the decision was actually made; and — the highest-value element — a case where you were wrong or where you disagreed and committed.
"Our staff iOS engineer wanted a shared KMP sync layer; I thought the coordination cost exceeded the benefit. We were both arguing from experience with nothing to test. We agreed on a time-boxed spike with success criteria set in advance — iOS build time impact and whether iOS engineers could still work independently. The spike showed a build-time cost we could live with, and I was wrong about the independence concern because the interface boundary was cleaner than I expected. We adopted it for the domain layer only. What I would keep from that: turning a disagreement into a falsifiable experiment with agreed criteria, before either of us was publicly committed to a position, is what let me change my mind without it being a defeat."
Question 4 · saying no
"Tell me about a time you said no to a senior stakeholder."
What is being evaluated
Whether you can defend engineering constraints in business language rather than by asserting technical authority. "I told them it wasn't possible" is a weak answer; so is "I did it anyway and we suffered."
The shape that works
Never say no to the goal; say no to the plan, and bring an alternative. "We can't ship the full redesign in six weeks with an acceptable crash rate. Here are three options: the full scope in ten weeks; the top two screens in six with the rest following; or six weeks at full scope with a quality risk I'd estimate at roughly double our current crash rate for a month. My recommendation is the second, and here's why." That converts a refusal into a decision the stakeholder owns, with the trade-off explicit.
Question 5 · mentorship
"Tell me about someone you mentored who wasn't improving."
What is being evaluated
Whether you diagnose rather than repeat. Also whether you know the boundary between mentoring and managing — a Staff engineer who tries to performance-manage someone is overstepping, and one who ignores a real performance problem is under-serving the team.
Strong elements: distinguishing skill gaps from motivation gaps from context gaps (a strong engineer failing because they were given ambiguous work with no context is common and frequently misread as a skill problem); changing the approach when the first one did not work; involving their manager appropriately; and an honest outcome — including one where the person left, if that is what happened.
Question 6 · incident leadership
"Walk me through an incident you led."
What is being evaluated: composure, structure and whether you fixed the class. Structure the answer as: detection (how long, and why not faster), triage and severity call, mitigation (with the reasoning for choosing it over alternatives), communication cadence and to whom, resolution, postmortem, and the systemic changes that shipped.
The Staff differentiator: spending as much time on "why did it take 90 minutes to detect" as on the bug itself. Detection time is almost always the more valuable fix, and most candidates skip it entirely.
Question 7 · prioritisation
"You have three critical things and capacity for one. How do you decide?"
Framework worth stating: reversibility (which decision closes doors), blast radius (who is affected if it goes wrong), decay (which gets more expensive if delayed), and dependency (which unblocks others). Then — the part that distinguishes levels — make the trade-off visible to the people affected rather than silently absorbing it. A Staff engineer who quietly drops the third thing has created a surprise; one who says "we are not doing X this quarter, here is the risk we are accepting" has made a decision.
Question 8 · technical debt vs deadlines
"How do you balance engineering quality with business deadlines?"
Weak answer: "Quality always comes first." It is untrue and signals inflexibility.
Strong answer: distinguish the quality that is negotiable from the quality that is not. Non-negotiable: data integrity, security, anything irreversible, anything that would require a user-visible migration to fix later. Negotiable: test depth on low-risk paths, refactoring, abstraction, polish. Then make the shortcut explicit — recorded, with an owner and an expiry — so it is a decision rather than a slide. "I take on debt deliberately and visibly; what I refuse is undocumented debt, because that is the kind that compounds silently."
Question 9 · ambiguity
"Tell me about a time you worked on something with no clear requirements or owner."
What is being evaluated: this is the single most Staff-specific question in the set. Ambiguity tolerance is the defining behavioural trait of the level.
Strong shape: you produced the missing definition (a written proposal, a scoping document, a prototype that made the choice concrete), you got it ratified by the people who would have to live with it, and you did it without waiting for permission. The best versions include the fact that the problem was not initially recognised as a problem by anyone else.
Question 10 · hiring and calibration
"How do you calibrate when interviewing? Tell me about a hire you got wrong."
Increasingly asked at Staff level, because you will be a significant fraction of the hiring signal. Strong elements: a consistent rubric rather than a vibe; awareness of specific biases (recency, similarity, over-weighting communication polish over substance); writing feedback with evidence rather than conclusions; and being willing to be the dissenting voice in a debrief.
For the "got wrong" half, the most useful answers describe a false negative you later recognised — because everyone can describe a bad hire, and recognising that your bar was miscalibrated in the other direction shows more self-examination.
Chapter 150Forty more questions, grouped
| Theme | Questions |
|---|---|
| Ownership | A system you owned that nobody assigned · something you deprecated · a process you created · work you inherited in a bad state · something you chose not to fix. |
| Influence | Changing a team's mind with data · a proposal that was rejected · building consensus across orgs · convincing product to fund engineering work · a standard you established. |
| Conflict | Disagreeing with your manager · two teams blocked on each other · a peer whose code you consistently had to reject · escalating appropriately · a decision you disagreed with and committed to. |
| Failure | A project that failed · a design you got wrong · an estimate you missed badly · an incident you caused · a hire or a mentee that did not work. |
| Ambiguity | Starting with no requirements · conflicting stakeholder goals · deciding with insufficient data · a problem nobody had noticed · killing your own project. |
| Mentorship | Growing someone to the next level · giving hard feedback · delegating something you wanted to do · improving a team's review culture · onboarding at scale. |
| Strategy | A multi-quarter technical plan · a build-vs-buy decision · a bet that did not pay off · aligning technical and product roadmaps · what you would change about your current architecture. |
| Operating | On-call improvements you made · a postmortem you ran · a metric you introduced · reducing a chronic source of toil · a rollout you halted. |
Chapter 151Building your story portfolio
You need eight stories, not forty. Each strong story answers four or five questions with a shift of emphasis. Build the matrix explicitly:
Story Scope Influence Conflict Failure Ambiguity Metric
1. Shared networking client ✓✓ ✓✓ ✓ ✓ ✓
2. Sync data-loss incident ✓ ✓ ✓✓ ✓✓
3. Modularization programme ✓✓ ✓ ✓ ✓✓ ✓✓
4. KMP disagreement ✓ ✓✓ ✓
5. Mentee who plateaued ✓ ✓ ✓
6. Said no to a redesign date ✓✓ ✓✓ ✓ ✓
7. Startup performance push ✓ ✓ ✓ ✓✓
8. Deprecating a loved system ✓✓ ✓✓ ✓✓ ✓
↑ every column needs 2+Extracting the numbers. Most candidates have the impact but not the figure. Before the loop, dig: crash-free rate before and after, build time, incident counts, support ticket volume, conversion or retention deltas, engineer-hours saved. An approximate honest number ("roughly a day per team per quarter, from asking the four leads") is far stronger than no number, and interviewers do not expect audited precision.
Rehearsal. Out loud, timed, to three minutes. The gap between a story you can recall and one you can tell is enormous under pressure. Record yourself once — it is unpleasant and it is the fastest correction available.
If a story is weak, do not inflate it. "This one didn't have the impact I hoped, and here's what I misjudged" scores better than an exaggerated outcome, because interviewers cross-check details across rounds and inconsistency is fatal.
Part XXIII rapid recall
- Scope, agency, judgment, impact, self-awareness, collaboration — everything you say is sorted into these.
- STAR-DL: add the Decision and the Learning; that is where Staff signal lives.
- "We" for the work, "I" for the decisions — say one explicit sentence isolating your part.
- Influence stories need a mechanism: prototype, data, or absorbed cost.
- Failure stories need a systemic fix, not just a bug fix.
- Never say no to the goal; say no to the plan and bring options with costs.
- Eight stories mapped across every theme, each with a real number.
- Rehearse aloud, timed. Do not inflate — interviewers cross-check across rounds.
Part XXIV
Organization-Scale Architecture
The questions that only appear at Staff and above, where the binding constraint is people rather than technology. There are no correct answers here — only defensible ones.
Chapter 152Governance without becoming a bottleneck
Every architecture-governance model sits on a spectrum between two failure modes: anarchy (twelve teams, twelve architectures, nothing shared) and the bottleneck (one person approves everything, and they are on holiday). The mechanisms below are ordered by how well they scale.
| Mechanism | Scales to | Cost / failure mode |
|---|---|---|
| Automated enforcement (lint, build rules, CI gates) | Unlimited | Only encodes mechanically-checkable rules; a bad rule is expensive to remove |
| Paved road (templates, generators, defaults) | Very large | Requires an owning team; rots if not maintained |
| ADRs (written, searchable, owned) | Large | Only works if people read them; needs a discovery mechanism |
| Architecture review for significant changes only | Medium | Requires a clear threshold, or everything becomes significant |
| Individual review by the architect | Small | The bottleneck. Does not survive growth or absence |
Rule: "All architectural changes must be approved
by the Architecture Council, which meets Thursdays."
Outcome after two quarters:
· A two-week queue for a one-day decision
· Teams reclassify work to avoid the label
· The council reviews trivia and misses the real drift
· The architect becomes a full-time reviewer and stops building
Approval-based governance scales linearly with the reviewer's time and inversely with the org's velocity. Teams route around it, and the routing is invisible.
Default path (no approval needed): · Generate the module from the template · Build rules enforce dependency boundaries automatically · Lint encodes the decisions from published ADRs Escalation required only for: · A new cross-team dependency · A new persistence or transport technology · Anything shipped to users that cannot be reversed (data format, public SDK API, sync protocol) Escalation is: write a one-page ADR, get two named reviewers, 48-hour SLA. Not a meeting.
Ninety-plus percent of work needs no human gate because the paved road already encodes the decisions. The remaining decisions are genuinely one-way doors and deserve deliberation.
Chapter 153Funding change with no immediate product value
Interview question · very common at Staff and above
"How do you convince teams to migrate to a new architecture when there's no immediate product value?"
Answer L4
"I'd start by testing the premise, because 'no product value' sometimes means 'no value' and I would rather find that out before spending a quarter. If it holds, four moves.
Translate to a business unit. Not 'the architecture is cleaner' but 'this costs us roughly 1.5 engineer-days per feature in workarounds and produced four incidents last year.' That converts to engineer-months and incident risk, which is a language product leadership can trade against.
Shrink the first increment until it fits inside work teams are already doing. New screens use the new path; nobody backfills. Migration then has near-zero marginal cost, and the codebase converges over time without a project.
Absorb the cost for the pilot. I do the first team's migration myself so their experience is reviewing a PR. The second team gets a codemod and a template; the third gets documentation and a lint rule. My involvement decays deliberately — otherwise I become the migration department.
Attach it to something they already want. Our networking migration landed because it also fixed a flaky-test problem that was costing one team an hour a day. That was the actual reason they adopted it, and it is fine that it was.
What I would not do is mandate it. A mandate without absorbed cost produces compliance theatre — teams add the wrapper, keep the old path, and now you have both."
Follow-ups
- "When do you abandon it?" — When the pilot's numbers do not reproduce, when the team who must own it afterwards will not, or when a platform shift makes the target obsolete. I would rather stop at 30% than have a permanently half-migrated codebase carrying both costs.
- "How do you handle the half-migrated state?" — Treat completion as the deliverable, not adoption. Publish a burn-down of remaining call sites; a migration without a visible end date does not finish.
- "What if leadership just wants features?" — That is a legitimate position. My job is to make the cost visible and let them choose; if they choose features, I record the decision and revisit when the cost changes. Disagree and commit, with a date.
Chapter 154Measuring architecture and preventing drift
"How do you know your architecture is working?" is the question most candidates cannot answer, because architecture feels unmeasurable. It is not — you just have to measure its effects.
| Type | Metric | What it tells you |
|---|---|---|
| Leading | Median incremental build time | Module graph health; the metric engineers feel daily |
| Leading | PRs touching more than one team's modules | Boundary quality. Rising = boundaries are wrong |
| Leading | Boundary-violation count (lint suppressions) | Drift, before it becomes structural |
| Leading | Time to first merged commit for a new hire | Comprehensibility, and it is easy to collect |
| Lagging | Change failure rate; incidents per release | Whether the structure actually reduces defects |
| Lagging | Lead time from merge to production | Whether the delivery pipeline matches the structure |
| Lagging | Effort per feature over time | The real question: is the system getting easier or harder to change? |
Drift is what happens between reviews: a boundary crossed "just this once", a lint suppression added under deadline, a second source of truth introduced by a team who did not know about the first. The counter-measures are mechanical, because vigilance is not a strategy — dependency rules in the build, a lint baseline that can only shrink, a periodic report of new suppressions with owners, and a standing budget so teams have a way to fix drift that does not require permission.
Chapter 155Twenty org-scale scenarios
Scenario 1 · the classic
"You inherit an Android app with 5M DAU and 500 engineers. The architecture has become impossible to change. What do you do?"
The trap
Proposing a target architecture in the first minute. With 500 engineers, the constraint is coordination, not design — and any answer that does not acknowledge that is scored as Senior.
Answer L4 L5
"First 30 days: measure, do not propose. I would want four things. Where does engineering time actually go — build waits, merge conflicts, cross-team blocking, incident response? What does the change-failure rate look like by area? Which files and modules have the highest churn and the highest defect density, since those are where the architecture is hurting most? And what do the engineers say — 20 conversations across teams surfaces the real pain faster than any dashboard.
'Impossible to change' is a symptom with several possible causes: build times, coupling, missing tests, unclear ownership, or fear from past incidents. They have completely different fixes, and choosing before diagnosing is how these programmes fail.
Next 60 days: one visible win. Pick the highest-pain, lowest-risk item — usually build time or test flake — and fix it measurably. With 500 engineers, credibility is the currency for everything after, and nobody grants it based on a strategy document.
Then: a written strategy with a small number of invariants. Not a target architecture diagram — three or four rules that every team can follow independently: features do not depend on features; one owner per module; one source of truth per domain concept; new code uses the paved road. Invariants scale to 500 engineers in a way that a diagram does not.
Then: make the right thing the easy thing. Templates, generators, automated boundary enforcement. At this size, mechanisms beat mandates by an enormous margin — a lint rule reaches 500 engineers and a wiki page reaches none.
Sequencing: stop the bleeding first (enforce boundaries on new code), then migrate the highest-value areas, then the long tail — accepting that some of the long tail will never migrate and that is an acceptable outcome. A permanently half-migrated codebase is bad; a deliberately partially-migrated one with a documented boundary is fine.
What I would explicitly not do: a rewrite; a big-bang reorganisation; or an architecture I personally review. All three fail at this scale, and the third fails slowest, which makes it the most seductive.
Time horizon: 18–24 months for structural change at this size, with quarterly measurable milestones. Anyone promising two quarters has not done it."
| Scenario | The key move |
|---|---|
| 2. Standardise across 20 teams with different stacks | Standardise the interfaces between teams, not their internals. Mandate the seams, leave the insides alone. |
| 3. Quality vs a hard external deadline | Separate non-negotiable (data, security, irreversible) from negotiable; make the shortcut explicit with an owner and expiry. |
| 4. When should you not refactor? | Code about to be deleted; code nobody touches; code you do not understand and have no tests for; and when the team lacks capacity to finish — a half-refactor is worse than none. |
| 5. Chartering a mobile platform team | Charter it as a product with internal customers and satisfaction metrics, not as a service desk. Without that, it becomes a ticket queue within two quarters. |
| 6. Build vs buy at scale | Buy anything that is not differentiating; build what is. Then examine the exit cost — the real risk is a vendor you cannot leave, not the licence fee. |
| 7. Surviving a reorg mid-migration | Re-establish ownership immediately; migrations die when their owner changes teams. Get the new owner named in writing in the first week. |
| 8. Two teams building the same thing | Not a technical problem. Find out why — usually one team could not get the other's roadmap priority. Fix the incentive, then merge the code. |
| 9. A team consistently ships poor quality | Diagnose before intervening: capacity, unclear ownership, missing tooling, or skill. Only one of those is a people problem, and it is the least common. |
| 10. Deprecating a widely-used internal library | Announce with a date and a migration path; provide a codemod; track the burn-down publicly; do not delete until it reaches zero. Deprecation without a burn-down never completes. |
| 11. Establishing standards without a bottleneck | Encode in automation; escalate only one-way doors; publish ADRs; delegate the review authority to a named group, not a person. |
| 12. Justifying a platform investment to a CFO-minded audience | Engineer-hours converted to money, incident cost, and time-to-market. Never technical elegance. |
| 13. Mobile and backend disagree on an API contract | Escalate on the asymmetry: the server can deploy hourly, installed clients cannot update. Compatibility obligations therefore sit with the server, and that is a principle, not a preference. |
| 14. Ten thousand-line PRs are normal here | Attack the cause — usually slow review or long-lived branches — not the symptom. Fast review SLAs shrink PRs more reliably than a size limit. |
| 15. Adopting a technology you personally dislike | Evaluate against the org's constraints, state your reservation once with evidence, then commit fully and publicly. Visible reluctance from a Staff engineer poisons an adoption. |
| 16. An SDK your app depends on is abandoned | Risk-classify it, wrap it behind your own interface if it is not already, plan replacement, and add "wrapped and replaceable" to the third-party integration standard so the next one is cheaper. |
| 17. Feature flags have accumulated to 400 | Flags need expiry dates and owners at creation. Stale flags are untested code paths — a correctness risk, not just clutter. Automate the cleanup reminder. |
| 18. Engineers keep re-litigating settled decisions | The decision was not written down, or it was written where nobody looks. Publish ADRs, link them from the lint failure message — that is where people actually read. |
| 19. Everyone wants to work on the new thing, nobody on the old | Make maintenance visible and credited in promotion cases; rotate ownership; and be honest that some legacy work should be funded as a fixed cost rather than volunteered for. |
| 20. You are becoming the single point of failure | Write down what only you know; delegate a domain entirely, including the authority; and measure your success by decisions made well without you. |
Part XXIV rapid recall
- Governance scales through automation and defaults; approval-based governance does not.
- Escalate only genuine one-way doors; everything else follows the paved road.
- Fund change with engineer-months and incident counts, not elegance.
- Absorb the migration cost for the pilot, then decay your involvement deliberately.
- Measure architecture by build time, cross-team PRs, boundary violations, change failure rate, and effort per feature.
- Drift is countered mechanically — baselines that only shrink, suppression reports, standing budget.
- At 500 engineers: measure first, one visible win, then invariants — never a diagram or a rewrite.
- 18–24 months for structural change at scale, with quarterly milestones.
Part XXV
Traps & Trick Questions
Questions where the confident, conventional answer is the wrong one. The interviewer is not testing knowledge — they are testing whether you reason from context or recite from dogma.
Chapter 156The answer shape that works every time
Every question in this part has the same structure underneath, so the answer has the same shape:
1. Reject the universal. "No — and the interesting part is when it's wrong." 2. Name the criterion. "The decision turns on X, not on the technology." 3. Apply it here. "For a screen like this, X says …" 4. State the inversion. "It flips when Y — for example …"
Dogma: "Yes, always — it's best practice." Evasion: "It depends." The second is worse than it looks: it sounds senior but conveys nothing. "It depends on whether the data has other writers" is an answer; "it depends" is a stall.
Chapter 157Thirty questions where the obvious answer is wrong
Trap 1
"Should every repository expose Flow?"
No. The criterion is whether the data has other writers. If a sync worker, a push handler or another screen can change it, a one-shot read is a bug waiting to happen and you need observation. If it is a command — submit, upload, refresh — a Flow is worse than a suspend function: the caller must remember to terminate it, and errors become emissions instead of exceptions, losing structured concurrency's propagation. Inversion: if you later add offline sync, previously-static data acquires other writers, and the answer changes for those types specifically — not for all of them.
Trap 2
"Is Clean Architecture always better?"
No. The criterion is the expected rate and cost of change at each boundary. On a six-screen app with three engineers, the layering costs 3–5 files per feature and buys isolation from changes that will not happen. Its genuine win is a pure-Kotlin domain with millisecond tests — so if you are not writing domain tests, you are paying for the architecture and not collecting. Inversion: once several teams share domain concepts, or the domain rules outlive the current backend, the boundary starts paying for itself.
Trap 3
"Is MVI better than MVVM?"
Neither is better. Both are unidirectional with immutable state; the only real difference is whether input is funnelled through one channel. That funnel buys traceability and replayability, and costs an intent type, a reducer and more files per screen. On a settings screen it is pure cost; on a checkout flow with three concurrent async inputs it is worth it. A codebase using both, with a written rule about which goes where, is a sign of judgment — not inconsistency.
Trap 4
"Should every screen have a ViewModel?"
No. A ViewModel exists to survive configuration change and to hold state off the UI. A purely static screen — a legal text page, an onboarding illustration — has no state to survive. Adding one costs a file, a DI binding and a test nobody writes. Inversion: the moment it acquires an async load or any state that must survive rotation, it needs one. And note the opposite trap: several screens sharing an activity-scoped ViewModel is usually worse than each having its own.
Trap 5
"Should everything be immutable?"
Mostly, but not everything, and the exceptions are principled. Immutable state is correct for UI state and domain models — it is what makes Compose skipping and concurrent reads safe. But data class copy() on a large object in a hot loop allocates; a 5,000-element list copied per emission is a measurable performance problem. Use persistent collections for structural sharing, or a bounded mutable buffer behind an immutable interface. Inversion: profile first — this only matters in hot paths, and premature mutability is a much worse bug source than the allocation it saves.
Trap 6
"Should we always use coroutines?"
No. Work that must survive process death belongs in WorkManager — a coroutine of any scope dies with the process. Genuinely synchronous, fast, local computation does not need suspension and adds ceremony. And a callback API with a single call site does not need callbackFlow. Inversion: the moment there is cancellation, concurrency or a lifecycle relationship, coroutines are correct.
Trap 7
"Should we always use Compose?"
For new screens, yes by default. For migrating existing ones, no. The criterion is not which is better but what the migration costs against what it returns. A stable, working XML screen that nobody touches returns nothing for a rewrite. Interop at the leaf level — Compose items inside a RecyclerView — performs poorly and complicates state, so migrate whole screens or not at all. Inversion: a screen under active development, or one whose XML complexity is the reason changes are slow, is worth converting.
Trap 8
"Should we always use dependency injection?"
Yes to constructor injection; no to a DI framework in every case. The distinction matters: constructor injection is a design practice with no cost. A framework buys graph management and scoping, and costs build time (kapt/KSP across every module) and a learning curve. For a small app, manual construction at a composition root is genuinely simpler. For a library you publish, no framework at all — forcing your DI choice on consumers is a hostile API decision.
Trap 9
"Should repositories contain business logic?"
Some, and the line is worth being precise about. Data-access policy — caching, retry, choosing local versus remote, merging sources — is the repository's job and putting it elsewhere leaks storage concerns upward. Domain rules — eligibility, pricing, validation — are not, because they need to be testable and reusable without a data layer. The test: would this rule still exist if we changed how we store data? If yes, it is domain logic.
Trap 10
"Should we always modularize?"
No. Modularization's benefits — parallel builds, enforced boundaries, ownership — mostly accrue with team count, not line count. Below roughly three teams, the coordination and build-configuration overhead can exceed the benefit, and a poorly-shaped module graph builds slower than a monolith. Inversion: more than three teams, or a build already dominated by a single module's recompilation.
Trap 11
"Should we always cache?"
No, and this is the trap most people fall into. A cache adds a second source of truth, an invalidation problem and a staleness bug class. Do not cache data that is cheap to fetch and expensive to be wrong about — a price, an account balance, inventory. The criterion is the cost of staleness versus the cost of fetching. A wrong balance shown confidently is worse than a spinner.
Trap 12
"Should we always retry network requests?"
No, and getting this wrong causes outages. Retry only what is safe and transient: connection failures and 5xx with jittered backoff. Never retry 4xx — the request is deterministic and retrying hides the bug. Never blind-retry a non-idempotent write after a post-send timeout; you need an idempotency key first. And remember that a million clients retrying in unison is a self-inflicted denial of service — which is why jitter and circuit breaking are not optional extras.
Traps 13–30
Rapid versions
| Question | The criterion, and the inversion |
|---|---|
| 13. "Should everything be offline-first?" | Only where users author data or connectivity is unreliable. It roughly doubles every feature's state space. A price-comparison app's stale data is worthless. |
| 14. "Is 100% test coverage the goal?" | No. Coverage is a diagnostic. Teams told to hit a number write tests for getters. Target defect escape rate instead. |
15. "Should we always use StateFlow over LiveData?" | For new code yes, but LiveData is lifecycle-aware by default and StateFlow is not — a careless migration creates background collectors. The migration must be justified per module. |
16. "Is suspend always better than a callback?" | For request/response yes. For a multi-emission platform callback, callbackFlow — and for a single call site in Java-interop code, a callback is fine. |
| 17. "Should we use a use case for every repository call?" | No — pass-through use cases are pure ceremony. Introduce one for logic, multiple sources, or reuse. |
| 18. "Should domain models always differ from API models?" | Where the schemas have diverged or are expected to. Field-for-field mappers are a cost with no purchase. |
| 19. "Is a single-activity app always right?" | Almost always, but multi-activity is legitimate for genuinely separate task surfaces — a share target, a widget configuration, a picker launched by other apps. |
| 20. "Should we always target the newest API level?" | You must, for Play. But adopting new behaviour is separate from targeting, and each targetSdk bump needs a behaviour-change audit, not just a version edit. |
| 21. "Are microservices' lessons applicable to mobile modules?" | Partly. Boundaries and ownership transfer; independent deployment does not — you ship one binary, so module boundaries buy build time and clarity, not release independence. |
| 22. "Should the UI ever talk to the repository directly?" | Via a state holder, yes — skipping the domain layer is fine when there is no domain logic. Skipping the state holder is not. |
| 23. "Is Room always better than raw SQLite?" | Yes for almost all apps. The exception is a highly dynamic schema or query shape that Room's compile-time model cannot express — and then you still use Room's SupportSQLite layer. |
| 24. "Should we always encrypt local data?" | No. It costs performance and adds a key-loss failure mode where data becomes unrecoverable. Encrypt what is sensitive; classify first. |
| 25. "Should we pin certificates?" | Only with backup pins, an expiry, a kill switch and monitoring. Pinning without an operational plan is a way to brick your app remotely. |
| 26. "Is more abstraction safer?" | No. Each layer must be understood by everyone who changes it. Unused flexibility is a permanent tax paid for a change that may never come. |
| 27. "Should we always fix flaky tests immediately?" | Quarantine immediately, fix on a deadline. Leaving them in the blocking suite trains engineers to re-run failures, which is the actual damage. |
| 28. "Should feature flags be permanent?" | No. A flag is an untested code path; every one needs an owner and an expiry. Kill switches are the deliberate exception and should be labelled as such. |
| 29. "Should we always A/B test?" | No. It costs a variant's worth of complexity and needs enough traffic for significance. Test decisions that are contested and reversible; do not test obvious fixes or things you would ship regardless. |
| 30. "Should Staff engineers still write code?" | Yes, but selectively — the risky parts, the exemplars, the prototypes that make an argument. An engineer who writes none loses credibility and calibration; one who writes only features is not operating at the level. |
Chapter 158Disagreeing with the interviewer's premise
Sometimes the trap is embedded in the question: "How would you make sure the app never shows stale data?" or "How would you guarantee push notifications arrive within a minute?" Both premises are unachievable, and accepting them leads you into an answer that is confidently wrong.
1. Acknowledge the underlying goal.
"The goal is that users trust what they see — agreed, that matters."
2. Name the constraint plainly, without lecturing.
"Guaranteed freshness isn't achievable on a device that can be offline."
3. Offer the achievable version.
"What we can do is bound the staleness and make it visible: refresh
on foreground, show an 'as of' timestamp, and push-invalidate."
4. Check you've understood the intent.
"Is that the shape you're after, or is there a hard requirement
behind it I should design to?"That last step is what keeps it collaborative. Some interviewers are testing whether you push back; others genuinely have a constraint in mind. Asking distinguishes them, and costs nothing.
Part XXV rapid recall
- Reject the universal, name the criterion, apply it here, state the inversion.
- "It depends" is only an answer when followed by what it depends on.
- Flow vs suspend turns on whether the data has other writers.
- Clean Architecture pays when you actually write domain tests.
- Caching turns on the cost of staleness, not on whether caching is possible.
- Never retry 4xx; never blind-retry a non-idempotent write.
- Modularization pays with team count, not line count.
- Unachievable premises should be named, reframed, and confirmed — not accepted.
Part XXVI
Comparison Reference
Head-to-head comparisons on fixed dimensions, so they can be scanned side by side under pressure — plus how to turn a table into a spoken answer in ninety seconds.
Chapter 159How to speak a comparison
Reciting a table is a Senior answer. The Staff version leads with the criterion, gives the answer, then the caveat:
Criterion "The decision turns on whether the data has other writers."
Answer "So: Flow for the observable case, suspend for commands."
Mechanism "Because a cold Flow re-executes per collector, five screens
means five calls unless you share it — that's the trap."
Inversion "It flips if we add offline sync, because then everything
has another writer."Chapter 160Reactive and concurrency
Flow vs LiveData
| Dimension | Flow / StateFlow | LiveData |
|---|---|---|
| Use case | Any observable data, any layer | UI-layer observation in legacy codebases |
| Advantages | Rich operators, structured concurrency, platform-independent (KMP), backpressure control | Lifecycle-aware by default; near-zero misuse surface |
| Disadvantages | Not lifecycle-aware — needs repeatOnLifecycle or collectAsStateWithLifecycle | Android-only, main-thread bound, almost no operators, no backpressure |
| Complexity | Higher: sharing, scope, cancellation are yours to get right | Low |
| Testing | Turbine plus virtual time; excellent once set up | InstantTaskExecutorRule plus observer boilerplate |
| Staff view | Default for new code. The migration risk is losing lifecycle awareness silently — that is the thing to check in review. | Not a defect. Migrate when touching the code, not as a project. |
StateFlow vs SharedFlow vs Channel
| Dimension | StateFlow | SharedFlow | Channel |
|---|---|---|---|
| Semantics | Always a current value; conflated; distinct-until-changed | Configurable replay and buffer; no initial value | Point-to-point; each element consumed once |
| Consumers | Many | Many | One (effectively) |
| Right for | UI state | Events with multiple observers | One-shot events with one observer |
| Failure mode | Drops intermediate values — never put events in it | replay > 0 re-fires events after rotation | Events lost if nothing is collecting; gone on rotation once consumed |
| Staff view | State in StateFlow, events in a Channel exposed via receiveAsFlow() for the single-consumer case. There is no loss-free option — name which loss you are accepting. | ||
suspend vs Flow · RxJava vs Coroutines
| Dimension | suspend function | Flow |
|---|---|---|
| Shape | One request, one result | Zero to many results over time |
| Errors | Exceptions with structured propagation | Emissions or terminal failure; needs catch |
| Right for | Commands: submit, refresh, upload | Observable state with other writers |
| Trap | — | Cold: N collectors = N executions unless shared |
| Dimension | RxJava | Coroutines / Flow |
|---|---|---|
| Advantages | Vast operator set, mature, explicit schedulers | Language-level cancellation, readable sequential code, first-class Android support, KMP |
| Disadvantages | Steep learning curve, manual disposal, large method count | Fewer operators; sharing and scoping are easy to get wrong |
| Migration risk | Error semantics differ: an Rx onError terminates one stream; an uncaught coroutine exception cancels the scope. Enumerate these before converting (Part XVIII). | |
Chapter 161UI and architecture
XML Views vs Jetpack Compose
| Dimension | XML + Views | Compose |
|---|---|---|
| Advantages | Mature tooling, huge ecosystem, predictable performance, smaller learning curve for the team you have | Less code, state-driven correctness, previews, testable via semantics, better animation and theming story |
| Disadvantages | Imperative state bugs, boilerplate, findViewById-era hazards, hard to unit test | Recomposition and stability as a new failure class, runtime you must understand, needs baseline profiles |
| Performance | Well understood; measured over a decade | Comparable when done correctly; worse without keys, stability and profiles |
| Staff view | New screens in Compose. Migrate existing screens only when they are being changed anyway, and migrate whole screens — leaf-level interop is where the regressions come from. | |
MVVM vs MVI
| Dimension | MVVM | MVI |
|---|---|---|
| Input | Public methods | One onIntent(Intent) |
| Traceability | State mutates from several places | Every transition through one reducer — loggable and replayable |
| Boilerplate | Low | Higher: intent, state, effect types per screen |
| Testing | Call method, assert state | Reducer is a pure function — the cheapest tests in the codebase |
| Right for | Most screens | Complex flows with concurrent inputs: checkout, editors, wizards |
Repository vs UseCase · Singleton vs DI
| Dimension | Repository | UseCase |
|---|---|---|
| Owns | Data access policy: caching, retry, local vs remote, merging sources | Domain rules: eligibility, pricing, validation, cross-source orchestration |
| Introduce when | Always, per domain concept, with one owner | There is logic, multiple sources, or reuse — not per repository method |
| Smell | Exposes UI state, or contains domain rules | One-line pass-through to a single repository |
| Dimension | Singleton object | DI-provided instance |
|---|---|---|
| Testability | Requires reset hooks; leaks state between tests | Constructed per test with fakes |
| Lifetime | Process; empty after process death — a real bug source | Explicit and scoped |
| When acceptable | Stateless utilities and constants | Everything with state or dependencies |
Chapter 162Infrastructure
Hilt vs Koin vs manual
| Dimension | Hilt/Dagger | Koin | Manual |
|---|---|---|---|
| Validation | Compile time | Runtime — fails when the screen opens | Compile time |
| Build cost | Significant (KSP across modules) | Negligible | None |
| Scaling | Best for many modules and teams | Good to medium size; KMP-friendly | Small apps and libraries |
| Staff view | Compile-time validation is decisive above ~10 engineers, because a runtime DI failure in a rarely-visited screen will reach production. Below that, Koin's simplicity and build speed often win. Libraries: no framework. | ||
Room vs raw SQLite · DataStore vs SharedPreferences
| Dimension | Room | Raw SQLite |
|---|---|---|
| Advantages | Compile-time query verification, observable queries, coroutine and Paging integration, migration testing | Total control; no codegen; fully dynamic schemas and queries |
| Disadvantages | Codegen build cost; awkward for highly dynamic queries | Everything is manual, including the bugs |
| Verdict | Room for almost everything; drop to SupportSQLite within Room for the dynamic cases rather than abandoning it. | |
| Dimension | DataStore | SharedPreferences |
|---|---|---|
| Threading | Fully async, transactional, observable | commit() blocks; apply() drains synchronously at onStop — an ANR source |
| Errors | Surfaced (must handle IOException) | Silent |
| Limitation | No multi-process support | Multi-process mode exists but is unreliable |
| Verdict | DataStore for new code. If you have multiple processes, neither is safe — front it with a ContentProvider or use a database. | |
WorkManager vs Foreground Service vs coroutine
| Dimension | WorkManager | Foreground service | Coroutine |
|---|---|---|---|
| Durability | Survives process death and reboot | Survives backgrounding while running | None |
| Timing | Eventually — can be hours in Doze | Now, while visible | Now, while the scope lives |
| Cost | Some latency; scheduling overhead | A visible notification; policy scrutiny; battery | Free |
| Right for | Sync, upload, deferred writes | Navigation, media playback, active tracking | Screen-scoped reads |
Chapter 163Networking and system shape
REST vs GraphQL vs gRPC
| Dimension | REST | GraphQL | gRPC |
|---|---|---|---|
| Round trips per screen | Often several | One | Several, but cheap |
| Over-fetching | Common | Eliminated by design | Fixed per method; compact binary |
| HTTP caching | Free | None (POST) — needs a normalised client cache | None |
| Debuggability | Excellent in any proxy | Moderate; queries opaque in logs | Poor without tooling |
| Client cost | Lowest | A normalised cache is a real subsystem with its own bugs | Codegen and binary size |
| Choose per | Surface, not company. GraphQL where a screen aggregates many resources; gRPC for high-frequency streaming; REST for everything else. | ||
WebSocket vs polling vs push
| Dimension | WebSocket | Polling | FCM push |
|---|---|---|---|
| Latency | Milliseconds | Half the interval, on average | Seconds, best-effort |
| Battery | High if held while backgrounded | Proportional to frequency | Near zero — the OS holds the connection |
| Reliability | Dies in Doze and on network change; needs resume tokens | Very reliable, wasteful | Best-effort; OEM-dependent |
| Staff answer | Hybrid: socket only while the relevant screen is foregrounded, push as the wake signal when backgrounded, delta fetch on resume to close gaps. | ||
Offline-first vs online-first · Modular vs monolithic · Clean vs simple layering
| Dimension | Offline-first | Online-first |
|---|---|---|
| Read/write path | DB is truth; network reconciles | Network is truth; cache is a fallback |
| Complexity | High: conflicts, queues, tombstones, reconciliation | Low |
| Choose when | Users author data, or connectivity is genuinely unreliable | Data is inherently server-live: prices, availability, feeds |
| Dimension | Multi-module | Monolithic module |
|---|---|---|
| Build | Parallel and incremental — if the graph is shallow and implementation-scoped | Everything recompiles |
| Boundaries | Enforceable by the compiler | Convention only |
| Cost | Build config, graph maintenance, longer configuration phase | None upfront; grows without limit |
| Breakpoint | Roughly three teams. Below it, overhead can exceed benefit — and a badly-shaped graph builds slower than a monolith. | |
| Dimension | Clean Architecture | Simple layered (UI / repository) |
|---|---|---|
| Files per feature | 5–8 | 2–3 |
| Domain tests | Pure JVM, milliseconds | Mixed with Android concerns |
| Onboarding | Days to internalise "where does this go?" | Immediate |
| Choose when | Non-trivial domain rules, multiple teams, or KMP ambitions | Small teams, CRUD-shaped features, short-lived apps |
Part XXVI rapid recall
- Lead with the criterion, not the feature list. Then answer, mechanism, inversion.
- Flow vs LiveData: the migration risk is silently losing lifecycle awareness.
- State in StateFlow, events in a Channel; every option loses something — name which.
- Compose for new screens, whole-screen migration only.
- Compile-time DI validation is decisive above ~10 engineers.
- Choose the API paradigm per surface, not per company.
- Real-time: socket foregrounded, push backgrounded, delta fetch on resume.
- Modularization pays around three teams; offline-first pays when users author data.
Part XXVII
Rapid Fire
Around 400 questions banded by the answer length they deserve. The banding is the training: mismatching answer length to question size is itself a calibration failure, and interviewers notice it within two questions.
Cover the right column. Say your answer out loud, timed to the band. A ten-second question answered in ninety seconds reads as unfocused; a three-minute question answered in ten reads as shallow. Both are correctable in a week of practice, and neither is correctable by reading.
Chapter 164Ten-second answers
Roughly 300 definitions and distinctions that must be instant. Hesitation here costs credibility for the rest of the round, because the interviewer starts wondering what else is shaky.
Kotlin language
val vs const val | Runtime vs compile-time constant; const is inlined at call sites, must be top-level or in an object. |
lateinit vs by lazy | Mutable and externally set, throws if unset · immutable and computed on first access. |
Why can't lateinit be primitive? | No null sentinel to represent "unset". |
Can lazy be used with var? | No — the delegate has no setter. |
lazy thread-safety modes | SYNCHRONIZED (default), PUBLICATION, NONE. |
== vs === | Structural equality vs referential identity. |
| What is a platform type? | A type from unannotated Java where nullability is unenforced (String!). |
| Four NPE sources in Kotlin | Platform types, reflective deserialisation, lateinit, !!. |
?.let vs if (x != null) | let captures the value, so it is smart-cast-safe for mutable properties. |
Why won't a var property smart-cast? | It could change between the check and the use. |
out vs in | Producer (return position) vs consumer (parameter position). |
| What is a star projection? | "Some unknown type" — safe to read as the upper bound, unsafe to write. |
| Declaration-site vs use-site variance | Declared once on the type vs declared per usage (List<out T> at a call). |
What does reified require? | inline — the type is substituted at each call site. |
Cost of a large inline reified function | Body duplicated at every call site: method count and APK size. |
noinline — when? | You need to store, pass on, or null the lambda. |
crossinline — when? | The lambda is invoked from another context; non-local return must be forbidden. |
| When does a value class box? | Generics, nullability, interface positions. |
@JvmInline — why needed? | Marks the value class for the JVM backend's inline representation. |
sealed class vs sealed interface | Interface allows a subtype to implement several sealed hierarchies. |
sealed vs enum | Sealed subtypes carry different data; enum entries are fixed singletons. |
data object vs object | data object gives a sane toString() and proper equality. |
| What does adding a sealed subtype break? | Binary compatibility for separately-compiled exhaustive when. |
Risk of data class copy() | Bypasses factory-level invariants. |
What does componentN() enable? | Destructuring — and it is positional, so reordering fields breaks callers silently. |
| Delegation trap | Overriding a member does not affect calls made inside the delegate. |
apply vs also | Both return the receiver; apply uses this, also uses it. |
let vs run | Both return the lambda result; let uses it, run uses this. |
| When does a Sequence beat an Iterable? | Long chain, large source, short-circuiting terminal. |
| Which operator defeats sequence laziness? | sorted — it must materialise everything. |
| Cheapest ordering optimisation in a chain? | Filter before map. |
Is Kotlin's List immutable? | Read-only interface over java.util.List — mutable from Java. |
Is internal a security boundary? | No — public in bytecode with a mangled name. |
@JvmStatic — why? | Makes an object member callable as a static from Java. |
@JvmOverloads — why? | Generates overloads so Java callers get default arguments. |
@Throws — why? | Kotlin has no checked exceptions; Java callers otherwise get no signal. |
What is @DslMarker for? | Prevents calling an outer builder's methods from an inner scope. |
| Operator overloading smell | Anything not arithmetic-shaped; it becomes ungreppable. |
What does contract buy? | Lets the compiler smart-cast across a function boundary. |
Difference between Nothing and Unit | Nothing has no instances (the function never returns normally); Unit has exactly one. |
Coroutines
launch vs async | Fire-and-forget vs returns a Deferred. |
withContext vs async{}.await() | Same result; withContext is cheaper for a single switch. |
What is a CoroutineContext? | An immutable indexed set: Job, Dispatcher, Name, ExceptionHandler. |
| What does a child inherit? | The parent context, with its own Job substituted. |
| What cancels a coroutine? | Cancelling its job or scope — cooperatively, at the next suspension point. |
| Why is a CPU loop uncancellable? | It never suspends. Add ensureActive() or yield(). |
ensureActive() vs yield() | Check only vs check plus give up the thread. |
| Never catch which exception? | CancellationException — rethrow it. |
| Where does cleanup go after cancellation? | finally, with withContext(NonCancellable) if it must suspend. |
What does SupervisorJob change? | A child's failure does not cancel siblings. |
Does launch(SupervisorJob()) supervise? | No — supervision comes from the scope. |
coroutineScope vs supervisorScope | Sibling failure cancels all vs siblings continue. |
When does an async failure surface? | In a plain scope, at throw time (cancels parent); under a supervisor, at await(). |
Where does an uncaught launch exception go? | Nearest CoroutineExceptionHandler, else the thread's default handler. |
| Does a handler on a child work? | No — it must be installed on the scope or the root coroutine. |
Dispatchers.IO vs Default | Blocking I/O (elastic, 64+) vs CPU-bound (CPU-count sized). Shared pool. |
What is Main.immediate? | Skips re-posting when already on the main thread — what viewModelScope uses. |
limitedParallelism(n) — why? | Bound a subsystem's concurrency without a separate pool. |
When is Unconfined legitimate? | Rarely — some tests and operator implementations. |
| Is Mutex reentrant? | No — nested withLock on the same mutex deadlocks. |
Mutex vs ReentrantLock in a coroutine | Mutex suspends; the lock blocks a thread. |
What does MutableStateFlow.update do? | CAS loop — retries on conflict, so no lost updates. |
runBlocking in production? | Only main() and tests; never the main thread. |
viewModelScope's context | SupervisorJob + Dispatchers.Main.immediate. |
When is lifecycleScope cancelled? | onDestroy — not on stop. |
What is a Semaphore used for? | Bounding concurrency; use withPermit so cancellation releases it. |
What does select do? | Waits on several suspending sources, taking the first to complete. |
awaitAll vs sequential await | Same concurrency; awaitAll fails fast on the first failure. |
Flow
| Cold vs hot | Cold runs per collector; hot emits regardless of collectors. |
| Five collectors on a cold flow? | Five executions — unless shared. |
flowOn affects what? | Everything upstream of it. |
catch operator sees what? | Upstream failures only. |
Why can't you withContext around emit? | Context preservation — it throws "Flow invariant is violated". |
Why not try/catch around emit? | Exception transparency — it would swallow downstream failures. |
conflate vs buffer | Drop intermediates vs keep them in a buffer. |
debounce vs sample | Wait for silence vs emit the latest at a fixed rate. |
flatMapLatest vs flatMapMerge vs flatMapConcat | Cancel previous · concurrent · sequential and ordered. |
combine vs zip | Emit on any change (latest of each) vs pair emissions one-to-one. |
stateIn vs shareIn | stateIn needs an initial value and conflates; shareIn does not. |
Why WhileSubscribed(5000)? | Survives a configuration change without keeping the upstream alive in the background. |
What does replayExpirationMillis control? | How long the cached value is kept after the upstream stops. |
SharingStarted.Eagerly risk | Upstream runs while backgrounded — battery and network. |
| What happens to an error in a shared flow? | Terminates it for every collector. |
callbackFlow vs channelFlow | Both allow concurrent emission; callbackFlow requires awaitClose. |
What does awaitClose do? | Keeps the flow alive and provides the unregistration hook. |
| StateFlow equality behaviour | Conflated and distinct-until-changed by equals. |
| Why is StateFlow wrong for events? | It drops intermediates and dedupes identical consecutive values. |
SharedFlow replay > 0 failure mode | Re-fires old events to a new collector after rotation. |
| Channel vs SharedFlow for events | Exactly-once to one consumer vs broadcast with possible loss. |
receiveAsFlow vs consumeAsFlow | Multiple collectors share the channel vs the channel is cancelled on completion. |
| How do you make a Flow lifecycle-aware? | repeatOnLifecycle or collectAsStateWithLifecycle. |
What does onEach not do? | It does not collect — a flow with only onEach never runs. |
distinctUntilChanged caveat | Uses equals; a data class with a changing timestamp defeats it. |
Android platform
| What survives process death? | SavedStateHandle, rememberSaveable, disk. |
| What survives rotation? | ViewModel, plus everything above. |
| What survives nothing? | Singleton in-memory state, in-flight coroutines, static caches. |
| Test process death how? | adb shell am kill, then return via Recents. |
Why not am force-stop? | Clears the task — you get a fresh launch, not a restoration. |
| Is "don't keep activities" equivalent? | No — the process survives, so ViewModels and singletons do too. |
When is onCleared() called? | When the owner finishes — not on configuration change. |
| What is zygote? | The pre-warmed VM your process forks from; shares framework pages copy-on-write. |
| Binder transaction limit | ~1 MB per process, shared across in-flight transactions. |
Common TransactionTooLargeException cause | Oversized onSaveInstanceState bundle or Intent extra. |
| What runs on the main thread? | Everything: lifecycle callbacks, input, view invalidation, your post. |
| What is Choreographer? | Runs frame callbacks on VSYNC: input → animation → traversal. |
| Frame budget at 60/90/120 Hz | 16.7 / 11.1 / 8.3 ms. |
| Input-dispatch ANR threshold | ~5 seconds. |
| Foreground-service start ANR | ~20 seconds. |
| Play's ANR bad-behaviour threshold | 0.47% of daily sessions. |
| Which launch mode clears the stack? | singleTask. |
What must singleTop handle? | onNewIntent — otherwise the new intent's data is ignored. |
| Default PendingIntent flag | FLAG_IMMUTABLE, with an explicit component. |
When is FLAG_MUTABLE required? | Direct-reply notifications and some Bubbles APIs. |
| What is intent redirection? | A mutable PendingIntent wrapping an implicit intent, filled in by an attacker. |
| Why is the share sheet empty on Android 11+? | Package visibility — declare <queries>. |
| Notification permission since? | Android 13, runtime-granted. |
| What is Doze? | Deferred network and jobs when the device is idle; high-priority FCM still wakes. |
| App Standby buckets affect what? | How often deferred work and alarms are allowed to run. |
| WorkManager's guarantee | Eventually, surviving process death and reboot — never "soon". |
| Unique work policies | KEEP vs REPLACE (one-time), UPDATE for periodic. |
| Why do foreground services need a type? | Policy enforcement — an unjustified type fails Play review. |
| Exact alarms require? | A special permission, and a policy-defensible use case. |
| Which context for a singleton? | Application. |
| Which context for a dialog? | Activity — it needs the themed, windowed context. |
What is FLAG_SECURE for? | Blocks screenshots and hides content in the recents preview. |
What does reportFullyDrawn() do? | Marks TTFD so time-to-usable is measurable. |
Jetpack
| Why does ViewModel exist? | To hold state across configuration change without leaking the Activity. |
| Why never hold a Context in a ViewModel? | It outlives the Activity by design. |
| Nav-graph-scoped ViewModel — when? | Multi-screen flows: checkout, onboarding. |
| Activity-scoped shared ViewModel risk | Becomes global mutable state nobody owns. |
repeatOnLifecycle vs launchWhenStarted | Cancels the producer vs only pauses delivery. |
| Is a Room suspend DAO main-safe? | Yes — no withContext(IO) needed. |
| Is a Retrofit suspend function main-safe? | Yes — it dispatches to OkHttp's pool. |
| Room observable queries invalidate on? | Any write to a table the query touches. |
Why @Transaction on a relation query? | It issues several statements; without it you can observe a torn read. |
| What does exporting the Room schema give you? | A committed contract that migration tests validate against. |
| When is destructive migration acceptable? | Pure cache with no user-authored data and no outbox. |
| DataStore's main limitation | No multi-process support. |
Why must you catch IOException from DataStore? | It surfaces read failures instead of hiding them. |
| Why did SharedPreferences cause ANRs? | apply()'s queue is drained synchronously at onStop. |
| First SharedPreferences read cost | The whole file is parsed synchronously. |
| Paging: cursor or page number for a live feed? | Cursor — page numbers duplicate and skip. |
| What does a RemoteMediator do? | Loads network pages into the database, which remains the source of truth. |
| Why write page and keys in one transaction? | A crash between them leaves the list and keys disagreeing. |
| Hilt vs Koin validation | Compile-time vs runtime. |
| When is field injection acceptable? | Only for framework-constructed types: Activity, Fragment, Worker. |
What does @ApplicationContext prevent? | Accidentally injecting an Activity context into a singleton. |
| App Startup library solves what? | Content-provider-per-library startup cost and initializer ordering. |
Compose
| What is the slot table? | A gap buffer recording, positionally, what was composed and remembered. |
How is remember keyed? | By call-site position, not by variable name. |
What does key() do? | Gives a composable semantic rather than positional identity. |
| What makes a type stable? | Consistent equals, no unobserved mutation, all public property types stable. |
Why is List unstable? | It is an interface — the implementation could be mutable. |
What is @Immutable? | A promise the compiler trusts and does not verify. |
| What did strong skipping change? | Unstable params compare by instance; lambdas are auto-remembered. |
remember vs rememberSaveable | Survives recomposition vs also survives process death. |
Why doesn't list.add() recompose? | The State reference never changed. |
| Fix for the above | mutableStateListOf, or replace the value with a new list. |
LaunchedEffect vs DisposableEffect | Suspending work vs register/unregister pairs. |
What is SideEffect for? | Publishing to non-Compose objects after a successful composition. |
rememberUpdatedState solves what? | A stale lambda captured by a long-lived effect. |
When does derivedStateOf help? | High-frequency input, low-frequency output, cheap derivation. |
| When does it hurt? | When the output changes as often as the input. |
produceState — what for? | Turning a suspend or callback source into State. |
snapshotFlow — what for? | Turning Compose state reads into a cold Flow. |
| The three phases | Composition, layout, drawing. |
Why Modifier.offset { } over offset()? | The lambda defers the read to layout, skipping composition. |
What phase does graphicsLayer { } read in? | Draw. |
| Why keys in a LazyColumn? | Stable identity — preserves state, enables animation and reuse. |
What does contentType improve? | Reuse across heterogeneous item types. |
| Why never nest same-direction scrollables? | Infinite constraints force measuring every item. |
| Does a LazyColumn compose all items? | No — only visible ones plus prefetch. |
| Legitimate CompositionLocal uses | Theme, typography, density, formatters — ambient and rarely changing. |
| Illegitimate ones | ViewModels, repositories, screen data. |
| The semantics tree is also? | The accessibility tree, and the test tree. |
collectAsState vs ...WithLifecycle | The latter stops collecting when the UI is stopped. |
| Where do compiler metrics come from? | composeCompiler { metricsDestination = ... }. |
| Recomposition count looks fine but it's slow? | Layout, draw, main-thread work, or a missing baseline profile. |
| Minimum touch target | 48dp. |
Why contentDescription = null on an icon inside a labelled button? | Otherwise TalkBack announces it twice. |
Architecture and modularization
| Justify a layer boundary by? | The change it isolates, weighed against mapping and indirection cost. |
| Clean Architecture's real win | A pure-Kotlin domain with millisecond tests. |
| Weakest Clean Architecture argument | "You can swap the database" — it never happens. |
| When to introduce a use case | Logic, multiple sources, or reuse — not per repository method. |
| Should a repository expose UI state? | No — it cannot then be reused by a Worker, and errors arrive pre-formatted. |
| Flow or suspend from a repository? | Flow if the data has other writers; suspend for commands. |
| Where does loading state belong? | The state holder — loading is a UI concept. |
| State or event? | Survives rotation = state; happens once = event. |
| MVVM vs MVI in one line | Methods vs one intent channel; traceability bought with boilerplate. |
| Feature-to-feature dependency? | Never — route contracts assembled in :app. |
api vs implementation | api only for types in your public signatures. |
| Which recompiles more, a body or a signature change? | Signature — it is an ABI change. |
| Deep or wide module graph? | Wide and shallow parallelises; deep serialises. |
| Biggest single build win in a Dagger/Room app | kapt → KSP. |
| When does modularization stop paying? | Below about three teams. |
What replaces a :core:shared module? | Capability modules with one owner each. |
| How are boundaries enforced? | Build logic and lint — not documentation. |
| Dynamic feature modules — when? | Only when install size is a demonstrated conversion problem. |
Networking, storage, sync
| What dominates mobile network cost? | Latency and round-trip count, not bandwidth. |
| What is radio tail time? | The radio stays in a high-power state after a transfer — chatty clients drain battery. |
| What does HTTP/2 fix, and not fix? | Fixes HTTP head-of-line blocking; not TCP-level. |
| What does HTTP/3 add for mobile? | Connection migration across network changes. |
| Application vs network interceptor | Misses cache hits vs sees every wire attempt. |
When is Authenticator invoked? | On 401 — and it replays the original request for you. |
What is EventListener for? | Per-phase timings: DNS, connect, TLS, request, response. |
| Which failures are safe to retry? | Connection failures and 5xx; post-send timeouts only with an idempotency key. |
| Never retry which class? | 4xx — deterministic. |
| Why jitter? | Synchronised retries turn a blip into an outage. |
| When is the idempotency key created? | At user commit, and persisted before the first call. |
| What is the unknown outcome? | No response received — a first-class state, never "failed". |
| What does an ETag save? | Bandwidth via 304 — not the round trip. |
| Cursor vs offset pagination | Offsets duplicate and skip on a live list. |
| Is connectivity a boolean? | No — captive portals, metered links, handovers. Attempt and handle failure. |
| What does WAL mode allow? | Concurrent readers during a write; still one writer. |
| Speedup from batching inserts | Typically 50–100× — one fsync instead of N. |
| How do you verify an index is used? | EXPLAIN QUERY PLAN — "SEARCH … USING INDEX", not "SCAN TABLE". |
| Bitmap memory formula | width × height × 4 bytes in ARGB_8888. |
| A 12 MP photo decoded costs? | About 48 MB. |
| Why must sync cursors advance in-transaction? | Otherwise a crash loses or replays a page silently. |
| Why do deletions need tombstones? | An offline client never learns about them otherwise. |
| Can you resolve conflicts with client clocks? | No — devices lie. Use server versions. |
| Push: data or signal? | Signal — makes handlers idempotent for free. |
| Exactly-once delivery? | Does not exist. Exactly-once effect via idempotency does. |
Performance, testing, security, build
| Which device do you measure on? | The 25th-percentile device in your install base. |
| TTID vs TTFD | First frame vs content actually usable. |
| What does a baseline profile do? | AOT-compiles hot paths so first launch and first scroll are not interpreted. |
| Typical baseline profile gain | 15–30% cold start, plus first-scroll jank. |
| Why read every thread in an ANR trace? | Main shows where it blocked; the cause is usually the lock holder. |
| Retained vs shallow size | Everything freed if it went away vs the object's own fields. |
| Will LeakCanary find unbounded growth? | No — those objects are reachable by design. |
What does onTrimMemory mean? | Release caches now or be killed sooner. |
| Where do hardware bitmaps live? | Outside the Java heap — but still in the process footprint. |
| Fake or mock? | Fake what you own and reuse; mock what you neither own nor reuse. |
Why never delay() in a test? | It bets on machine speed — the main flake source. |
| What makes coroutine tests deterministic? | Injected dispatchers plus runTest virtual time. |
Why does a WhileSubscribed StateFlow look broken in a test? | It does not start until collected. |
| Is coverage a target? | No — a diagnostic. Target escaped defects. |
| What is worse than no tests? | A flaky suite — it trains engineers to re-run failures. |
| Which Gradle phase runs every invocation? | Configuration, unless the configuration cache is on. |
| What silently disables build caching? | Volatile inputs: timestamps, absolute paths. |
| Pre-merge pipeline budget | Under ten minutes, or engineers route around it. |
| How do you roll back a mobile release? | You don't — halt rollout, kill switch, server fix, forward hotfix. |
| What does halting a rollout affect? | New installs only; existing users keep the bad build. |
| Obfuscation vs encryption vs security | Renaming vs unreadable-without-a-key vs a system property enforced server-side. |
| Where do Keystore keys live? | Hardware (TEE/StrongBox) — never in your process. |
| What must never be reused under GCM? | The IV, with the same key. |
| Correct mobile OAuth flow | Authorization Code with PKCE, in a Custom Tab. |
| Why not an embedded WebView for login? | Your app can read credentials; no shared session; no verifiable URL bar. |
| Client secret in a mobile app? | None — it is a public client; PKCE replaces it. |
| Biometrics: boolean or key? | Key — a boolean branch is patchable. |
| What does certificate pinning risk? | Bricking the app on rotation — needs backup pins, expiry, kill switch. |
| Best root-detection signal | Play Integrity, validated server-side. |
Chapter 165Thirty-second answers
Mechanism plus one consequence. If you exceed 45 seconds you are explaining rather than answering.
| How does structured concurrency prevent leaks? | Every coroutine has a parent; cancelling the parent cancels all descendants transitively, and a scope cannot complete before its children. Work cannot outlive its owner — which is exactly what GlobalScope breaks. |
What happens when one of three async calls fails? | In a plain coroutineScope the failure cancels the parent at throw time, not at await time, so siblings die and a try/catch around await() does not help. Under a supervisor, siblings continue and each failure surfaces at its own await. |
| Why can five screens cause five network calls? | Flow is cold — the builder runs per collector. stateIn/shareIn, or a single database source of truth, collapses them to one. |
How does the slot table give remember its identity? | Identity is the call site's position in the composition tree. Move the call into a branch and it becomes a different slot — which is why key() exists. |
| Why does a 40 ms main-thread block drop three frames? | The frame it lands in is missed, and the next two VSYNC pulses arrive while the thread is still busy, so their work queues and runs late. |
| Why does WAL mode reduce jank? | Readers proceed during a write, so a background sync no longer blocks the query feeding a scrolling list. There is still one writer. |
| Why is jitter necessary in retry backoff? | Without it, a million clients that failed together retry together, turning a brief blip into a sustained outage. Full jitter spreads them uniformly. |
| Why must an idempotency key be persisted? | The case you are defending against is process death mid-request. A key held in memory is regenerated on restart, so the retry creates a second order. |
| Why is a mutable PendingIntent with an implicit intent dangerous? | Another app can fill in the intent and have it executed with your app's identity and permissions. |
Why doesn't @Immutable make a type safe? | It is a promise the compiler trusts, not one it verifies. Breaking it produces stale UI that is very hard to debug. |
Why is replay = 1 wrong for events? | A collector created after rotation immediately receives the last event, so navigation or a snackbar fires twice. |
| Why does adding a sealed subtype break binary compatibility? | Separately-compiled consumers with exhaustive when throw NoWhenBranchMatchedException until recompiled. |
Why does api hurt build times? | It puts the dependency on every consumer's compile classpath, so an ABI change there invalidates far more modules than necessary. |
| What does a baseline profile actually do? | Ships a list of hot methods AOT-compiled at install, so first launch and first scroll do not run interpreted while the JIT warms. |
| Why read every thread in an ANR trace? | Main's stack shows where it blocked, not why. If it waits on a monitor, the bug is in whichever thread holds the lock. |
| Why is unbounded growth invisible to LeakCanary? | The objects are reachable and intentionally retained; LeakCanary detects unreachable-but-retained objects. |
| Why do offsets break pagination on a live feed? | Insertions above your offset shift the window, so you see duplicates and skip items. Cursors encode a position in the data. |
| Why must the sync cursor advance inside the transaction? | Otherwise a crash between applying data and saving the cursor loses a page permanently or replays it — silently. |
| Why can't you resolve conflicts with client timestamps? | Device clocks are wrong — users change them and drift is common. Use server-assigned versions. |
| Why is biometric-as-a-boolean insufficient? | Nothing cryptographic depends on the result, so patching the branch or hooking the callback bypasses it. |
| Why is there no rollback for a mobile release? | You cannot remove a binary from devices. Halting affects new installs only; mitigation is a kill switch or server-side fix. |
| Why does flake matter more than test count? | A flaky suite trains engineers to re-run until green, so real failures get re-run too. Worse than no tests. |
| Why does a config-time timestamp hurt? | It changes every invocation, so the task is never up to date, everything downstream recompiles, and the configuration cache breaks. |
| Why prefer fakes over mocks at scale? | Mocks assert implementation, so refactors break tests without behaviour changing. At thousands of tests, refactoring becomes uneconomic. |
Why does launchWhenStarted not save battery? | It suspends delivery but keeps the upstream producing — a socket or location flow keeps running with results buffered. |
| Why does the first read of SharedPreferences hurt startup? | The entire XML file is parsed synchronously on first access, on whatever thread asked. |
Why is catch (e: Exception) around a suspend call a bug? | It swallows CancellationException, so the parent believes the child completed and you show errors on abandoned screens. |
| Why does deduplicating in the caller's scope backfire? | The first caller navigating away cancels the shared request for everyone else. |
| Why compare against the stale token during refresh? | Five concurrent 401s enter the lock in turn; those that find a changed token skip refreshing, so rotating refresh tokens are not invalidated. |
| Why does Room re-query on unrelated writes? | Invalidation is per table, not per row — so a hot table plus an unbounded query is a CPU load. |
| Why can a process be killed with an empty Java heap? | Native and hardware-bitmap memory count toward the process footprint the low-memory killer sees. |
| Why does the same code jank only on newer phones? | A 120 Hz display halves the frame budget to 8.3 ms. |
| Why is push latency unguaranteeable? | Doze, standby buckets and OEM battery optimisation all defer delivery; the design must tolerate delay. |
| Why does an activity-scoped shared ViewModel grow? | It is reachable from every screen, so it becomes the path of least resistance for any shared state. |
| Why does leaf-level Compose interop perform badly? | Each ComposeView carries its own composition and layout bridging, so per-item cost multiplies inside a RecyclerView. |
| Why measure with a release build? | Debug builds disable R8, add instrumentation, and run without AOT profiles — the numbers are unrelated to production. |
| Why does a shared flow's exception affect other screens? | It terminates the shared upstream for all collectors; with WhileSubscribed it silently restarts on the next subscription. |
| Why isn't "clear the cache on logout" enough? | The outbox may hold pending writes the user believes are saved, and a cleared session can apply them to the next account. |
Why is a mutable Kotlin List a Java problem? | List is java.util.List at runtime, so a Java caller can mutate a collection your API presents as read-only. |
| Why does the Compose compiler care about your module boundaries? | Types from modules without the Compose compiler cannot have stability inferred, so they are treated as unstable. |
Chapter 166One-minute answers
Trade-off questions. Every answer must contain a criterion and a caveat; without a criterion it is not an answer.
Should this repository return Flow or suspend? | Criterion: does the data have other writers? Sync workers, push handlers, other screens → Flow over an observable query. A command → suspend. Caveat: cold flows need sharing or you pay N calls for N collectors, and Flow<Result<T>> leaks transport vocabulary into the domain. |
| Where should loading and error state live? | The repository emits data or throws; the state holder maps to Loading/Ready/Failed, because loading describes the view's relationship to data. Per-section rather than one global flag, so an 80 ms header does not wait on a 2 s list. |
| MVVM or MVI for this screen? | Criterion: how many independent inputs mutate state, and do you need replayable transitions? Settings → MVVM. Checkout with payment, address and inventory racing → MVI's funnel earns its boilerplate. Both in one codebase with a written rule is judgment, not inconsistency. |
| Hilt or Koin here? | Criterion: team size and the cost of a runtime failure. Above ~10 engineers, compile-time validation wins because a runtime DI failure in a rare screen reaches production. Below, Koin's build-time saving is real. Libraries: no framework. |
| How do you decide what to cache? | Criterion: cost of staleness versus cost of fetching. Cheap-to-fetch, expensive-to-be-wrong data — prices, balances, inventory — should not be cached, or should carry an explicit "as of". Every cache needs an invalidation story before it is written. |
| When would you not modularize? | Below roughly three teams, build-config overhead and graph maintenance can exceed the benefit — and a deep, api-heavy graph builds slower than a monolith. It pays with team count and boundary enforcement, not line count. |
| How do you choose a real-time transport? | Socket while the relevant screen is foregrounded, push as the wake signal when backgrounded, delta fetch on resume. Criterion: latency requirement versus battery and connection cost — 1.5M idle sockets is a real bill for a feature most users are not currently using. |
| How much of a design should be offline-capable? | Classify: reference data (evictable, TTL), user-owned (never evicted while signed in), pending writes (durable, never purged by a cache clear). Offline-first roughly doubles each feature's state space, so apply it where users author data. |
| What is your testing strategy for a new feature? | Domain rules and reducers always; state holder behaviour always; repository with fakes usually; a screenshot per significant visual state; UI tests only on the critical path. Explicitly not: mocked interaction verification, or layout assertions that churn weekly. |
| How do you decide whether to take on technical debt? | Non-negotiable: data integrity, security, anything irreversible or needing a user-visible migration later. Negotiable: test depth on low-risk paths, refactoring, polish. Record it with an owner and an expiry — undocumented debt is the kind that compounds. |
| Foreground service or WorkManager? | Criterion: must the user see it happening now, or must it merely eventually happen? Navigation and active tracking → foreground service with a declared type. Sync and upload → WorkManager with unique work and backoff. |
| How do you approach a performance regression? | Quantify and segment first — metric, percentile, device tier, version, rollout correlation. Three ranked hypotheses. Trace on a mid-tier device with a release build. Fix, verify in lab and field, then add the CI gate, or you will repeat this investigation next year. |
| REST, GraphQL or gRPC for this surface? | Criterion: does the screen aggregate many resources, and does the field set vary by client version? Yes → GraphQL, accepting that you lose HTTP caching and take on a normalised cache. High-frequency streaming → gRPC. Otherwise REST, because caching and debuggability are free. |
| Should this state live in the ViewModel or the repository? | Criterion: is it screen state or app state? Selected tab, scroll position, form input → ViewModel with SavedStateHandle. Signed-in user, feature flags, cart → repository with a persisted source of truth, because more than one screen depends on it surviving. |
| How do you decide the conflict policy for a data type? | Criterion: what does a wrong resolution cost? Nil → last-write-wins (read state). Independent fields → per-field merge. Business-critical → server arbitration. User-authored text → conflict copy, because silently losing writing is never acceptable. |
| When is a custom Compose layout justified? | Criterion: can the built-in layouts express it without intrinsics or nested weights in a hot path? If measurement cost is showing in a trace, a custom layout with a single measurement pass is justified. Otherwise it is a maintenance cost with no return. |
| How do you scope a kill switch? | Criterion: what is the smallest thing you would want to disable in an incident? Per-feature, evaluated at the entry point, defaulting to on, with a local default for offline. Anything integrating a third party gets one by policy, because you cannot fix their outage. |
| How do you pick what to instrument? | Work backwards from failure modes: for each way the feature can fail, name the metric that would move. If a failure mode has no metric, it will be discovered by users. Then check the cost — telemetry volume is a real budget line. |
Chapter 167Three-minute answers
Mini-designs and debugging prompts that open into longer conversations. Each should end with you naming what you would measure.
| Design the offline write queue for a messaging app. | Outbox table with the row ID as the idempotency key; states pending/in-flight/failed; stale in-flight rows reset on startup; FIFO drain with stop-on-retryable to preserve order; jittered backoff; permanent failures surfaced to the user; WorkManager for durability; local-ID-to-server-ID rewriting for dependent operations. Measure: outbox depth p95, permanent-failure rate. |
| A user reports the app "loses their edits sometimes." Diagnose. | Candidates: non-transactional cursor advance; state not saved across process death; outbox purged by a cache clear or logout; a conflict policy silently choosing server-wins; destructive migration. Instrument first — sync lag and outbox depth make this observable rather than anecdotal. |
| Design the state model for a multi-step checkout. | Server-driven step machine so payment and compliance steps change without a release; local persistence of step and idempotency key for resumability; explicit Unknown outcome; re-price confirmation before charge; navigation as events, not state. Measure: step abandonment, unknown-outcome rate. |
| Cold start regressed 800 ms over two quarters with no single culprit. | Accumulation, not a regression: three-tier initialisation with an owner for the total, a CI startup budget that fails the build, baseline profile regeneration, an SDK integration standard requiring lazy init. Report in retention terms to keep it funded. |
| Design a feature-flag system for a 40-engineer Android org. | Remote config with local defaults for offline; typed accessors, not string keys; owner and expiry declared at creation; kill switches as a separate permanent category; staged-rollout integration with halt criteria; a stale-flag report, because 400 accumulated flags are 400 untested code paths. |
| How would you make a 90-minute test suite usable again? | Flake first — history-based detection, automatic quarantine with owner and expiry, a visible dashboard. Then tier: affected-module tests pre-merge, full suite post-merge, E2E nightly. Measure PR wall-clock and change failure rate, not coverage. Ownership via CODEOWNERS or the quarantine list grows forever. |
| Design image loading for a media-heavy feed. | Server-side renditions requested at display size; memory cache sized from memoryClass; disk cache with a hard quota and LRU; prefetch two screens ahead on unmetered only; cancel on detach; decode off the main thread. Measure: cache hit rate, decode time p95, OOM rate by device tier. |
| Your app is the source of a backend outage. What happened and what do you change? | Almost certainly a synchronised retry herd, or a client-side loop against a failing endpoint. Changes: full jitter, a client circuit breaker, respect for Retry-After, bounded queues with a downsampling policy, and a kill switch on the calling feature. Measure: request rate per user per minute, and alert on it. |
| Design analytics for a 200-screen app so it stays trustworthy. | A typed event schema reviewed like an API; events enqueued durably and batched; a client-side enqueued counter reconciled daily against server-received counts; consent state as a first-class input; sampling policy with a cost dashboard. Measure: the reconciliation gap — that is the number that catches silent loss. |
| Walk me through making a legacy screen testable without changing behaviour. | Extract pure logic behind the existing behaviour, characterise it with tests written against current output first, inject the clock and dispatchers, split the stateful entry point from the stateless UI, then refactor under the new tests. Ship each step separately. Measure: nothing should move — that is the point. |
Part XXVII practice protocol
- Ten-second band: cover the answers, run 40 in seven minutes, mark every hesitation.
- Thirty-second band: answer aloud; over 45 seconds means you are explaining rather than answering.
- One-minute band: every answer needs a criterion and a caveat.
- Three-minute band: end each one by naming what you would measure.
- Re-run the bands you failed 48 hours later, not the same day.
- Two weeks out, run the full ten-second set in one sitting — it should take under 40 minutes.
Part XXVIII
Mock Interview Loops
Complete loops with interviewer scripts, expected reasoning, follow-ups and filled-in scorecards. Run them timed, with a peer as interviewer. Reading them is worth a fraction of performing them.
Chapter 168How to run these
- Timed and out loud. 45 or 60 minutes per round, no pausing to look things up.
- Peer as interviewer. Give them the script; they do not need to know the material to run it, because the follow-ups and the scorecard are provided.
- Score immediately against the rubric, before discussing. Discussing first contaminates the score.
- One loop per week, not one per day. The value is in fixing what the scorecard exposed between attempts.
Dimension 1 2 3 4 5 Evidence Problem framing / constraints ○ ○ ○ ○ ○ ______________ Technical depth (mechanism) ○ ○ ○ ○ ○ ______________ Trade-off reasoning ○ ○ ○ ○ ○ ______________ Failure & scale thinking ○ ○ ○ ○ ○ ______________ Communication & structure ○ ○ ○ ○ ○ ______________ Collaboration (uses hints well) ○ ○ ○ ○ ○ ______________ 3 = Senior · 4 = Staff · 5 = Principal Hire decision at Staff requires 4s in framing, trade-offs and failure thinking. A single 2 in communication frequently sinks an otherwise strong loop.
Chapter 169Loop 1 — Senior → Staff at a product company
Shape: Kotlin/coroutines · Android platform · architecture · mobile system design · behavioural. This is the calibration loop: the scorecard at the end shows exactly where the Staff line falls.
Round 1 · 45 min · Kotlin and concurrency
Interviewer script
- Warm-up (5 min): "Walk me through what happens when three concurrent
asynccalls run inside acoroutineScopeand one fails." - Core (25 min): "Implement a request deduplicator: five screens ask for the same user simultaneously, we want one network call." Let them design the API first. Probe: what scope does the work run in? What if the first caller navigates away? What if the request fails?
- Extension (10 min): "Now add a 30-second result cache." Probe: where does the clock come from, and how do you test expiry?
- Close (5 min): "What would you change about your solution before shipping it?"
What separates the levels here
finally, and names the leak risk of the map growing.Round 2 · 45 min · Android platform
Interviewer script
- "A user reports that their form data disappears sometimes. Walk me through how you'd investigate." (Target: distinguishing configuration change from process death, and knowing how to reproduce each.)
- "What actually survives process death?" Then: "How would you test that in CI?"
- "We need to upload a photo the user just took. It must complete even if they leave the app. Design it." (Target: WorkManager, unique work, foreground service for the visible case, idempotency, failure surfacing.)
- "The upload works but battery complaints doubled. What are your hypotheses?"
Red flags: confusing "don't keep activities" with process death; proposing a bare coroutine for durable work; no mention of what the user sees when the upload permanently fails.
Round 3 · 45 min · Architecture
Interviewer script
- Present the "everything in the ViewModel" architecture (Part XXII, Architecture 1) as a real codebase. "Critique this."
- "You have one quarter and the team is shipping features throughout. Sequence the fix."
- "Should repositories expose Flow or suspend functions?" — the criterion question.
- "Your proposal adds files and a new rule. What is the cost, and who pays it?"
The Staff signal in this round is question 4: candidates who cannot name a downside of their own proposal are scored as advocating a style rather than making a decision.
Round 4 · 60 min · Mobile system design
"Design the client for a food delivery app: browse, order, track the courier."
Interviewer's checklist — tick what the candidate raises unprompted:
| Requirements pinned before designing | □ Functional □ Non-functional □ Out of scope |
| Scale estimate that drives a decision | □ Update frequency □ Socket vs push conclusion |
| Order as a server-authoritative state machine | □ Client never infers transitions |
| Push carries the signal, client fetches truth | □ Idempotent handling of duplicate push |
| Price/availability revalidation at checkout | □ Structured diff shown to the user |
| Offline and degraded behaviour | □ Cart local-first □ Tracking staleness indicator |
| Battery for courier tracking | □ Batching □ Foreground service type |
| Failure modes named unprompted | □ Restaurant unresponsive □ Payment unknown outcome |
| Observability | □ Names a metric per failure mode |
Follow-ups to deploy at 40 minutes: "The courier's phone loses signal for ten minutes — what does the customer see?" · "Two devices, same account, one places an order — what happens on the other?" · "How would you roll this out safely?"
Round 5 · 45 min · Behavioural
Interviewer script
- "Tell me about a technical decision you made that turned out to be wrong."
- "Tell me about influencing a team you had no authority over." Probe for the mechanism: prototype, data, or absorbed cost.
- "Describe a time you disagreed with a decision and had to commit to it anyway."
- "What's the most significant thing you've owned that nobody asked you to?"
Loop 1 — worked scorecard for a borderline candidate
| Round | Score | Evidence |
|---|---|---|
| Kotlin/concurrency | 4 | Raised scope ownership unprompted; injected the clock; missed the map-growth leak until prompted. |
| Platform | 4 | Correct on process death and reproduction; strong on WorkManager; did not consider what the user sees on permanent failure. |
| Architecture | 3 | Good critique, sensible sequencing — but could not name a cost of their own proposal. Advocated rather than decided. |
| System design | 3 | Solid client architecture; spent 12 minutes on the backend; offline behaviour only when prompted; no observability. |
| Behavioural | 4 | Genuine failure story with a systemic fix. Influence story lacked a mechanism — "I explained why." |
Committee outcome: Senior, not Staff. The technical depth is there. What is missing is visible in two places — an inability to critique their own proposal, and a design round that drifted to the backend and needed prompting for failure and observability. Both are learnable in weeks, and both are exactly what Parts XVI and XXII drill.
Chapter 170Loop 2 — Staff at a large technology company
Shape: advanced Android internals · concurrency debugging · mobile system design · architecture leadership · behavioural. Harder in breadth of probing rather than in question difficulty — expect three levels of follow-up on everything.
- Internals (45 min). Looper and Choreographer mechanism; why a 40 ms block drops three frames; Binder and
TransactionTooLargeException; then "walk me through everything that happens between tapping the icon and the first frame." - Concurrency debugging (45 min). Presented with the
UploadManagersnippet from Part XXI, Review 4: "This works in testing and duplicates uploads in production. Why?" Then: "Fix it." Then: "Now make it survive process death." - System design (60 min). "Design a chat client." Follow-ups at 30 minutes: ordering with wrong device clocks; multi-device read state; offline send with permanent failure; what changes with end-to-end encryption.
- Architecture leadership (45 min). "You join and find four teams with four networking layers. Walk me through your first 90 days." Probes: how do you know it is worth fixing; who pays; what if a team refuses; how do you know it worked.
- Behavioural (45 min). Failure, conflict, saying no, and "what would your most difficult colleague say about working with you?"
Scoring note: at this level, a correct answer that arrives only after two hints scores as a 3. The bar is not "can reach the answer" but "reaches it independently and knows why it is the answer."
Chapter 171Loop 3 — Principal / Mobile Architect
Deliberately brutal. Every round has contradictory constraints and no clean answer; the assessment is entirely about how you reason under them.
- Strategy (60 min). "5M DAU, 500 engineers, the architecture is unchangeable, and leadership wants a redesign shipped in two quarters. What do you do?" The trap: accepting both constraints. Expected: reframe, measure, one visible win, invariants, honest timeline (Part XXIV).
- Design under contradiction (60 min). "Design multi-device sync for a note app. It must be end-to-end encrypted, support full-text search, work offline for a month, and never lose data." These constraints conflict — E2E prevents server-side search, month-long offline maximises conflicts. The assessment is whether you surface the conflict and negotiate scope rather than pretending to satisfy all four.
- Organisational design (45 min). "Design the mobile engineering org for a company scaling from 40 to 200 engineers. Team boundaries, platform team charter, governance model, and what breaks first."
- Executive communication (30 min). "Explain to a CFO why we need two quarters of engineering investment with no new features." Assessed on: money and risk framing, no jargon, a concrete decision requested at the end.
- Reverse round (30 min). "What questions do you have for us?" At Principal level this is scored — the questions reveal what you think matters. Strong: how decisions actually get made here, what the last failed technical initiative was and why, who owns mobile architecture today.
Chapter 172Loops 4–12 — specialised variants
| Loop | Round structure | What it uniquely tests |
|---|---|---|
| 4 · Fintech | Payments idempotency design · threat modelling · Kotlin concurrency · incident leadership · behavioural | Correctness over latency; the unknown-outcome state; regulatory framing; whether you would ever show "failed" when you mean "unknown". |
| 5 · Real-time / location | Streaming concurrency · battery and background policy · live-tracking design · on-call behaviour | Battery as a product metric; foreground service policy; degraded behaviour under signal loss. |
| 6 · Media / performance | Rendering internals · memory and bitmaps · video design · performance-programme leadership | Whether you profile or guess; frame-timeline reading; running performance as a programme with budgets. |
| 7 · Platform / infrastructure | Gradle internals · modularization exercise · DX as a product · migration leadership | Build-time economics in engineer-hours; whether you treat engineers as users; enforcement over documentation. |
| 8 · Startup Staff | Breadth screen · pragmatic design under constraint · "what would you not build" · founder conversation | Speed/quality judgment; comfort being the only Staff engineer; willingness to choose the boring option. |
| 9 · Offline-first product | Sync engine design · conflict resolution · data-integrity incident · testing strategy for sync | Per-type conflict policy; transactional cursors; whether you can test partial failure deterministically. |
| 10 · Compose-heavy consumer app | Compose internals · state-management design · recomposition debugging · architecture review | Runtime understanding versus API familiarity; the "low recomposition but slow" diagnosis. |
| 11 · Legacy modernisation | Codebase assessment · migration sequencing · stakeholder management · metrics | Whether you propose a rewrite; incremental value; how you would know it is working. |
| 12 · KMP / cross-platform org | KMP evaluation · shared-domain architecture · team topology · a design spanning both platforms | Whether you treat it as a technical or an organisational decision; whether you keep an exit path. |
After every mock — the only three questions that matter
- What did I need a hint for? That is the gap, and it is specific enough to fix.
- Where did I explain instead of answering? Over-long answers are the most common and most fixable communication failure.
- Which lens did I never apply? Most candidates consistently forget the same two of the thirteen — usually observability and organisational impact. Find yours and make them a habit.
Part XXIX
Follow-Up Trees
Staff loops are rarely decided on the opening question. They are decided three questions deep, when the candidate runs out of mechanism and starts repeating vocabulary.
Chapter 173The three-level rule
| Level | What it probes | How it usually sounds |
|---|---|---|
| Level 1 | Knowledge | "How would you design X?" — most candidates prepare only for this. |
| Level 2 | Mechanism | "What happens when Y fails?" — separates people who have shipped it from people who have read about it. |
| Level 3 | Judgment | "What if the constraint you assumed away is real?" — no rehearsed answer exists; this is the level that decides the loop. |
Reason from mechanism — derive an answer from what you know about how the system works, narrating the derivation. Name the trade-off space — "there are two families of answer here, and the choice depends on…". Say what you would do to find out — "I don't know; here is the experiment that would tell us in a day." All three score well. Guessing confidently scores worst of all, because a Staff engineer who bluffs is a liability.
Chapter 174Worked tree — offline-first
The full expansion of the single most-asked mobile design question. Each answer is deliberately short, because in a real loop these come rapidly.
Root question
"How would you design an offline-first application?"
Answer: Local database as the single source of truth; the UI reads only from it and never awaits the network. Every mutation writes locally and appends to a durable outbox. A sync engine pushes the outbox, pulls a server-cursored delta, and resolves conflicts by a policy defined per data type. Push is a wake signal, not data.
→ "What happens when two devices modify the same record?"
Version-based optimistic concurrency: each client sends the version it last saw; the server rejects a stale write with a 409 and the current record. Resolution is per data type — last-write-wins for read state, per-field merge for profiles, server arbitration for inventory, conflict copy for documents. The client must retain the last-synced base version, not just its local mutation, or merging is impossible and you can only overwrite.
→→ "What if the field-level merge produces something the user did not intend?"
Then per-field merge was the wrong policy for that type. The test is whether fields are semantically independent — a name and an avatar are; two edits to a paragraph are not. For dependent content, keep both and surface the conflict. Silently producing a merged result the user never wrote is worse than showing them a conflict, because they cannot detect it.
→ "What if synchronisation fails halfway through?"
The cursor advances only inside the transaction that applies the page, so a crash resumes exactly where it stopped. The outbox drains one operation at a time with the operation marked in-flight; on startup, stale in-flight rows are reset to pending, because a crash mid-request leaves them stranded otherwise.
→→ "The operation was actually applied server-side, but you crashed before recording the ack. Now you replay it."
That is why the outbox row ID is the idempotency key. The replay carries the same key, the server recognises it and returns the original result rather than applying it twice. This is the unknown-outcome problem, and the client cannot solve it alone — it is a contract with the backend, which is why I would establish it globally rather than per endpoint.
→→→ "The server only retains idempotency keys for 24 hours, and your outbox can be a week old."
Then there is a correctness gap, and I would rather name it than paper over it. Three options: bound the outbox lifetime to the retention window and fail operations older than that explicitly; reconcile by business identity instead of by key — query whether an order for this cart already exists; or negotiate a longer retention for the operations where duplication is expensive. Which one depends on the cost of a duplicate: for a payment I would push hard for the third; for a "mark as read" I would accept the first.
→ "What if the server returns duplicate events?"
Application must be idempotent by construction: upserts keyed by server ID, never blind inserts. That also makes push-triggered fetches safe to run twice, which is why treating push as a signal rather than as data removes a whole class of bug.
→ "What if the user logs out with pending writes?"
A product decision that must be made before launch, and it is the one nobody thinks of. Options: block logout until the queue drains, warn and discard with an explicit confirmation, or complete the queue in the background before clearing. What is never acceptable is clearing local data with pending writes still queued — the user believes their work was saved. And the outbox must be user-scoped, or a queued write can be applied to the next account signed in on that device.
→ "What if local storage is corrupted?"
Detect on open. Preserve the outbox if it is readable — it contains work the user believes is saved — and rebuild everything else from the server. If the outbox itself is unreadable, that is a data-loss event: report it as one, tell the user specifically what was lost if you can determine it, and do not silently reset. Silent recovery is how corruption bugs go unnoticed for months.
→ "How do you test all of this?"
Four layers. A deterministic two-client harness with a fake server that can be told which write lands first, covering every conflict policy. Fault injection that kills the process at each await point in the sync loop. A fake clock for TTLs and retention. And a soak test at realistic data volume, because every sync bug I have seen hides at small scale.
→→ "How would you roll it out to five million existing users?"
Staged, with a shadow phase first: run the sync engine in read-only mode for a small cohort and compare its computed state against the server's without acting on it. That surfaces disagreement before any user data depends on it. Then enable writes for 1%, with halt criteria on conflict rate, sync-lag p95 and support contact rate. The bootstrap is the risky part — a user with 500k records cannot sync the way a test account with 500 does, so the migration needs a paged bootstrap with progressive availability, and I would explicitly test the largest accounts in the install base before rollout.
Chapter 175Fifty follow-ups to expect, by category
State and lifecycle
| "What happens after process death?" | Tests whether the design distinguishes memory, saved state and disk. |
| "What does the user see during rotation?" | Tests WhileSubscribed, and whether the upstream restarts. |
| "Two screens showing the same data — one edits it." | Tests single source of truth. |
| "The user backgrounds the app mid-flow for an hour." | Tests token expiry, stale data, resumability. |
| "What if they force-quit?" | Tests durability versus in-memory state. |
Failure and partial failure
| "One of your three requests fails — what renders?" | Per-section state versus a global loading flag. |
| "The network drops mid-write." | Idempotency, unknown outcome, UI honesty. |
| "The backend returns a 500 for ten minutes." | Backoff, jitter, circuit breaking, degraded mode. |
| "The response is malformed." | Parsing tolerance, unknown enum handling, error reporting. |
| "Wi-Fi to cellular mid-request." | Socket death, retry safety, HTTP/3 connection migration. |
| "Storage is full." | Write failure paths, cache eviction, user messaging. |
| "The token expires during five concurrent calls." | Single-flight refresh, stale-token comparison. |
Scale
| "What breaks at 10× users?" | Usually a backend or fan-out concern — say which part is yours. |
| "10× data on the device?" | Bootstrap time, query plans, memory, eviction policy. |
| "10× engineers on this codebase?" | Boundaries, ownership, build time, review capacity. |
| "10× screens?" | Templates, navigation topology, DI graph, consistency mechanisms. |
| "What is the cost of this at scale?" | Telemetry volume, CDN egress, push fan-out, socket count. |
Product and edge cases
| "A brand-new user with no data?" | Empty states, bootstrap, first-run performance. |
| "A user with five years of history?" | Pagination, storage quota, migration time. |
| "The user changes their device clock?" | Never trust client time for ordering or expiry. |
| "They install on a second device?" | Multi-device state, read convergence, notification dismissal. |
| "They uninstall and reinstall?" | What is recoverable, what is lost, what the user expects. |
| "The device is offline at first launch?" | Almost always broken in practice — a very common gap. |
| "Accessibility for this screen?" | Semantics, touch targets, font scaling, screen-reader order. |
| "Right-to-left and long translations?" | Layout assumptions, truncation, hardcoded strings. |
Operations
| "How do you know this is broken in production?" | The single most-skipped question. Name a metric per failure mode. |
| "How would you roll this out?" | Staged, halt criteria, kill switch, cohort selection. |
| "How do you turn it off without a release?" | Remote config; if the answer is "we can't", that is the finding. |
| "What is your alert threshold, and who is paged?" | Ownership, not just instrumentation. |
| "How long until you detect a silent data problem?" | Sync lag, outbox depth, reconciliation counts. |
| "How do you debug this on a user's device?" | Breadcrumbs, remote logging with consent, targeted tracing. |
Testing and quality
| "How do you test the concurrency here?" | Virtual time, deterministic interleavings, no delay. |
| "How do you test the process-death path?" | Saved-state unit tests plus instrumented restoration. |
| "How do you test conflict resolution?" | Two-client harness with controlled ordering. |
| "What would you deliberately not test?" | Tests judgment; "everything" is a weak answer. |
| "How do you keep this suite from becoming flaky?" | Injected clocks and dispatchers, no shared state, quarantine policy. |
Organisational
| "Who owns this component?" | Unowned components are the Staff-level failure mode. |
| "How do you stop other teams from breaking it?" | Enforcement in build logic, not documentation. |
| "How would 40 engineers build this consistently?" | Templates, generators, lint, defaults. |
| "What if another team disagrees with this design?" | Evidence, absorbed cost, escalation path, disagree-and-commit. |
| "How long, realistically, and who does it?" | Estimation honesty; vague answers read as inexperience. |
| "What would you cut if you had half the time?" | Prioritisation under constraint; the answer reveals what you think is essential. |
Meta
| "What is the weakest part of your design?" | Self-critique. Having no answer is scored as not having thought about it. |
| "What would make you choose differently?" | The inversion condition — the single highest-value sentence in any design answer. |
| "What did you deliberately not build?" | Scope discipline. |
| "What would you do with another week?" | Prioritisation, and whether you know what is unresolved. |
| "What don't you know here?" | Calibrated honesty. Answer it directly and follow with how you would find out. |
Part XXIX rapid recall
- Level 1 knowledge, level 2 mechanism, level 3 judgment — prepare for all three.
- At level 3: reason from mechanism, name the trade-off space, or say how you would find out. Never bluff.
- "How would you know this is broken in production?" is the most-skipped follow-up in the set.
- Volunteering the weakest part of your own design is a top-tier signal.
- Practise trees, not questions — depth is where the level is decided.
Part XXX
Cheat Sheets & Final Checklists
Compression, not new material. Everything here appears earlier in the book; this is what you read in the last week, and on the last day.
Chapter 176One-page cheat sheets
Kotlin
NPE sources platform types · reflective parsers · lateinit · !!
Variance out = produces (return) · in = consumes (param) · * = unknown
reified forces inline — keep the inlined body one line thick
value class boxes under generics, nullability, interface positions
sealed adding a subtype breaks binary compat for separate compilation
data class copy() bypasses factory invariants; prefer explicit withX
delegation overriding a member does NOT affect calls inside the delegate
sequences win on long chains + large source + short-circuit terminal
sorted() defeats laziness
interop @JvmStatic @JvmOverloads @JvmName @Throws; internal is not private
Kotlin List is java.util.List — mutable from JavaCoroutines and Flow
Scope rule repositories expose suspend/Flow; callers own scopes
GlobalScope never · injected AppScope deliberately
WorkManager for anything that must survive process death
Main-safety belongs to the function that blocks; inject the dispatcher
Room and Retrofit suspend fns are ALREADY main-safe
Cancellation cooperative · ensureActive() in CPU loops
never catch CancellationException · cleanup in finally
withContext(NonCancellable) only inside finally, kept tiny
Errors coroutineScope: sibling failure cancels all, rethrows
supervisorScope: siblings survive
async in a plain scope fails the parent AT THROW, not at await
supervision comes from the SCOPE, not launch(SupervisorJob())
Streams StateFlow = state (conflated, distinct) · Channel = one-shot events
SharedFlow replay>0 re-fires events after rotation
cold flow: N collectors = N executions unless shared
WhileSubscribed(5000) survives rotation, stops on background
Operators conflate=drop · buffer=keep · debounce=wait for silence
sample=steady rate · flatMapLatest=cancel previous (UI default)
Invariants flowOn affects upstream · catch sees upstream only
never withContext around emit; never try/catch around emitCompose
Identity positional in the slot table; key() makes it semantic
State mutating a list inside mutableStateOf does NOT notify
Effects LaunchedEffect = suspending work (key it correctly)
DisposableEffect = register/unregister pairs
rememberUpdatedState = fresh lambda in a long-lived effect
derivedStateOf = high-freq input → low-freq output, cheap derivation
Stability List is unstable (interface) · use ImmutableList
@Immutable is a promise, not a check
strong skipping reduces the tax, doesn't replace immutable modelling
Phases composition → layout → draw
Modifier.offset { } and graphicsLayer { } defer the read, skip phases
Lists key + contentType · no allocation in the item body
never nest same-direction scrollables
Architecture Route (stateful) / Screen (stateless) — enables preview + screenshot
Slow but low → layout cost, draw cost, main-thread work, or missing baseline profile
recomposition → get a system trace; don't guess
Semantics tree = accessibility tree = test treeLifecycle and platform
Survives rotation ViewModel
Survives process death SavedStateHandle, rememberSaveable, disk
Survives everything disk (Room, DataStore)
Test process death adb shell am kill (NOT force-stop)
Binder ~1MB per-process buffer; saved state travels over it
Frame budget 16.7ms @60Hz · 8.3ms @120Hz
ANR ~5s input · ~20s FGS start · Play threshold 0.47% sessions
Launch modes singleTask clears the stack above it
Background work WorkManager = eventually · FGS = now & visible
coroutine = neither durable nor guaranteed
PendingIntent FLAG_IMMUTABLE + explicit component
Collection repeatOnLifecycle cancels the producer;
launchWhenStarted only pauses deliveryArchitecture and modularization
Boundary test justify by the change it isolates; collapse field-for-field mappers
Clean AA real win = pure-Kotlin domain, millisecond tests
not "swap the database" — that never happens
UseCase introduce for logic, multiple sources, or reuse. Not per method
Repository data-access policy yes; domain rules no; UI state never
State vs event survives rotation = state; happens once = event
Loading per-section, not one global flag
Modules features never depend on features
implementation by default; api only for public signatures
shallow+wide parallelises; deep serialises
ABI changes invalidate dependents; bodies don't
one owner per module in CODEOWNERS
enforcement in build logic, not in a wiki
Breakpoint modularization pays around 3 teams, not at a line countNetworking, storage, sync
Mobile network latency and round-trip count dominate, not bandwidth
radio tail time makes chatty clients expensive
Interceptors application = misses cache hits · network = every wire attempt
Authenticator = 401 only, replays for you
Retry connection failures freely · post-send timeouts only with a key
never 4xx · always jitter (herd → self-inflicted outage)
Idempotency key at USER COMMIT, persisted BEFORE the call, reused forever
Unknown is a first-class outcome, never "failed"
Truth network writes to the store; the UI observes the store
SQLite WAL = concurrent readers, one writer
batch inserts in one transaction (50–100×)
EXPLAIN QUERY PLAN: "SCAN TABLE" = no index
Room observable queries invalidate per TABLE — bound with LIMIT
every shipped schema version needs a tested path, forever
Sync opaque server cursor, advanced INSIDE the applying transaction
deletions need tombstones + a retention window
conflict policy is per data type; keep the base version to merge
never resolve with client clocks
Push a wake signal, not data — makes handlers idempotent for free
Bitmaps w × h × 4 bytes; decode to display sizePerformance, testing, security, CI
Perf method quantify → segment → hypothesise → trace → fix → verify → gate
measure on the 25th-percentile device, release build
Startup TTID + TTFD; three-tier init with an owner; CI budget
baseline profile covers startup AND the primary scroll
ANR read EVERY thread; the cause is usually the lock holder
Memory retained ≠ shallow; unbounded growth is not a leak
LeakCanary in instrumented CI
Testing fake what you own and reuse; mock what you don't
inject dispatchers; virtual time; never delay() to wait
flake is worse than absence → quarantine, owner, expiry
measure PR wall-clock + change failure rate, not coverage
Build config cache, build cache, parallel, KSP — before restructuring
volatile inputs (timestamps, abs paths) kill caching
frame build time in engineer-hours
Release trains, staged rollout, automated halt criteria, kill switches
THERE IS NO ROLLBACK
Security the client is hostile territory; enforce server-side
obfuscation ≠ encryption ≠ security
Keystore keys never enter your process; never reuse a GCM IV
biometrics unlock a KEY, not a boolean
PKCE in a Custom Tab; rotating refresh with reuse detection
pinning needs backup pins, expiry, kill switch, monitoringChapter 177100 things every Staff Android engineer should know
A single-sitting read for the night before, weighted toward mechanism and failure modes.
| Area | Facts |
|---|---|
| Kotlin (1–12) | Platform types defeat null safety · reflective parsers can write null into non-null fields · reified forces inlining · value classes box under generics · sealed additions break binary compatibility · copy() bypasses invariants · delegation does not intercept internal calls · internal is not private in bytecode · Kotlin's List is mutable from Java · sequences lose on small collections · sorted defeats laziness · variance is a public-API decision. |
| Coroutines (13–28) | Cancellation is cooperative · CPU loops need ensureActive · never catch CancellationException · async fails the parent at throw time · supervision comes from the scope · launch(SupervisorJob()) does nothing useful · Mutex is not reentrant · never hold a lock across a suspension · Dispatchers.IO and Default share a pool · limitedParallelism bounds a subsystem · runBlocking never on main · Room/Retrofit suspend fns are main-safe · injected dispatchers make tests deterministic · GlobalScope breaks ownership · app-scope is not durability · WorkManager is. |
| Flow (29–40) | Cold means per-collector execution · flowOn affects upstream · catch sees upstream only · never withContext around emit · StateFlow conflates and drops · SharedFlow replay re-fires events · Channel delivers once · WhileSubscribed(5000) survives rotation · replayExpiration controls staleness · flatMapLatest is the UI default · debounce adds latency by design · a shared flow's error kills every collector. |
| Platform (41–56) | Zygote makes startup fast · Binder buffer is ~1 MB · saved state travels over Binder · Choreographer runs per VSYNC · a 40 ms block drops consecutive frames · ANR at ~5 s input · ViewModel survives rotation only · am kill not force-stop · "don't keep activities" tests a different path · singleTask clears the stack · onNewIntent must be handled · PendingIntent must be immutable · package visibility filters queries · notifications are permission-gated · background location needs justification · exact alarms need a special permission. |
| Compose (57–70) | Slot-table identity is positional · key makes it semantic · mutating a list in mutableStateOf does not notify · LaunchedEffect keys matter · DisposableEffect for register/unregister · rememberUpdatedState for stale lambdas · derivedStateOf only for coarse outputs · List is unstable · @Immutable is unverified · strong skipping uses instance equality · deferred reads skip phases · lazy lists compose only what is visible · semantics = accessibility = tests · low recomposition can still be slow. |
| Architecture (71–80) | Justify boundaries by isolated change · pure domain = fast tests · pass-through use cases are cost · repositories never emit UI state · state survives rotation, events do not · per-section loading · features never depend on features · one source of truth per concept · enforcement beats documentation · sometimes the answer is less architecture. |
| Data and network (81–90) | Round trips dominate mobile latency · radio tail costs battery · Authenticator handles 401 replay · single-flight refresh by stale-token comparison · never retry 4xx · always jitter · idempotency keys are persisted at commit · Unknown is a first-class outcome · WAL allows concurrent readers · sync cursors advance inside the transaction. |
| Operations (91–100) | Measure on 25th-percentile devices · TTID and TTFD together · baseline profiles cover scroll too · read every thread in an ANR trace · unbounded growth is not a leak · flake is worse than no tests · volatile build inputs kill caching · there is no rollback · the client is hostile territory · every third-party SDK needs a kill switch and an owner. |
Chapter 178Fifty questions you must answer cold
| Group | Questions |
|---|---|
| Level (1–5) | Senior vs Staff · what do you own that nobody assigned · a decision you got wrong · how you influence without authority · what you would change about your current architecture. |
| Kotlin (6–12) | Why NPEs still happen · out vs in · cost of reified · when value classes box · sealed vs enum · when a sequence loses · what internal guarantees. |
| Coroutines (13–22) | Structured concurrency in one sentence · three async, one fails · why GlobalScope is architectural · making CPU work cancellable · why never catch CancellationException · where main-safety belongs · Mutex vs atomic vs confinement · single-flight refresh · deduplicating five callers · testing without delay. |
| Flow (23–29) | Cold vs hot · five collectors, how many calls · StateFlow vs SharedFlow vs Channel · why WhileSubscribed(5000) · flatMapLatest vs merge · exception transparency · where stateIn belongs. |
| Platform (30–36) | What survives process death · how to test it · why a 40 ms block drops three frames · WorkManager's actual guarantee · why FLAG_IMMUTABLE · what singleTask does to the back stack · what breaks with a new targetSdk. |
| Compose (37–42) | What makes a type stable · why list.add() does not recompose · when derivedStateOf hurts · the three phases and how to skip one · optimising a 10,000-item list · slow screen with low recomposition. |
| Design (43–50) | Offline-first in 60 seconds · two devices edit the same record · duplicate payment prevention · push vs socket vs poll · cursor vs offset pagination · one source of truth · how you would know it is broken in production · what you would deliberately not build. |
Chapter 179The last 24 hours
Review — two hours maximum
- These cheat sheets, once, out loud where you hesitate.
- Your eight stories (Part XXIII) — say the opening two sentences of each aloud.
- The design framework timings (Part XVI) until the phases are automatic.
- The thirteen lenses (Part I) — you should be able to list them in fifteen seconds.
- Your two weakest rapid-fire bands from Part XXVII.
Deliberately skip
- Anything new. A half-learned concept is worse than an honest "I have not used that."
- Obscure API details. Nobody is failed at Staff for not recalling a parameter name.
- Reading more model answers. At this point, production beats recognition.
Logistics
- Confirm the schedule, the format, and what each round covers — ask the recruiter, they will tell you.
- Test the video and screen-share setup on the actual platform.
- Have a drawing tool ready and rehearse using it once; fumbling with a whiteboard app costs real minutes.
- Water, and a hard stop between rounds if you can get one.
First five minutes of each round
- Ask what the round covers and how long it is. This is normal and it prevents mis-pacing.
- For design: spend the first five minutes on requirements. Say that you are doing so.
- For coding: write the signature first, and ask whether it is what they want.
- For behavioural: pick your story deliberately, and say which one you are choosing and why.
When a round is going badly
- Say it out loud: "I've gone down a path I don't think is right — let me step back." Recovery is scored positively; silent flailing is not.
- Return to constraints. "What dominates here is X" re-anchors the conversation.
- Use the hint. Interviewers offer hints to see whether you collaborate; refusing one scores worse than needing it.
- If you do not know: say so, then say how you would find out. That is a legitimate Staff answer.
- A bad round is not a bad loop. Reset between rounds — committees weigh the whole signal.
Questions worth asking them
- How does a technical decision that spans two teams actually get made here?
- What was the last significant technical initiative that failed, and why?
- Who owns mobile architecture today, and how is that changing?
- What would you want the person in this role to have changed in twelve months?
- What is the current state of build times, crash-free rate and release cadence?
The material in this book will not, by itself, get you the offer. What gets the offer is the habit it is built around: naming the constraint, choosing with a stated criterion, saying what would change your mind, and knowing how you would find out you were wrong. Interviewers can tell within two questions whether that habit is real. Practise it out loud until it is.
Part XXXI
Android Best Practices & Anti-Patterns
A side-by-side catalogue of the Android-specific patterns that separate code that works on your phone from code that survives a million devices. Every pair is a defect that has shipped, not a style preference.
The left column is not strawman code — it is what reasonable engineers write under deadline, and most of it passes review. What makes each one wrong is a failure mode that only appears under a condition your development device rarely reproduces: process death, a slow network, a revoked permission, an OEM battery optimiser, or a hundred thousand rows. The why line under each pair is the interview answer.
Chapter 180Lifecycle and context
class MapFragment : Fragment() {
override fun onCreateView(...): View {
viewLifecycleOwner // available…
lifecycleScope.launch { // …but not used
locationRepo.updates().collect { drawPin(it) }
}
return binding.root
}
}
lifecycleScope on a Fragment lives until onDestroy, but the view dies at onDestroyView — which happens every time the fragment goes on the back stack. The collector keeps drawing into a destroyed view hierarchy: a leak plus an IllegalStateException when the binding is gone.
override fun onViewCreated(v: View, s: Bundle?) {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(STARTED) {
locationRepo.updates().collect { drawPin(it) }
}
}
}
Two different lifetimes exist in a Fragment and they differ by a whole back-stack traversal. viewLifecycleOwner plus repeatOnLifecycle tears the collector and the upstream down when the view goes away.
class SessionManager {
var currentUser: User? = null // set at login
val isLoggedIn get() = currentUser != null
}
// Screen assumes it is populated:
if (session.isLoggedIn) showDashboard() else showLogin()
After process death the singleton is reconstructed with currentUser = null while the task record is restored, so the user returns from Recents and is silently logged out. Reproduces constantly on low-end devices and almost never on a developer's phone.
class SessionRepository(private val store: DataStore<Prefs>) {
val session: Flow<Session?> = store.data
.map { it[USER_JSON]?.let(::decodeSession) }
suspend fun signIn(s: Session) = store.edit { it[USER_JSON] = encode(s) }
suspend fun signOut() = store.edit { it.remove(USER_JSON) }
}
The test: if this value vanished right now, would the app misbehave? If yes it belongs on disk, observed — not in a field. Verify with adb shell am kill, not by rotating.
Chapter 181Background work
fun submitReview(review: Review) {
appScope.launch {
api.postReview(review) // no retry, no persistence
toast("Thanks for your review!")
}
}
Three defects. The coroutine dies with the process, so a review composed on a train is silently lost. A transient 503 discards it. And the toast fires from a scope that outlives the screen, so it can appear over an unrelated part of the app — or crash if the Activity is gone.
suspend fun submitReview(review: Review) {
val id = outbox.enqueue( // durable, idempotency key
Operation(id = UUID.randomUUID().toString(), payload = review)
)
SyncWorker.enqueueUnique(context) // constraints + backoff
}
// The UI observes the outbox row and shows pending → sent → failed.
Anything the user believes is saved must reach disk before the network is attempted. WorkManager then guarantees it eventually runs across process death and reboot — and the idempotency key means the retry cannot create a second review.
PeriodicWorkRequestBuilder<SyncWorker>(15, MINUTES).build()
.let { wm.enqueue(it) } // duplicated every launch
// inside the worker:
wakeLock.acquire()
sync() // may throw
wakeLock.release() // unreachable on failure
Every launch enqueues another worker, so they race and multiply. The wakelock leaks on the error path and holds the CPU awake indefinitely. This is the shape of "battery consumption doubled after the release".
wm.enqueueUniquePeriodicWork(
"sync", ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<SyncWorker>(6, HOURS)
.setConstraints(Constraints(
requiredNetworkType = NetworkType.UNMETERED,
requiresBatteryNotLow = true))
.build())
wakeLock.acquire(30_000) // hard timeout
try { sync() } finally { if (wakeLock.isHeld) wakeLock.release() }
Server push is the trigger; periodic work is the safety net, not the mechanism. Unique work makes re-enqueueing idempotent, and a timeout means a crash cannot leave the CPU held.
Chapter 182Permissions and privacy
override fun onCreate(s: Bundle?) {
requestPermissions(arrayOf(CAMERA, ACCESS_FINE_LOCATION,
READ_EXTERNAL_STORAGE, POST_NOTIFICATIONS), 42)
}
fun takePhoto() = camera.capture() // assumes CAMERA is granted
A wall of prompts before the user knows what the app does produces the worst grant rates in the industry. Worse, permissions are revocable at any time — including while backgrounded — so an assumed grant is a SecurityException waiting for a user who changed their mind.
val camera = rememberLauncherForActivityResult(RequestPermission()) { granted ->
if (granted) vm.onCameraReady() else vm.onCameraDenied()
}
fun onAddPhotoClicked() = when {
hasPermission(CAMERA) -> openCamera() // check every time
shouldShowRationale(CAMERA) -> showWhyWeNeedIt()
else -> camera.launch(CAMERA)
}
Ask at the moment the value is obvious — when the user taps "Add photo". Every permission-gated call needs a not-granted-right-now branch, not just a request flow. And prefer the photo picker or ACTION_GET_CONTENT, which need no permission at all.
Chapter 183Notifications, intents and deep links
val pi = PendingIntent.getActivity(ctx, 0,
Intent(ACTION_OPEN), PendingIntent.FLAG_MUTABLE)
// deep link handler
val url = intent.data?.getQueryParameter("next")
webView.loadUrl(url!!) // whatever the link says
A mutable PendingIntent wrapping an implicit intent lets another app fill it in and have it executed with your identity — intent redirection. And loading an arbitrary next parameter into a WebView is an open redirect: a phishing page rendered inside your app, wearing your branding.
val pi = PendingIntent.getActivity(ctx, requestCode,
Intent(ctx, DetailActivity::class.java).putExtra(EXTRA_ID, id),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
// One resolver, unit-testable without an emulator:
fun resolve(uri: Uri): Route? {
if (uri.host !in ALLOWED_HOSTS) return null
return ProductId.parse(uri.lastPathSegment)?.let(Route::Product)
}
Deep links are untrusted input from the internet. Validate the host, parse IDs into typed values, and route through one resolver that a JVM test can cover in milliseconds.
Chapter 184Storage and data
@Query("SELECT * FROM messages ORDER BY ts DESC")
fun all(): Flow<List<Message>> // 80k rows, no index
suspend fun importAll(items: List<Message>) {
items.forEach { dao.insert(it) } // 80k transactions
}
Room invalidates per table, so every write re-runs this query over the whole table and re-maps 80,000 objects — on a hot table that is a permanent CPU load. And per-row inserts mean one fsync each: an import that should take under a second takes a minute.
@Entity(indices = [Index(value = ["chat_id", "ts"])])
data class MessageEntity(...)
@Query("SELECT * FROM messages WHERE chat_id = :id " +
"ORDER BY ts DESC LIMIT :limit")
fun page(id: String, limit: Int = 50): Flow<List<Message>>
@Insert(onConflict = REPLACE)
suspend fun insertAll(items: List<MessageEntity>) // one transaction
The composite index covers both the filter and the sort so SQLite skips the temporary b-tree. Confirm with EXPLAIN QUERY PLAN: "SEARCH … USING INDEX" is what you want, "SCAN TABLE" means you have none.
Room.databaseBuilder(ctx, Db::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()
On the next schema bump this silently deletes everything — including the outbox of writes the user believes were saved. It is a data-loss incident scheduled for whenever someone adds a column.
.addMigrations(MIGRATION_8_9)
@Test fun migrate_8_to_9_preserves_rows() {
helper.createDatabase(TEST_DB, 8).apply {
execSQL("INSERT INTO messages VALUES ('m1','hi',1700000000)"); close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 9, true, MIGRATION_8_9)
db.query("SELECT read FROM messages WHERE id='m1'").use {
it.moveToFirst(); assertEquals(0, it.getInt(0))
}
}
Every shipped schema version is permanent public API — users skip releases, so version 3 must still reach version 12. Export the schema, commit it, and test every adjacent pair in CI.
Chapter 185Networking
suspend fun <T> retry(block: suspend () -> T): T {
repeat(5) {
try { return block() } catch (e: Exception) { delay(1000) }
}
return block()
}
retry { api.placeOrder(cart) }
Four separate faults: it retries 4xx, which will never succeed; it swallows CancellationException, breaking structured concurrency; it retries a non-idempotent write with no key, so a post-send timeout double-charges; and with no jitter, a million clients retry in lockstep and turn a brief backend blip into a sustained outage.
suspend fun <T> retrying(
attempts: Int = 4, baseMs: Long = 300,
retryable: (Throwable) -> Boolean, block: suspend () -> T,
): T {
repeat(attempts) { i ->
try { return block() }
catch (e: CancellationException) { throw e }
catch (e: Throwable) {
if (!retryable(e) || i == attempts - 1) throw e
delay(Random.nextLong(0, min(8_000, baseMs shl i))) // full jitter
}
}
error("unreachable")
}
retrying(retryable = ::isTransient) { api.placeOrder(cart, key = op.id) }
Retry connection failures freely; retry a post-send timeout only behind an idempotency key generated at user commit and persisted before the call; never retry a 4xx.
Chapter 186UI and Compose
LazyColumn {
items(orders) { order -> // no key
val fmt = SimpleDateFormat("dd MMM", Locale.getDefault())
val total = order.lines.sumOf { it.price } // recomputed per frame
Row {
Text(fmt.format(order.date))
AsyncImage(model = order.thumbUrl) // unbounded decode
}
}
}
A formatter allocated per item per frame, a sum recomputed during scroll, and full-resolution bitmap decodes. Without a key, inserting one order at the head shifts every item's remembered state and defeats reuse. This is what "the list is janky but recomposition counts look fine" actually means.
// Mapped once, off the main thread, in the state layer:
data class OrderRow(val id: String, val dateLabel: String,
val totalLabel: String, val thumbUrl: String)
LazyColumn {
items(rows, key = { it.id }, contentType = { "order" }) { row ->
Row {
Text(row.dateLabel)
AsyncImage(
model = ImageRequest.Builder(ctx).data(row.thumbUrl)
.size(120, 120).build(),
modifier = Modifier.size(60.dp))
}
}
}
The item body should read fields and emit UI — nothing else. Every derivation moves into the state mapping, images decode to display size, and keys let the runtime move slots instead of rebuilding them.
data class UiState(
val items: List<Item> = emptyList(),
val navigateTo: Route? = null, // an event
val toast: String? = null, // another event
)
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { nav.navigate(it); vm.clearNav() }
}
Rotation re-emits the state and navigates a second time. Two identical error messages in a row show once, because StateFlow is distinct-until-changed. And every consumer must remember to call a clear method.
val state: StateFlow<UiState> = ... // what the screen looks like
private val _events = Channel<UiEvent>(BUFFERED)
val events = _events.receiveAsFlow() // what happens once
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(STARTED) {
vm.events.collect { e -> when (e) {
is Navigate -> nav.navigate(e.route)
is ShowSnack -> snackbar.showSnackbar(e.text)
} }
}
}
The rule: if it should still be true after rotation it is state; if it should happen exactly once it is an event. A Channel delivers once, buffers while stopped, and replays nothing.
Chapter 187Concurrency
private val _state = MutableStateFlow(UiState())
fun addItem(item: Item) {
_state.value = _state.value.copy(items = _state.value.items + item)
}
fun load() = viewModelScope.launch {
val data = api.fetchBlocking() // blocks Dispatchers.Main
_state.value = _state.value.copy(data = data)
}
Read-modify-write on a StateFlow loses updates under concurrency — two callers read the same value and the second write wins. And a blocking call on the default main dispatcher freezes the frame pipeline: dropped frames now, an ANR under a slow network.
fun addItem(item: Item) =
_state.update { it.copy(items = it.items + item) } // CAS loop
class Repo(private val api: Api, private val io: CoroutineDispatcher) {
/** Safe to call from any dispatcher. */
suspend fun fetch(): Data = withContext(io) { api.fetchBlocking() }
}
update {} retries on conflict so no write is lost. Main-safety belongs to the function that knows it blocks, with an injected dispatcher so the test can replace it — never as a convention call sites must remember.
Chapter 188Security
object Config { const val API_SECRET = "sk_live_9f2b…" } // in the APK
Log.d(TAG, "auth ok, token=$accessToken") // in bug reports
biometricPrompt.authenticate(info)
override fun onAuthenticationSucceeded(r: AuthResult) {
unlocked = true; showBalance() // patchable branch
}
A constant in the APK is extractable with strings. Tokens in logcat reach crash breadcrumbs and support bundles. And a biometric check that only flips a boolean is bypassed by patching the branch — nothing cryptographic depends on the result.
// No secret ships. The server authorises; the client proves identity.
val cipher = Cipher.getInstance(TRANSFORM).apply {
init(Cipher.DECRYPT_MODE, keystoreKey, GCMParameterSpec(128, iv))
}
biometricPrompt.authenticate(info, CryptoObject(cipher))
override fun onAuthenticationSucceeded(r: AuthResult) {
// usable ONLY because the user authenticated — enforced by hardware
val token = r.cryptoObject!!.cipher!!.doFinal(encryptedToken)
session.restore(token)
}
The client runs on hardware the attacker controls. Keys live in the Keystore and never enter your process; biometrics unlock a key rather than setting a flag; and every authorisation decision that matters is made by the server.
Chapter 189Resources, configuration and accessibility
Text("Delivered " + count + " orders") // not translatable
Box(Modifier.size(32.dp).clickable { onDelete() }) {
Icon(Icons.Default.Delete, contentDescription = null)
}
if (screenWidthDp > 600) TwoPane() else OnePane() // breaks on fold
String concatenation cannot be translated or pluralised — word order differs by language. A 32dp target fails the minimum touch size and the icon announces nothing to TalkBack. And a raw width threshold misses that a foldable changes class at runtime, mid-flow.
Text(pluralStringResource(R.plurals.orders_delivered, count, count))
IconButton(onClick = onDelete, // 48dp target, Role.Button
modifier = Modifier.semantics {
contentDescription = "Delete ${item.name}"
}) { Icon(Icons.Default.Delete, contentDescription = null) }
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Expanded -> TwoPane()
else -> OnePane()
}
Plurals handle languages with more than two forms. The semantics annotation serves TalkBack and the test at once — a screen that is hard to test is usually inaccessible. And window size classes react to folding and resizing, which fixed thresholds cannot.
Chapter 190The catalogue as a checklist
| Area | The question to ask in review |
|---|---|
| Lifecycle | Which lifetime does this work belong to — view, screen, user intent, or the device? |
| State | If the process died right now, would the user lose something they believe was saved? |
| Background | Must this happen eventually, or now and visibly? Is it unique, constrained and backed off? |
| Permissions | Is there a branch for "granted before, revoked since"? |
| Intents | Immutable and explicit? Is every deep-link parameter validated as untrusted input? |
| Storage | Bounded query, covering index, batched write, tested migration? |
| Networking | Which errors are retryable, is there jitter, and is the write idempotent? |
| UI | Any allocation or derivation inside an item body? Keys present? Events out of state? |
| Concurrency | Read-modify-write anywhere? Is main-safety owned by the blocking function? |
| Security | Does anything in the APK need to stay secret? Does a check gate a key or a boolean? |
| Resources | Concatenated strings, fixed touch targets, or hardcoded width thresholds? |
| Observability | If this fails silently in production, which metric moves? |
Part XXXI rapid recall
viewLifecycleOwnerfor anything touching the view;repeatOnLifecycleto stop the producer.- State that matters is persisted and observed, never a field on a singleton.
- Write to disk before the network for anything the user believes is saved.
- Unique work, constraints, backoff, and wakelocks with a timeout in a
finally. - Ask for permissions in context and re-check on every use.
FLAG_IMMUTABLEplus an explicit component; deep links are untrusted input.- Bounded queries, covering indexes, batched inserts, tested migrations — never destructive.
- Classify errors, jitter the backoff, key the write.
- Nothing derived or allocated in a lazy item body; keys always; events in a channel.
update {}not read-modify-write; main-safety at the blocking function.- Biometrics unlock a key; the server is the only real authority.
- Plurals, semantics, and window size classes rather than concatenation, 32dp and magic numbers.
Part XXXII
End-to-End: a paginated, offline-first feature
One vertical slice built completely — Hilt, Room, Paging 3, coroutines, StateFlow and SharedFlow, WorkManager and Compose — with the trade-off behind every line stated out loud. Then the tasks an interviewer builds out of exactly this slice.
Most of this book argues about decisions in isolation. This part does the opposite: it wires one realistic feature end to end so the seams are visible — where pagination meets offline, where an optimistic write meets a server that disagrees, where a Flow stops being cold. Every snippet is production-shaped. Where a shortcut is taken, the why line says so.
Chapter 191The brief, and the one rule
Build a saved-articles feed. A cursor-paginated endpoint returns articles; the list scrolls indefinitely; each row has a save toggle. It must open instantly on a cold start with no network, scroll through everything already cached, accept save toggles while offline, and converge with the server when connectivity returns. Nothing may be lost to process death.
That brief contains the entire difficulty, and it resolves to one rule:
The UI reads the database and nothing else. The network is a process that writes into the database; so is the sync worker. Neither is ever on the path between the user and the pixels. Every design decision below follows from this, and almost every architecture that fails at this brief has broken it somewhere.
Once the database is the only reader-visible surface, "offline" stops being a mode with its own code path. There is no if (isOnline) anywhere in this feature. Offline is simply the case where the writers are quiet — the reader cannot tell, and does not need to.
flowchart LR
API["Feed API · cursor pages"] --> RM["RemoteMediator"]
RM --> DB[("Room · articles + remote_keys")]
DB --> PS["PagingSource"]
PS --> PAGER["Pager · PagingData"]
PAGER --> VM["FeedViewModel · cachedIn"]
VM --> UI["Compose · LazyColumn"]
UI -- "toggle save" --> VM
VM --> REPO["Repository · optimistic write"]
REPO --> DB
REPO --> OUT[("Room · outbox")]
OUT --> W["SyncWorker"]
W --> API
W --> DB
Read the diagram as two loops that meet only in Room. The read loop is API → mediator → database → paging source → UI. The write loop is UI → repository → database + outbox → worker → API → database. They never call each other. That is what makes each one independently testable, and what makes the feature survive an airplane-mode toggle mid-scroll.
Module layout
| Module | Contains |
|---|---|
core:network | Retrofit, OkHttp, the auth interceptor, DTOs. Knows nothing about Room. |
core:database | Room entities, DAOs, migrations, the outbox. Knows nothing about Retrofit. |
core:model | Domain types. Depends on nothing. |
data:feed | The repository, the RemoteMediator, the mappers, the sync worker. The only module that sees both sides. |
feature:feed | ViewModel and Compose. Depends on data:feed's interface, never its implementation. |
The mapper placement is deliberate and gets asked about: DTO→entity and entity→domain both live in data:feed, because it is the only module that should know that a thumb_url column and a thumbUrl JSON field are the same idea. Put the mapper in core:network and the network module now depends on the database; put it in feature:feed and every feature re-implements it.
Chapter 192The API contract
Pagination style is the first real decision, and it is the server's decision that the client must live with. Establish it before writing anything.
| Style | Mechanism | Where it breaks |
|---|---|---|
| Offset / page number | ?page=3&size=20. Trivial server-side, trivially resumable. | An insert at the head shifts every page boundary: the user sees a duplicated row and misses another. Cost grows with offset because the database still walks the skipped rows. |
| Cursor / keyset | ?cursor=eyJ0cyI6…&limit=20. The cursor encodes the sort key of the last row. | Cannot jump to an arbitrary page, and the cursor is opaque — the client must persist it because it cannot recompute it. Requires a stable, unique sort key. |
| Sync token / delta | Server returns a token representing "everything you know"; the next call returns changes since. | Best for a feed that must converge rather than merely append, but requires server-side tombstones and change tracking. See Part XVII. |
This feature uses cursor pagination, which is the correct default for an append-only feed and the one that forces you to solve the interesting problem: the cursor is opaque, so it must be persisted alongside the data or the app cannot resume paging after process death.
interface FeedApi {
@GET("v1/articles")
suspend fun articles(
@Query("cursor") cursor: String?, // null = first page
@Query("limit") limit: Int,
): PageDto<ArticleDto>
/** Idempotent by header, so a retry after a post-send timeout is safe. */
@PATCH("v1/articles/{id}/state")
suspend fun updateState(
@Path("id") id: String,
@Header("Idempotency-Key") key: String,
@Body body: StatePatchDto,
): ArticleDto
}
@Serializable
data class PageDto<T>(
val items: List<T>,
/** null means the server has nothing more. Not an empty string. */
@SerialName("next_cursor") val nextCursor: String? = null,
@SerialName("server_time") val serverTime: Long,
)
@Serializable
data class ArticleDto(
val id: String,
val title: String,
val excerpt: String,
@SerialName("thumb_url") val thumbUrl: String? = null,
val saved: Boolean,
@SerialName("updated_at") val updatedAt: Long,
/** Server-assigned ordering key. The client never invents one. */
val position: Long,
)Three details that are worth arguing for in an interview. The end-of-pagination signal is a nullable cursor, not an empty item list — an empty page with a cursor is a legitimate response when the server filters after paging, and treating it as the end truncates the feed. Every DTO field that the server may add later has a default, so an older client does not crash on a newer payload. And position comes from the server: if the client orders by updated_at, an edit reorders the feed under the user's thumb mid-scroll.
Chapter 193Room as the single source of truth
The schema carries three things that a naive cache does not: which feed a row belongs to, where the server put it, and what the user has done to it that the server has not seen yet. That last column is what makes offline writes work.
@Entity(
tableName = "articles",
indices = [Index(value = ["feed_id", "position"])], // covers filter + sort
)
data class ArticleEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "feed_id") val feedId: String,
val title: String,
val excerpt: String,
@ColumnInfo(name = "thumb_url") val thumbUrl: String?,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
val position: Long,
/** Server truth. Only sync writes this. */
@ColumnInfo(name = "saved_remote") val savedRemote: Boolean,
/** Local intent while a write is unacknowledged. null = agrees with server. */
@ColumnInfo(name = "saved_local") val savedLocal: Boolean? = null,
)
@Entity(tableName = "remote_keys")
data class RemoteKeyEntity(
@PrimaryKey @ColumnInfo(name = "feed_id") val feedId: String,
/** The opaque cursor for the NEXT append. null = end reached. */
@ColumnInfo(name = "next_cursor") val nextCursor: String?,
@ColumnInfo(name = "refreshed_at") val refreshedAt: Long,
)Splitting saved into a remote column and a local override is the single most important decision in this part. One mutable saved boolean cannot answer the question sync must ask on every reconnect — is this value the server's, or something the user did that I still owe the server? — and without that answer a refresh silently reverts the user's taps.
/** The projection the list renders. Note it has no nullable-override concept:
* the merge happens in SQL so no caller can forget it. */
data class ArticleRow(
val id: String,
val title: String,
val excerpt: String,
@ColumnInfo(name = "thumb_url") val thumbUrl: String?,
val saved: Boolean,
val pending: Boolean,
)
@Dao
interface ArticleDao {
@Query("SELECT id, title, excerpt, thumb_url, " +
"COALESCE(saved_local, saved_remote) AS saved, " +
"(saved_local IS NOT NULL) AS pending " +
"FROM articles WHERE feed_id = :feedId ORDER BY position ASC")
fun pagingSource(feedId: String): PagingSource<Int, ArticleRow>
@Upsert
suspend fun upsertAll(items: List<ArticleEntity>) // one transaction
@Query("UPDATE articles SET saved_local = :saved WHERE id = :id")
suspend fun setSavedLocal(id: String, saved: Boolean)
@Query("UPDATE articles SET saved_remote = :saved, saved_local = NULL, " +
"updated_at = :updatedAt WHERE id = :id")
suspend fun applyServerState(id: String, saved: Boolean, updatedAt: Long)
/** Refresh clears the server's view of the feed — never the local overrides. */
@Query("DELETE FROM articles WHERE feed_id = :feedId AND saved_local IS NULL")
suspend fun clearSyncedRows(feedId: String)
}Why the merge is a SQL expression. COALESCE(saved_local, saved_remote) means the UI physically cannot render a stale value, and the pending flag that drives the "not synced yet" affordance falls out of the same row for free. Compute this in Kotlin instead and you have introduced a rule that every future call site must remember; here it is impossible to get wrong because there is no other query.
Why clearSyncedRows has a WHERE clause. The standard RemoteMediator sample deletes the whole table on REFRESH. Do that here and a user who saved three articles on the underground loses all three the moment the app reconnects and refreshes — the local intent is deleted before the worker ever sends it. This is the most common data-loss bug in offline-first paging, and it is invisible in testing because it needs an offline write followed by a refresh.
Chapter 194Hilt wiring
Dependency injection earns its cost here in exactly one way: everything that touches the outside world — clock, dispatcher, network, database — arrives through the constructor, so the whole slice runs in a JVM test with no emulator. That is the argument to make, not "it reduces boilerplate".
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class ApplicationScope
@Module @InstallIn(SingletonComponent::class)
object CoroutinesModule {
@Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO
/** Survives every screen. For work that must finish even if the user leaves. */
@Provides @Singleton @ApplicationScope
fun appScope(@IoDispatcher io: CoroutineDispatcher): CoroutineScope =
CoroutineScope(SupervisorJob() + io)
}class ArticleRepository @Inject constructor(private val api: FeedApi) {
suspend fun refresh() = withContext(Dispatchers.IO) { api.articles(null, 20) }
}
The test now needs a real thread pool, so it cannot use virtual time: every delay in the code under test costs real wall-clock seconds, and assertions race the dispatcher. Suites written this way are the origin of most "flaky on CI, passes locally" tickets.
class ArticleRepository @Inject constructor(
private val api: FeedApi,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun refresh() = withContext(io) { api.articles(null, 20) }
}
// test: ArticleRepository(fakeApi, StandardTestDispatcher(scheduler))
One TestDispatcher makes the whole slice deterministic and instant. This is the concrete payoff of DI in Android and the reason to introduce Hilt at all — say it in exactly these terms.
@Module @InstallIn(SingletonComponent::class)
abstract class FeedDataModule {
/** @Binds, not @Provides — no factory code is generated for a cast. */
@Binds @Singleton
abstract fun repository(impl: ArticleRepositoryImpl): ArticleRepository
}
@Module @InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides @Singleton
fun db(@ApplicationContext ctx: Context): AppDatabase =
Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db")
.addMigrations(MIGRATION_4_5) // never fallbackToDestructiveMigration
.build()
@Provides fun articleDao(db: AppDatabase): ArticleDao = db.articleDao()
@Provides fun outboxDao(db: AppDatabase): OutboxDao = db.outboxDao()
}The scoping question interviewers ask: why is the repository @Singleton? Not for performance — because it owns the outbox and the refresh signal, and two instances would mean two independent views of pending work. Scope follows shared mutable state, not object cost. A stateless mapper needs no scope at all, and adding one there is cargo cult.
Chapter 195RemoteMediator — where pagination meets offline
RemoteMediator is the piece people get wrong, because its job is counter-intuitive: it does not supply pages to the UI. It is a write-behind component that notices the list is running out of cached rows and fills the database. The PagingSource over Room is what the UI actually reads, and it re-emits automatically because Room invalidates the query.
@OptIn(ExperimentalPagingApi::class)
class ArticleRemoteMediator(
private val feedId: String,
private val api: FeedApi,
private val db: AppDatabase,
private val articles: ArticleDao,
private val keys: RemoteKeyDao,
private val clock: Clock,
) : RemoteMediator<Int, ArticleRow>() {
/** Skip the network entirely if the cache is fresh — this is what makes
* a warm start render instantly instead of flashing a spinner. */
override suspend fun initialize(): InitializeAction {
val age = clock.now() - (keys.refreshedAt(feedId) ?: 0L)
return if (age < CACHE_TTL_MS) InitializeAction.SKIP_INITIAL_REFRESH
else InitializeAction.LAUNCH_INITIAL_REFRESH
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ArticleRow>,
): MediatorResult {
val cursor: String? = when (loadType) {
LoadType.REFRESH -> null // start from the top
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
LoadType.APPEND -> keys.nextCursor(feedId)
?: return MediatorResult.Success(endOfPaginationReached = true)
}
return try {
val page = api.articles(cursor = cursor, limit = state.config.pageSize)
db.withTransaction { // atomic: keys and rows agree
if (loadType == LoadType.REFRESH) {
articles.clearSyncedRows(feedId) // preserves local overrides
keys.clear(feedId)
}
keys.upsert(RemoteKeyEntity(feedId, page.nextCursor, clock.now()))
articles.upsertAll(page.items.map { it.toEntity(feedId) })
}
MediatorResult.Success(endOfPaginationReached = page.nextCursor == null)
} catch (e: CancellationException) {
throw e // never swallow cancellation
} catch (e: IOException) {
MediatorResult.Error(e) // offline: cache still renders
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}Why the cursor comes from a table, not from state.lastItemOrNull(). The samples derive the next key from the last loaded item, which only works when the key is a field of the item. An opaque cursor is not — it encodes server-side sort state the client cannot reconstruct. Persisting it in remote_keys is also what lets paging resume after process death: the user returns from Recents forty rows down and the next append continues correctly instead of re-fetching page one.
Why the transaction wraps both writes. Without it, a crash between "store cursor" and "store rows" leaves a cursor pointing past data that was never saved, and those articles are invisible forever — a gap the user cannot recover from without clearing app data.
Why MediatorResult.Error is not a failure state for the screen. It records that this load failed. The PagingSource over Room keeps emitting whatever is cached, so the list stays fully readable offline. The UI's job — Chapter 198 — is to show that error as a retry affordance without blanking the content, and that distinction is the entire difference between offline-first and offline-hostile.
"What happens if the user scrolls fast while offline?" Paging invokes load once per boundary, sees Error, and stops — it does not hammer. Recovery is retry(), driven by the user or by a connectivity signal. If you also want automatic recovery on reconnect, collect connectivity in the ViewModel and call retry(); do not put a network callback inside the mediator, which has no lifecycle of its own.
Chapter 196The repository, and where SharedFlow is genuinely right
The repository assembles the Pager and owns the write path. It deliberately does not call cachedIn — that operator needs a scope, and the only correct scope is the ViewModel's. A repository is a singleton; caching paging state there leaks it across screens.
@Singleton
class ArticleRepositoryImpl @Inject constructor(
private val api: FeedApi,
private val db: AppDatabase,
private val articles: ArticleDao,
private val outbox: OutboxDao,
private val keys: RemoteKeyDao,
private val sync: SyncScheduler,
private val clock: Clock,
@IoDispatcher private val io: CoroutineDispatcher,
) : ArticleRepository {
override fun feed(feedId: String): Flow<PagingData<ArticleRow>> = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 10, // start the next fetch 10 rows early
initialLoadSize = 40, // fill the first screen in one round trip
enablePlaceholders = false, // count is unknown with cursor paging
),
remoteMediator = ArticleRemoteMediator(feedId, api, db, articles, keys, clock),
pagingSourceFactory = { articles.pagingSource(feedId) },
).flow // cachedIn belongs to the ViewModel
/** Returns as soon as the intent is durable. The network is not on this path. */
override suspend fun setSaved(articleId: String, saved: Boolean) = withContext(io) {
db.withTransaction {
articles.setSavedLocal(articleId, saved)
outbox.upsert(
OutboxEntity(
articleId = articleId,
saved = saved,
opId = UUID.randomUUID().toString(), // fresh idempotency key
createdAt = clock.now(),
)
)
}
sync.requestSync()
}
}The PagingConfig numbers are an argument, not a default. initialLoadSize above pageSize exists so the first screen does not need two round trips; setting them equal is the most common cause of "the list loads, then immediately loads again". prefetchDistance trades data for smoothness — raise it on a fast feed, lower it on a metered one. And enablePlaceholders requires a known total count, which a cursor API cannot give you, so it must be off here.
StateFlow, SharedFlow, or Channel
All three appear in this feature, doing three different jobs. Choosing by habit rather than by question is what produces duplicated navigation and lost snackbars.
| Primitive | The question it answers | Used here for |
|---|---|---|
StateFlow | "What does the screen look like right now?" Always has a value, conflated, distinct-until-changed. | Screen state — filter selection, sync banner. Survives rotation by definition. |
SharedFlow | "Something happened that several unrelated observers care about." Configurable replay, multicast, no current value. | The app-wide sync-finished signal. Two screens and a badge subscribe; none owns it. |
Channel | "Something must be handled exactly once by exactly one consumer." | Snackbars and navigation. Buffers while the screen is stopped, replays nothing on rotation. |
@Singleton
class SyncSignals @Inject constructor() {
private val _completed = MutableSharedFlow<SyncResult>(
replay = 0, // a sync that finished before
// you opened the screen is not news
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val completed: SharedFlow<SyncResult> = _completed.asSharedFlow()
/** tryEmit, not emit: the worker must never be suspended by a slow subscriber. */
fun publish(result: SyncResult) { _completed.tryEmit(result) }
}Why this is a SharedFlow and the snackbar is not. Multicast is the whole point: the feed screen, the settings screen and a toolbar badge each want to know that a sync finished, and none of them owns the signal. A Channel would deliver to exactly one of them, arbitrarily. Conversely a snackbar has exactly one consumer and must not be dropped when the screen is briefly stopped — that is a Channel. Getting these backwards produces the two classic bugs: an event that reaches only one of three collectors, and a toast that vanishes because it fired while the app was backgrounded.
replay = 0 with a non-zero buffer plus DROP_OLDEST is the configuration that makes tryEmit always succeed and never block the worker. A replay = 1 here would re-deliver the last sync result to every newly-opened screen — a stale snackbar on every navigation.
Chapter 197The ViewModel
@HiltViewModel
class FeedViewModel @Inject constructor(
private val repo: ArticleRepository,
private val signals: SyncSignals,
savedState: SavedStateHandle,
) : ViewModel() {
private val feedId: String = checkNotNull(savedState["feedId"])
/** cachedIn survives configuration change; without it, rotation refetches
* page one and the user loses their scroll position. */
val items: Flow<PagingData<ArticleRow>> =
repo.feed(feedId).cachedIn(viewModelScope)
private val _state = MutableStateFlow(FeedUiState())
val state: StateFlow<FeedUiState> = _state.asStateFlow()
private val _events = Channel<FeedEvent>(Channel.BUFFERED)
val events: Flow<FeedEvent> = _events.receiveAsFlow()
init {
viewModelScope.launch {
signals.completed.collect { result ->
_state.update { it.copy(syncing = false) }
if (result is SyncResult.Failed) {
_events.send(FeedEvent.Snack("Changes will sync when you're back online"))
}
}
}
}
fun onSaveToggled(row: ArticleRow) {
viewModelScope.launch {
_state.update { it.copy(syncing = true) }
try {
repo.setSaved(row.id, !row.saved) // returns once durable
} catch (e: CancellationException) {
throw e // structured concurrency
} catch (e: Throwable) {
_events.send(FeedEvent.Snack("Couldn't save — please try again"))
}
}
}
}
data class FeedUiState(val syncing: Boolean = false, val filter: Filter = Filter.All)
sealed interface FeedEvent { data class Snack(val text: String) : FeedEvent }Note what is not in FeedUiState: the list. Paging owns its own stream and its own load states; copying rows into the state object means holding every loaded page in memory and re-emitting the whole list on every unrelated state change. Two streams from one ViewModel is the correct shape here, not a smell.
Note the absence of runCatching. It catches Throwable, which includes CancellationException — so a ViewModel cleared mid-write would be reported to the user as a save failure, and the coroutine machinery would be told the cancellation was handled. Explicit rethrow, every time.
val user: StateFlow<User?> = repo.observeUser()
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
Eagerly keeps the upstream — and any database or socket it holds — alive for the ViewModel's whole life, including the hours the app spends in the background. On a screen the user is not looking at, that is a battery and memory cost with no reader.
val user: StateFlow<User?> = repo.observeUser()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
The five seconds is not superstition: it is longer than a configuration change and shorter than a user's attention. The upstream survives rotation without restarting, and stops when the screen is genuinely gone.
Chapter 198The Compose layer
Load-state handling is where offline-first is won or lost. There are three independent load states — refresh, append, prepend — and the rule is that an error only takes over the screen when there is nothing to show.
@Composable
fun FeedScreen(
snackbar: SnackbarHostState,
vm: FeedViewModel = hiltViewModel(),
) {
val items = vm.items.collectAsLazyPagingItems()
val state by vm.state.collectAsStateWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.events.collect { event ->
when (event) {
is FeedEvent.Snack -> snackbar.showSnackbar(event.text)
}
}
}
}
val refresh = items.loadState.refresh
val isEmpty = items.itemCount == 0
when {
refresh is LoadState.Loading && isEmpty -> FeedSkeleton()
refresh is LoadState.Error && isEmpty ->
ErrorPane(refresh.error, onRetry = items::retry)
refresh is LoadState.NotLoading && isEmpty -> EmptyPane()
else -> LazyColumn {
// Cached content is showing; a failed refresh is a banner, not a takeover.
if (refresh is LoadState.Error) {
item { StaleBanner(onRetry = items::retry) }
}
items(
count = items.itemCount,
key = items.itemKey { it.id },
contentType = items.itemContentType { "article" },
) { index ->
items[index]?.let { row ->
ArticleCard(
row = row,
onToggleSave = { vm.onSaveToggled(row) },
)
}
}
when (val append = items.loadState.append) {
is LoadState.Loading -> item { AppendSpinner() }
is LoadState.Error -> item { AppendRetry(onRetry = items::retry) }
else -> Unit
}
}
}
}when (items.loadState.refresh) {
is LoadState.Loading -> FullScreenSpinner()
is LoadState.Error -> FullScreenError() // 200 cached rows, hidden
else -> ArticleList(items)
}
Every one of the user's cached articles is replaced by an error page the moment the device loses signal. All the work in Chapters 193–196 exists to make offline reading possible, and four lines of UI throw it away. This is the single most common bug in Paging 3 code review.
when {
refresh is LoadState.Error && items.itemCount == 0 -> FullScreenError(::retry)
refresh is LoadState.Error -> { StaleBanner(::retry); ArticleList(items) }
else -> ArticleList(items)
}
Nothing cached means a full-screen error is the only honest UI. Something cached means the content stays and the failure is a dismissible banner. State the rule as: degrade, never blank.
Why key and contentType are not optional here. A save toggle updates one row; without a stable key, Room's invalidation produces a new PagingData and Compose rebuilds every visible item — a visible stutter on a mid-tier device. contentType lets the runtime reuse slots between items of the same shape rather than discarding them.
Why collectAsStateWithLifecycle rather than collectAsState. The latter keeps collecting while the app is in the background: a state flow backed by a database query goes on running against a screen nobody can see. On the events channel the difference is sharper still — repeatOnLifecycle(STARTED) is what makes a snackbar wait for the user to come back instead of being shown to an empty screen.
Chapter 199Writes: optimistic update plus outbox
The write path has one requirement that dictates its whole shape: the tap must be durable before the network is attempted. Everything else follows.
@Entity(tableName = "outbox")
data class OutboxEntity(
/** Keyed by article, so repeated toggles COALESCE into one pending intent. */
@PrimaryKey @ColumnInfo(name = "article_id") val articleId: String,
val saved: Boolean,
/** Regenerated whenever the intent changes. Sent as Idempotency-Key. */
@ColumnInfo(name = "op_id") val opId: String,
@ColumnInfo(name = "created_at") val createdAt: Long,
val attempts: Int = 0,
)The primary key is the design. Saving, unsaving and re-saving an article produces one row, not three: for a set-membership toggle only the final intent has meaning and the intermediate states are not business events. That is not universally true — an outbox of "post a comment" operations must be append-only and keyed by operation id, because every comment is a distinct fact. Choosing between these two is a genuine Staff-level question, and the answer is: does replaying only the last operation produce the same server state as replaying all of them? If yes, coalesce; if no, append.
The idempotency key is generated at user commit and persisted with the row — not at send time. A key generated when the request is dispatched changes on every retry, which defeats the entire mechanism: the server sees two distinct operations and applies both.
| Moment | What the user sees | What is true on disk |
|---|---|---|
| Tap | Icon fills immediately; a subtle pending mark | saved_local = 1, outbox row written, same transaction |
| Offline, app killed | Reopens with the icon still filled | Both rows survived; worker is queued by constraint |
| Reconnect | Pending mark clears; nothing moves | saved_remote = 1, saved_local = NULL, outbox row deleted |
| Server rejects (4xx) | Icon reverts, with an explanation | saved_local = NULL, outbox row deleted — server wins |
Chapter 200The sync worker
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted ctx: Context,
@Assisted params: WorkerParameters,
private val api: FeedApi,
private val db: AppDatabase,
private val articles: ArticleDao,
private val outbox: OutboxDao,
private val signals: SyncSignals,
) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
var retryable = false
for (op in outbox.oldestBatch(limit = 50)) {
try {
val dto = api.updateState(
id = op.articleId,
key = op.opId, // survives retries
body = StatePatchDto(saved = op.saved),
)
db.withTransaction {
articles.applyServerState(dto.id, dto.saved, dto.updatedAt)
outbox.delete(op.articleId)
}
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
retryable = true // offline — keep the row
} catch (e: HttpException) {
if (e.code() == 429 || e.code() in 500..599) {
retryable = true
} else {
// 4xx will never succeed. Drop the intent, revert to server truth,
// and make sure the user is told rather than silently overruled.
db.withTransaction {
articles.clearLocalOverride(op.articleId)
outbox.delete(op.articleId)
}
signals.publish(SyncResult.Rejected(op.articleId))
}
}
}
signals.publish(if (retryable) SyncResult.Failed else SyncResult.Ok)
return if (retryable) Result.retry() else Result.success()
}
}
class SyncScheduler @Inject constructor(@ApplicationContext private val ctx: Context) {
fun requestSync() = WorkManager.getInstance(ctx).enqueueUniqueWork(
"outbox-sync",
ExistingWorkPolicy.KEEP, // draining is idempotent
OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build(),
)
}Error classification is the substance of this worker. Retrying a 400 forever burns battery and never succeeds; not retrying a 503 loses the user's data. The three buckets — transient (retry), permanent (drop and inform), cancellation (rethrow) — are what an interviewer is checking for, and most candidates write a single catch (e: Exception) { return Result.retry() }.
Why KEEP and not APPEND. The worker drains the whole outbox, so a second request arriving while one runs is redundant. The honest caveat: an operation enqueued after the batch read but before completion would wait for the next trigger. Two mitigations, and naming one unprompted is a strong signal — re-check outbox.count() at the end of doWork and return Result.retry() if non-zero, and keep a low-frequency periodic worker as the safety net rather than the mechanism.
Why oldestBatch(limit = 50) and not the whole table. A user who spent a week offline can accumulate thousands of operations; an unbounded loop inside a worker with a ten-minute execution limit gets killed halfway through, with no partial progress recorded if the deletes were in one outer transaction. Bounded batches make progress monotonic.
Chapter 201Conflict resolution
Two devices toggle the same article while both are offline. Whatever the strategy, state it explicitly — "the server wins" is a legitimate answer; "I hadn't thought about it" is the one that fails the round.
| Strategy | Mechanism | Correct when |
|---|---|---|
| Server authoritative | Client sends intent; the server's response is applied verbatim, overwriting local. What this feature does. | The server can validate — entitlements, quotas, moderation. Simplest to reason about and the right default. |
| Last-write-wins | Compare timestamps, newest wins. | Only with a server-issued clock. Device clocks are user-settable, so LWW on client time is a data-loss bug wearing a strategy's name. |
| Merge by operation | Send the operation ("add to set"), not the resulting value, and let the server apply it. | Concurrent edits to different parts of one object, or set membership. Naturally convergent — order does not matter. |
| Surface to the user | Keep both versions, ask. | The conflict is semantically meaningful and expensive to get wrong — a document body, not a bookmark. |
This feature is quietly in the third category and it is worth noticing why: "saved" is set membership, and add and remove are idempotent operations. Two devices both saving converge on saved regardless of arrival order. Had the field been a counter or a free-text note, the same architecture with the same code would be wrong — the outbox would need to carry deltas rather than final values. The architecture is not what makes it converge; the shape of the operation is.
Chapter 202Testing the slice
Everything above runs on the JVM. Nothing here needs an emulator except the Room migration test, which needs one because it must exercise real SQLite.
| Under test | How | The assertion that matters |
|---|---|---|
RemoteMediator | Call load() directly with a hand-built PagingState. No Pager needed. | REFRESH after an offline write leaves saved_local intact — the data-loss regression from Chapter 193. |
| Paging end to end | paging-testing: repo.feed(id).asSnapshot { scrollTo(60) } | Exactly four pages fetched, no duplicate ids across boundaries. |
| Repository writes | In-memory Room plus a fake FeedApi. | After setSaved, both the override and the outbox row exist in the same transaction — kill the process between them and the feature is broken. |
| ViewModel | Turbine on state and events, StandardTestDispatcher. | A failed sync emits exactly one snackbar event, and re-collecting after rotation does not replay it. |
| Worker | Construct it directly; fake api returns 503 then 200. | The first pass returns retry() and keeps the row; the second clears it. Same opId both times. |
| Migrations | MigrationTestHelper on-device, real files. | Every adjacent version pair, with rows present. See Part XXXI. |
@Test fun refresh_preserves_unsynced_local_edits() = runTest {
// given: an article cached, then saved while offline
articles.upsertAll(listOf(article(id = "a1", savedRemote = false)))
repo.setSaved("a1", true)
// when: the feed refreshes from the network
mediator.load(LoadType.REFRESH, emptyPagingState())
// then: the user's intent survived, and is still owed to the server
assertTrue(articles.row("a1").saved)
assertEquals(1, outbox.count())
}This test fails against the canonical RemoteMediator sample, which is the point. It encodes the one behaviour that separates a cache from an offline-first store, and it is cheap enough to run on every commit.
Chapter 203Interview tasks built on this feature
Interviewers rarely ask you to build all of the above — there is no time. They take one seam and press on it. These are the tasks that actually appear, with what each is really measuring and the trap that catches strong candidates.
| Task | Level · time | What it is really testing |
|---|---|---|
| Wire a paginated list from an existing API to a Compose screen. | Core 60 min | Whether you reach for a database at all, or paginate straight from the network into memory. The latter is the down-level. |
| Add offline support to this working online-only feed. | Advanced take-home | Whether "offline" becomes a branch or becomes the architecture. Look for a single source of truth and no isOnline checks. |
| The save button feels laggy. Fix it. | Core 30 min | Optimistic update plus durable intent — not a faster network call. Follow-up is always "what if it fails?". |
| Users report saves disappearing after a few minutes. | Advanced 45 min | Debugging judgment. The bug is refresh deleting unsynced local state. Do you reproduce before theorising? |
| Make this ViewModel testable. | Starter 30 min | Injected dispatchers and clock, an interface at the repository boundary, no static System.currentTimeMillis(). |
| Two devices, both offline, both toggle. What ships? | Staff+ discussion | Whether you have a stated conflict policy and know why device clocks disqualify LWW. |
| Add pull-to-refresh without losing scroll position. | Core 30 min | cachedIn, refresh() versus invalidate(), and what a REFRESH does to the remote keys table. |
| Review this PR (the anti-pattern column of Chapter 198). | Core 45 min | Whether you spot that a failed refresh blanks 200 cached rows — and whether you lead with it or with naming style. |
| The list stutters when a save toggles. Diagnose. | Advanced 30 min | Missing key, work inside the item body, or a state copy that re-emits the whole list. Do you measure or guess? |
| Extend the outbox to support commenting. | Staff+ 45 min | Whether you notice that coalescing by entity id is wrong for append-only operations. The best question in this list. |
Task · live coding, 45 minutes
"Here is a working online-only feed. Make it work offline."
What the interviewer is watching for
Not the code — the first sentence. A candidate who starts typing is guessing. A candidate who asks "should the user be able to write offline, or only read?" has already separated the two problems that this task hides, and the answer changes the design by an order of magnitude.
Graded expectations
L3 Adds Room, caches responses, reads from the cache when a request fails. Works, and has an if (networkAvailable) in it. Writes are not addressed unless prompted.
L4 Inverts the flow: the database becomes the only thing the UI reads and the network becomes a writer. Names the consequence — there is no offline branch because there is no online branch either. Adds an outbox for writes, generates the idempotency key at commit, and identifies that refresh must not delete unsynced rows. States what is not handled: deletions on the server produce stale local rows without tombstones.
L5 Adds the organisational frame: this is a pattern the whole app will copy, so it ships as a documented module with a template and a lint rule, not as one feature's cleverness. Names the measurable success criterion — not "it works offline" but a drop in save-related support tickets and a rise in successful writes per session. Names the exit: if the product later needs real-time collaboration, this outbox is the wrong shape and here is what replaces it.
Follow-ups you should expect
- "The user saves while offline, then the article is deleted server-side. What happens?" — The sync gets a 404, which is permanent: drop the operation, revert the override, tell the user. Without tombstones the row itself lingers until the next full refresh; say so rather than implying convergence you have not built.
- "How large can the outbox get?" — Unbounded is a bug. Cap it, and decide what full means: reject new writes with a visible error, or drop the oldest. Never fail silently.
- "Why not just retry in a coroutine instead of WorkManager?" — Because a coroutine dies with the process and the user's intent must survive a reboot. WorkManager is the only thing on Android that guarantees eventually.
Task · debugging, 45 minutes
"Users say articles they saved on the train are unsaved by the time they get to work."
Why this is the best task on the list
It has a specific, findable root cause, and the path to it is pure methodology. The bug is DELETE FROM articles on REFRESH destroying rows whose local intent had not yet been sent. Everything about the report points at it — offline writes, then a reconnect — and yet the common failure is to start reading the sync worker, because that is where "saving" feels like it lives.
The path a strong candidate takes
- Reproduce before theorising. Airplane mode, save, re-enable, observe. Two minutes, and it converts a vague report into a deterministic case.
- Establish where the value dies. Query the table directly after each step. Is the outbox row gone, or the override? Different columns mean different bugs.
- Read the write path in reverse from the column that lost its value, not forward from the button.
- Write the failing test first — the one in Chapter 202 — then fix. A bug this cheap to reproduce and this expensive in trust must never regress.
- Ask what else shares the shape. Any other
REFRESHhandler in the app that clears a table has the same defect today.
The answer that gets down-levelled
"I'd add logging and ship it to see what happens." Sometimes correct for a genuine heisenbug; here it is an admission that a locally reproducible bug was not reproduced. The reflex an interviewer is checking for is reproduce, then bisect — everything else is commentary.
Task · design discussion, 20 minutes
"Walk me through what happens between the tap and the pixel when a user saves an article."
What this question is for
It cannot be answered from memorised vocabulary, which is exactly why it is asked. Either you have built this path or you have not, and the answer reveals which within thirty seconds.
The shape of a complete answer
Tap arrives at a lambda in the item body, which does no work beyond calling the ViewModel. The ViewModel launches in viewModelScope and calls the repository, which opens one Room transaction writing both the local override and the outbox row — durable before anything else happens. That commit invalidates the Room query; the PagingSource re-emits; COALESCE yields the new value with pending = 1; Compose recomposes just that item because the list has stable keys. The whole loop is local and takes a frame or two. Separately, the repository asks WorkManager for a unique sync; when connectivity allows, the worker sends the intent with the idempotency key generated at tap time, applies the server's response, clears the override, deletes the outbox row, and publishes on a SharedFlow that anyone interested can observe. The user sees the pending mark disappear. The network was never on the path between the tap and the pixel.
Follow-ups
- "Where could this lose data?" — Between the two writes if they were not in one transaction; on an unbounded outbox; on a 4xx that is dropped without telling the user.
- "Which part would you delete if the deadline halved?" — The pending affordance, and the
SharedFlow. Not the transaction, not the idempotency key: those are correctness, and the rest is polish. - "What if the product wants an undo?" — The outbox already is one, for as long as the row is unsent. Making undo reliable after sync is a different feature — a server-side reversal — and it should be scoped as one.
Chapter 204What is actually being graded
| Seam | The Senior answer | The Staff answer |
|---|---|---|
| Source of truth | Cache network responses in Room. | Invert it: the UI reads only the database, so no code path knows whether the device is online. |
| Pagination | Paging 3 with a RemoteMediator, following the sample. | Persist the cursor because it is opaque; keep the mediator's two writes in one transaction; refuse to delete unsynced rows on refresh. |
| Writes | Optimistic update, then call the API. | Durable before dispatch; idempotency key generated at commit; a bounded outbox drained by WorkManager. |
| Streams | Expose StateFlow from the ViewModel. | State, multicast signal and one-shot event are three different primitives, chosen by question rather than by habit. |
| Errors | Show an error screen when a call fails. | Classify transient, permanent and cancellation; degrade the UI in proportion to what is cached. |
| Conflicts | Not mentioned until asked. | A stated policy, with the reason device clocks disqualify last-write-wins and why set operations converge for free. |
| Testing | The ViewModel is unit tested. | Injected dispatcher and clock make the whole slice a JVM test; the first test written is the data-loss regression. |
| Scope | Builds the feature. | Names what was not built — tombstones, outbox bounds, undo after sync — before being asked. |
Part XXXII rapid recall
- The UI reads the database and nothing else; the network and the worker are writers.
- An opaque cursor must be persisted — it cannot be recomputed after process death.
RemoteMediatorfills the cache; the RoomPagingSourceis what the UI reads.- Store the cursor and the rows in one transaction, or a crash creates an unreachable gap.
REFRESHmust never delete rows carrying unsynced local intent.- Split server truth from local intent into two columns; merge them with
COALESCEin SQL. - Durable before dispatch: one transaction writes the override and the outbox row.
- Generate the idempotency key at user commit, persist it, reuse it on every retry.
- Coalesce the outbox by entity for toggles; append by operation id for distinct facts.
cachedIn(viewModelScope)in the ViewModel, never in the repository.StateFlowfor state,SharedFlowfor multicast signals,Channelfor one-shot events.- Classify errors three ways: retry, drop and inform, rethrow cancellation.
- Degrade, never blank — a refresh error with cached rows is a banner, not a takeover.
- Injected dispatcher and clock are what make the entire slice a JVM test.
Part XXXIII
Greenfield build tasks
Sixty build-from-nothing exercises, graded by difficulty and timed the way an interviewer times them. Each one names the signal it produces and the trap that catches strong candidates — because on a greenfield task the code is rarely what decides the round.
Chapter 205How greenfield tasks are actually graded
A greenfield task is the most misread format in an Android loop. Candidates treat it as "show me you can build things", which is the one thing your résumé already established. The interviewer is measuring something narrower: what you do with an under-specified problem and a clock.
The scoring, in the order it is applied:
- Scoping in the first five minutes. What did you cut, and did you say so out loud? A candidate who builds 40% of the problem deliberately outscores one who builds 70% by accident every time.
- The boundary you drew first. Whatever you wrote before the UI — the repository interface, the state model, the error type — is the strongest single signal in the round, because it is the decision that is expensive to reverse.
- Failure modes, unprompted. Empty, offline, slow, rotated, process-killed, permission-revoked. Naming them costs a sentence; discovering them under follow-up costs the level.
- Whether it runs. Genuinely fourth. A compiling skeleton with honest gaps beats a half-finished monolith that does not build.
- What you would do next. The closing sentence. Candidates who end with "that's it" lose points that candidates who end with "given another two hours I'd add X, and here's what worries me" collect for free.
Before writing anything: "Here's what I'm going to build, here's what I'm deliberately not building, and here's the assumption I'm making that I'd normally verify with product." Say it aloud, and every later shortcut reads as a decision rather than an omission. This single habit moves more candidates from L3 to L4 than any technical knowledge in this book.
The rubric applied to any task in this part
| Level | What the submission looks like |
|---|---|
| L2 | Feature works on the happy path. One layer — logic in the composable or the Activity. Errors are a toast. No tests. |
| L3 | Layered, injected, testable. Loading and error states modelled. A few unit tests on the state holder. Sensible library choices. |
| L4 | The scoping conversation happened first. Failure modes are designed for, not caught. State model makes illegal states unrepresentable. Tests target the risky logic, not the getters. The README names what was cut and why. |
| L5 | Treats the exercise as a template others will copy: a documented boundary, a migration story, a measurable success criterion, and an explicit statement of the conditions under which this design becomes wrong. |
Chapter 206Foundations — one concept, done properly
These look trivial and are not. Each has exactly one trap, and the trap is what is being measured.
Two text fields, a unit picker, live bidirectional conversion.
Tests State hoisting and whether you can keep two inputs consistent without an infinite update loop.
Trap Two-way binding where each field updates the other. Model a single source value plus which field is focused; derive the other.
Start, pause, reset, lap list. Must survive rotation and backgrounding.
Tests Whether time lives in the ViewModel and whether you store an instant or a duration.
Trap Counting ticks. Background the app and the count drifts. Store the start timestamp and derive elapsed on every frame.
Create, edit, delete notes. Room-backed. Survives process death.
Tests Room basics, reactive queries, and whether the list re-renders without manual refresh.
Trap Re-querying after each write instead of collecting a Flow. Also: deleting without an undo affordance.
Filter 5,000 in-memory items as the user types.
Tests debounce, distinctUntilChanged, flatMapLatest, and whether filtering happens off the main thread.
Trap Filtering inside the composable body, so every recomposition re-scans the list.
Email, password, confirm. Inline errors, submit disabled until valid.
Tests State modelling. Whether validation is a pure function you can unit test.
Trap Showing "invalid email" before the user has finished typing. Validate on blur or after first submit, not on every keystroke.
Light/dark/system and two locales, persisted, applied without a restart.
Tests DataStore, AppCompatDelegate / AppLocaleManager, and configuration handling.
Trap SharedPreferences read on the main thread at startup, and hardcoded strings that cannot switch locale.
Three pages, dots, skip, shown only on first launch.
Tests "First launch" as persisted state rather than an in-memory flag.
Trap A boolean in a singleton. Kill the process and onboarding returns forever.
Categories that expand to items, with animated open/close.
Tests Flattening a tree into a lazy list rather than nesting scrollables.
Trap LazyColumn inside LazyColumn — an unbounded-height crash. Flatten to one list with type markers.
Chapter 207Data, networking and pagination
Infinite scroll over a REST endpoint, with loading, error and retry.
Tests Paging 3, load-state handling, and whether a database appears at all.
Trap Paginating into an in-memory list, so rotation refetches from page one. See Part XXXII.
List screen and detail screen backed by the same store; detail opens instantly from cache then refreshes.
Tests Single source of truth, and stale-while-revalidate as a deliberate pattern.
Trap Passing the whole object through the navigation argument. Pass an id; read from the store.
OkHttp Authenticator that refreshes on 401 — once, no matter how many requests fail together.
Tests Concurrency under a thundering herd, and mutex discipline.
Trap Five parallel refreshes, four of which invalidate the token the fifth just obtained, logging the user out.
Map transport failures to a domain error type the UI can switch on exhaustively.
Tests Whether HttpException and IOException leak into the UI layer.
Trap Result<T> everywhere, including where failure is not a state the screen can render.
Query three endpoints in parallel, merge, show partial results as they land, never block on the slowest.
Tests coroutineScope vs supervisorScope, and partial-failure UX.
Trap awaitAll, so one failing source produces an empty screen instead of two-thirds of the answer.
A chat-style list that pages both up and down from an arbitrary anchor message.
Tests RemoteMediator PREPEND handling, remote keys, and scroll-anchor preservation.
Trap Prepending shifts content under the user's thumb. You need a stable anchor and reversed layout, not just a working query.
Two screens sharing entities; an edit on one updates the other with no refetch.
Tests Cache normalisation as a concept, not a library feature.
Trap Treating the response document as the cache key, so the same entity exists twice with divergent values.
Upload a 200 MB file across process death, network loss and app restart.
Tests Durable progress, chunk idempotency, WorkManager foreground service, and honest progress reporting.
Trap Progress held in memory. Kill the app at 90% and the upload starts over — the defect the exercise exists to expose.
Chapter 208Offline-first and synchronisation
A list that renders fully with the network disabled, and refreshes when it returns.
Tests Whether the UI reads the database or the network.
Trap An if (isOnline) branch. Correct designs have no such check anywhere.
Toggle favourites offline; converge on reconnect; survive reboot.
Tests Durable-before-dispatch, idempotency keys, error classification. Part XXXII in one sitting.
Trap Refresh deleting rows that still carry unsent local intent.
Same account on two devices, both edit offline, both reconnect. Define and implement the conflict policy.
Tests Whether you have a stated policy and know why device clocks disqualify last-write-wins.
Trap Comparing System.currentTimeMillis() across devices. Clocks are user-settable.
Sync token in, changed and deleted entities out, applied atomically.
Tests Deletion propagation — the half of sync that most designs forget.
Trap No tombstones, so remotely deleted rows live locally forever.
Two users editing one document offline. Design the convergence model and build a vertical slice.
Tests Whether you can articulate operational transform versus CRDT trade-offs and pick one with reasons.
Trap Reaching for a CRDT library without noting the memory cost of tombstone growth on a mobile device.
Generalise the outbox so eight feature teams use it without reading its source.
Tests API design for other engineers; what you refuse to make configurable.
Trap Every knob exposed. A platform module's value is the decisions it takes away.
Chapter 209Compose and design systems
Reproduce a supplied design, including dark mode and dynamic type.
Tests Whether you use theme tokens or literals.
Trap Color(0xFF1B4A8F) inline. Invisible until dark mode, then everything is unreadable.
A card that other teams compose into, with no boolean flags in its signature.
Tests Slot-based API design and the modifier parameter convention.
Trap showIcon: Boolean, showBadge: Boolean, isCompact: Boolean — the flag explosion a slot API exists to prevent.
List-detail that becomes two panes, and survives folding mid-flow.
Tests Window size classes, and state preservation across a layout change.
Trap A raw screenWidthDp > 600 check that misses runtime folding.
Headers, ads, cards and footers in one scroller, sticky headers, smooth at 120 Hz.
Tests key, contentType, and keeping derivations out of the item body.
Trap Date formatters and sums computed per item per frame.
A radial menu or flow layout via the Layout composable, plus a Canvas chart.
Tests Measure/place mechanics and the three phases.
Trap Measuring a child twice without SubcomposeLayout, which throws at runtime.
Velocity-aware swipe, spring-back, snackbar undo, correct on interruption.
Tests Gesture and animation coordination, and undo as a state machine rather than a delay.
Trap Deleting after a 3-second delay. Navigate away and the row is gone with no undo.
Ten components, each with light, dark, RTL and large-font baselines in CI.
Tests Whether you treat visual regression as infrastructure with a flake budget.
Trap Baselines that differ per machine. Pin the renderer and the font.
Token pipeline, component API rules, deprecation policy, adoption metric.
Tests Governance, not components. How a token change reaches 200 screens safely.
Trap Building components and calling it a system. The system is the rules and the migration path.
Chapter 210Concurrency, streams and real time
Stream updates into a map, stop cleanly when the screen stops, resume on return.
Tests callbackFlow, awaitClose, repeatOnLifecycle, conflation.
Trap The callback outliving the collector — a leak plus battery drain nobody notices for weeks.
Live messages, exponential backoff with jitter, gap-fill via REST after a reconnect, ordered delivery.
Tests Transport as a Flow, and the realisation that reconnect leaves a hole that must be backfilled.
Trap Reconnecting and resuming the stream without fetching what was missed while disconnected.
Bounded parallelism, priority queue, per-job cancellation, cooperative shutdown.
Tests Structured concurrency under pressure; Semaphore and Channel discipline.
Trap Cancelling a job cancels the scope and takes down its siblings.
Cart, live pricing, stock checks, promo codes — all derived, all consistent, no duplicated state.
Tests combine, derived state, and avoiding read-modify-write on a StateFlow.
Trap Storing the total. It drifts from the lines within a day. Derive it.
Ten requests per second across the whole app, cancellable, fair, no busy-waiting.
Tests Token bucket, suspension rather than blocking, releasing the lock before delaying.
Trap delay while holding the mutex, serialising every caller behind one waiter.
Analytics events from many producers, batched, persisted, retried, bounded memory under a burst.
Tests An explicit overflow policy — the question most candidates never ask.
Trap An unbounded buffer. It is an OOM with a delay fuse.
Chapter 211Media, camera and device capability
Pick, crop, downscale, upload with progress.
Tests Photo Picker over storage permission, and scaled decoding.
Trap Requesting READ_EXTERNAL_STORAGE when the Photo Picker needs no permission at all.
Record, live amplitude waveform, pause, playback, survive interruption by a call.
Tests Audio focus, foreground service, lifecycle of a hardware resource.
Trap Not releasing the recorder on interruption, so the mic stays held and the next start fails.
CameraX preview plus a barcode analyser at 30 fps without dropping frames.
Tests Backpressure strategy on the analyser and closing every ImageProxy.
Trap Forgetting imageProxy.close() — the pipeline stalls after exactly three frames.
Media3 playback, partial-download cache, background audio, notification controls.
Tests Media session, cache eviction policy, and playback across process death.
Trap An unbounded disk cache that quietly consumes the device.
Classify camera frames locally, keeping the main thread free and the battery intact.
Tests Threading around a native interpreter, and throttling inference to a useful rate.
Trap Inferring on every frame. Ten per second is plenty and a third of the power.
Chapter 212Platform, background and integration
Route external URLs to the right screen with a correct back stack, from cold start.
Tests A single testable resolver, and synthesised parent destinations.
Trap Treating the URL as trusted. Validate the host; parse ids into typed values.
FCM receipt, channels, inline reply, correct deep link, Android 13 permission flow.
Tests PendingIntent flags and asking for POST_NOTIFICATIONS in context.
Trap FLAG_MUTABLE with an implicit intent — an intent-redirection vulnerability.
Glance widget showing live data, refreshed on a budget, tappable into the app.
Tests Update cadence versus battery, and reading the same store as the app.
Trap A 15-minute periodic refresh nobody asked for.
Encrypt a token with a Keystore key that requires user authentication.
Tests CryptoObject, key invalidation on biometric enrolment, and a real fallback.
Trap Biometrics flipping a boolean. Patch the branch and the gate is gone.
A watch surface sharing data with the phone, functioning while the phone is away.
Tests Data layer sync and designing for an intermittent peer.
Trap Assuming the phone is reachable. It frequently is not.
A three-step flow fully operable by TalkBack and switch access, at 200% font.
Tests Semantics, focus order, live regions, and touch targets.
Trap Adding contentDescription everywhere, including decorative images, so TalkBack becomes unusable noise.
Play Billing, entitlement as server truth, restore, refunds, offline grace.
Tests Treating the client as untrusted, and designing for the refund case.
Trap Granting entitlement on the client's purchase callback. Trivially spoofable.
Chapter 213Architecture and platform engineering
Split a 60-screen monolith into a layered graph with enforced boundaries.
Tests Dependency direction and mechanical enforcement, not just folders.
Trap A :common module everything depends on. You renamed the monolith.
Remote flags, local overrides, typed access, consistent within a session, testable.
Tests Flag lifecycle — the removal plan is the interesting half.
Trap Flags that re-evaluate mid-session, so the UI changes under the user.
Compile-time-checked routes, deep-link parity, no feature module importing another.
Tests Decoupling navigation from feature modules.
Trap String routes assembled by hand — a runtime crash per typo.
A typed event schema, sampling, PII policy, offline buffering, and mobile SLIs that alert.
Tests Whether you can name what you would alert on and what you would deliberately not measure.
Trap Logging everything. Cost and PII exposure both scale with volume.
Share one real domain across Android and iOS; report honestly on the cost.
Tests Organisational reasoning — build times, hiring, debugging across the boundary.
Trap Selling the code-sharing percentage. Interviewers want the tax, not the brochure.
Profile the graph, cache, parallelise, shard tests, define what runs per-PR versus nightly.
Tests Measuring before optimising, and trading confidence against latency explicitly.
Trap Deleting tests to hit the number.
Day-one architecture: modules, DI, navigation, state, testing, release, and the written rules.
Tests Which decisions are one-way doors, and what you defer on purpose.
Trap Deciding everything up front. The Staff answer names what it is deliberately leaving open.
Chapter 214Take-home projects
Full-weekend scope. The README is graded as heavily as the code — often more, because it is the only place your reasoning is visible.
Search, subscribe, download for offline, background playback, resume position across devices.
Tests Nearly everything: paging, downloads, media session, sync, notifications.
Trap Attempting all of it. Ship three features properly and document the rest as scoped-out.
Camera capture, offline entry, categories, monthly aggregation, CSV export.
Tests Money handling, aggregation queries, and file sharing via FileProvider.
Trap Currency in Double. Use minor units as Long, and say why in the README.
Foreground-service GPS session, live stats, route map, history, battery-conscious.
Tests Long-running background work and location accuracy versus power.
Trap Maximum-accuracy GPS at 1 Hz for an hour. Adaptive intervals, and measure the drain.
Browse, filter, cart, checkout, orders — with optimistic cart edits and typed errors.
Tests A realistic multi-screen state model and idempotent checkout.
Trap A retried order placement without an idempotency key — a double charge.
Offline send queue, delivery states, bidirectional paging, presence, reconnection gap-fill.
Tests The hardest common mobile problem. Ordering, idempotency and convergence in one.
Trap Ordering by client timestamp. Two devices, two clocks, a scrambled conversation.
Chapter 215Scoping any greenfield task in five minutes
The same six questions work on every task above. Ask them aloud; the answers are the design.
| Question | What the answer decides |
|---|---|
| Read-only, or does the user write? | Writes bring durability, optimism, idempotency and conflicts. This one answer can triple the scope. |
| Must it work offline? | Yes means the database is the source of truth and the whole architecture inverts. |
| How much data, and how does it grow? | Decides pagination, indexing and whether the list can be held in memory at all. |
| Who else changes this data? | Another device, a worker or a push handler forces reactive reads rather than one-shot loads. |
| What must never be lost? | Anything the user believes is saved must reach disk before the network is attempted. |
| What is the deadline? | Determines what you cut — and cutting explicitly is the behaviour being scored. |
Part XXXIII rapid recall
- Say what you are not building before you build anything.
- The boundary you write first is the strongest signal in the round.
- Name failure modes unprompted: empty, offline, slow, rotated, killed, revoked.
- A deliberate 40% beats an accidental 70%.
- Compiling with honest gaps beats broken with ambition.
- Writes plus offline is the combination that triples scope — price it out loud.
- Money in minor units as
Long; ordering by server key, never a device clock. - Unbounded anything — buffer, cache, outbox, retry — is a bug with a delay fuse.
- On a take-home the README carries the reasoning; it is graded as heavily as the code.
- Close with what you would do next and what worries you.
Part XXXIV
Rapid recall
Every term you must be able to define in one breath, with the shortest example that makes it true. Built for the morning of the interview and for the five minutes before a call — definitions first, mechanism second, nothing you cannot say out loud in fifteen seconds.
Chapter 216How to drill this part
Reading definitions is close to worthless; retrieving them is where the learning is. The protocol that works:
- Cover the right column. Read the term, say your definition aloud, then uncover. The friction is the point.
- Grade yourself in two buckets, not three. Either you produced a mechanism, or you produced vocabulary. "It's lifecycle-aware" is vocabulary.
- Chase every miss one level deeper. If you could not define
SupervisorJob, you also cannot answer "what happens when a child fails?" — which is the question that will actually be asked. - Re-drill misses only. Three passes over what you missed beats one pass over everything.
These are the opening answers — the thirty seconds that buy you the right to keep talking. A Staff loop is decided by the follow-ups: mechanism, failure mode, alternative, and the condition that flips the choice. Use this part to make the recall automatic so your attention is free for the part that scores.
Chapter 217Coroutines — the core vocabulary
An instance of a suspendable computation. Not a thread: it is an object the runtime can pause and resume, so tens of thousands can share a handful of threads. Cheap to create — the cost is an allocation, not a stack.
suspend functionA function that can pause without blocking its thread, and can only be called from another suspending function or a coroutine builder. The compiler rewrites it into a state machine that takes a hidden Continuation parameter.
suspend fun user(id: String): User = api.fetch(id) // no thread held while waiting
"The rest of the function", captured as an object. Suspension means storing the continuation and returning the thread; resumption means calling it back with a result.
CoroutineContextAn indexed set of elements carried by every coroutine — Job, dispatcher, name, exception handler. Combined with +; children inherit it and may override individual elements.
Dispatchers.IO + SupervisorJob() + CoroutineName("sync")CoroutineScopeAn object holding a context whose Job defines a lifetime. Everything launched in it becomes a child of that job, so cancelling the scope cancels all of it. A scope is a lifetime, not a thread pool.
JobThe handle and lifecycle of a coroutine: New → Active → Completing → Completed, or Cancelling → Cancelled. Jobs form a parent–child tree, which is what makes cancellation and error propagation automatic.
Every coroutine has a parent; a scope does not complete until all its children complete; and cancellation and failures propagate along that tree. The consequence is the point: no coroutine can outlive the scope that started it, so work cannot leak.
The default — a plain Job, as in coroutineScope { }. One child failing cancels the parent, which cancels every sibling. Correct when the children are parts of one indivisible result.
coroutineScope {
val a = async { first() }
val b = async { second() } // if first() throws, this is cancelled
a.await() + b.await()
}SupervisorJob or supervisorScope { }. A child's failure stops at the supervisor: siblings and the parent survive. Cancellation still flows downward normally — supervision changes upward failure propagation only.
supervisorScope {
launch { risky() } // may fail
launch { other() } // still runs
}Cooperative. Cancelling moves the job to Cancelling and makes every suspension point throw CancellationException. Code that neither suspends nor checks isActive keeps running to completion regardless.
while (isActive) { crunch() } // cooperative
while (true) { crunch() } // uncancellableCancellationExceptionThe normal, expected signal of cancellation — the parent ignores it rather than treating it as a failure. Catching it without rethrowing silently breaks structured concurrency, which is why catch (e: Exception) and runCatching are hazards in coroutine code.
ensureActive() / yield()Cooperation points for CPU-bound loops that never suspend. ensureActive() throws if cancelled; yield() also gives other coroutines a turn on the dispatcher.
NonCancellableThe context that lets cleanup suspend after cancellation. Without it, a suspending call inside finally throws immediately and the cleanup never happens.
try { work() } finally {
withContext(NonCancellable) { db.close() }
}Decides which thread a coroutine resumes on. Main — UI. Default — CPU work, pool sized to cores. IO — blocking calls, up to 64 threads by default. Unconfined — resumes wherever it was resumed; for tests and internals, not app code.
withContextRuns a block in a different context and returns its result, suspending the caller. The tool for main-safety, and it belongs to the function that knows it blocks — never to its call sites.
suspend fun read() = withContext(io) { file.readText() }launch vs asynclaunch returns a Job for fire-and-forget work; an exception propagates to the parent immediately. async returns a Deferred<T> for a value; the exception is held until await(). An async whose result is never awaited swallows its failure.
coroutineScope vs supervisorScopeBoth are suspending builders that wait for all children. They differ in exactly one way: whether one child's failure cancels the others. Neither creates a new thread.
CoroutineExceptionHandlerA last-resort handler for uncaught exceptions from launch at the root of a scope. It does nothing for async, whose exception belongs to the Deferred, and nothing for a non-root coroutine, which delegates to its parent.
runBlockingBridges blocking and suspending worlds by blocking the current thread until the block completes. Legitimate in main and in tests; in app code it is how you write an ANR.
Mutex / SemaphoreSuspending mutual exclusion and bounded permits. They suspend rather than block, and are not reentrant — taking the same mutex twice in one call chain deadlocks.
mutex.withLock { state = state + 1 }viewModelScope / lifecycleScopePrebuilt scopes cancelled at onCleared and onDestroy respectively. In a Fragment, lifecycleScope outlives the view — anything touching the view belongs to viewLifecycleOwner.lifecycleScope.
Chapter 218Flow and reactive streams
FlowA cold asynchronous stream. Nothing executes until a terminal operator collects it, and each collector gets its own independent execution of the whole chain.
flow { emit(1); emit(2) }.collect { println(it) }Cold produces per collector and starts on collection (Flow). Hot exists independently of collectors and is shared (SharedFlow, StateFlow, Channel). Five collectors on a cold network flow means five requests.
StateFlowA hot flow with exactly one current value, conflated and distinct-until-changed. The state holder's primitive: it always has a value to render, and it never re-emits an equal one.
private val _s = MutableStateFlow(UiState()) val s: StateFlow<UiState> = _s.asStateFlow()
SharedFlowA hot multicast flow with configurable replay and buffer, and no notion of a current value. For signals several unrelated observers care about.
ChannelA hot queue where each element is delivered to exactly one consumer. The right primitive for one-shot events — navigation, snackbars — because nothing is replayed and nothing is dropped while the screen is stopped.
_.update { }Atomic compare-and-set on a MutableStateFlow. Required because value = value.copy(...) is a read-modify-write that loses updates under concurrency.
_state.update { it.copy(items = it.items + x) }stateIn / shareInConvert a cold flow into a hot one shared by all collectors, within a scope. stateIn also keeps a current value.
.stateIn(viewModelScope, WhileSubscribed(5_000), Loading)
SharingStartedThe sharing policy. Eagerly — starts now, never stops. Lazily — starts on first collector, never stops. WhileSubscribed(5_000) — the Android default, because five seconds outlives a rotation and stops when the screen is genuinely gone.
flowOnChanges the dispatcher of everything upstream of it, and nothing downstream. Context preservation means a flow can never change the collector's context from inside emit.
map vs transformmap emits exactly one value per input; transform may emit zero or many. filter, take, and friends are all transform underneath.
flatMapLatestCancels the previous inner flow when a new value arrives. The correct operator for search-as-you-type, where an in-flight query for a stale term is waste.
query.debounce(300).flatMapLatest { repo.search(it) }flatMapMerge / flatMapConcatMerge runs inner flows concurrently and interleaves results; Concat runs them strictly in sequence. Merge is faster and unordered; concat preserves order and serialises.
combine vs zipcombine emits on any source emitting, using each other's latest — for derived state. zip pairs elements strictly by index and waits for both — for correlated streams.
buffer / conflate / collectLatestThree answers to a slow collector: buffer keeps everything and runs producer and consumer concurrently; conflate drops intermediate values; collectLatest cancels the in-progress collector body when a newer value arrives.
What happens when a producer outpaces its consumer. Flow handles it by suspending the producer — which is why an unbounded buffer, added to "fix" slowness, converts a stall into an out-of-memory error.
callbackFlow / awaitCloseWraps a callback API as a flow. awaitClose is mandatory — it keeps the flow alive and is where the listener is unregistered when the collector goes away.
callbackFlow {
val l = Listener { trySend(it) }
api.register(l)
awaitClose { api.unregister(l) }
}catch and retrycatch handles exceptions from upstream only — it cannot catch a failure in the collector's own lambda. retryWhen resubscribes; use it with a predicate and jitter, never unconditionally.
The test library for flows: awaitItem(), awaitComplete(), and an assertion that nothing more was emitted. Its real value is failing loudly on unconsumed emissions.
Chapter 219Kotlin language
data classGenerates equals, hashCode, toString, copy and componentN from the primary constructor properties only. Adding a property is a binary-incompatible change to copy.
sealed class / sealed interfaceA closed hierarchy known at compile time, so when can be exhaustive without an else. Prefer sealed interface: it permits multiple implementation and avoids the constructor.
object / companion objectA singleton declaration, and the per-class singleton that holds what other languages call statics. A companion object is a real object, so its members are not free — const val and top-level functions are.
value classA single-property wrapper erased at runtime, giving type safety with no allocation. Boxes when used as a generic argument, as a nullable, or through an interface.
@JvmInline value class UserId(val raw: String)
inline / reifiedinline copies a function's body to the call site, removing the lambda allocation — worth it for small higher-order functions, harmful for large ones. reified is only possible because of inlining: it keeps the type argument at runtime.
inline fun <reified T> Bundle.get(k: String): T? = get(k) as? T
crossinline / noinlinecrossinline forbids a non-local return from an inlined lambda; noinline exempts a parameter from inlining so it can be stored or passed on.
out / inout T — covariant, a producer, T appears only in return position. in T — contravariant, a consumer, T only in parameters. The mnemonic is producer-extends, consumer-super.
<*>"Some specific type, unknown here." You may read values as the upper bound and may not write any.
byForwards interface implementation to another object, or a property's get/set to a delegate. Composition without boilerplate.
class Repo(db: Dao) : Dao by db
val heavy by lazy { build() }lazy vs lateinitlazy — val, computed once on first access, thread-safe by default. lateinit — var, non-null, assigned later, throws if read first. Neither works for primitives via lateinit.
let — transform, receiver as it. run — transform, receiver as this. apply — configure, returns receiver. also — side effect, returns receiver. with — non-extension run.
Syntactic sugar for a static function taking the receiver as its first parameter. Resolved statically, so it never participates in polymorphism and never overrides a member.
Sequence vs ListList operators are eager and allocate an intermediate collection per step. Sequence is lazy and element-by-element. Sequences win on long chains over large data and lose on short chains over small data.
Encoded in the type system: T and T? are different types. ?: supplies a fallback, ?. short-circuits, and !! is a deliberate crash you should be able to justify.
T!A type from Java whose nullability is unknown, so the compiler cannot check it. This is why annotating Java interop boundaries matters — an unannotated getter is a silent NPE.
typealiasA name for an existing type. No new type, no safety — for readability only. Use a value class when you actually want distinctness.
internalVisible within the compilation module. The mechanism that makes a Gradle module's public surface intentional rather than accidental.
@JvmStatic, @JvmOverloads, @JvmNameInterop controls: expose a companion member as a real static, generate overloads for default parameters, and rename for Java callers.
The compiler narrows a type after a check, but only when it can prove the value cannot change — so a mutable property from another module never smart-casts. A local val copy is the fix.
Chapter 220Jetpack Compose
A function that describes UI by emitting into a composition rather than returning a view. The compiler plugin gives it a Composer parameter and positional memory.
Re-running composables whose inputs changed. It is optimistic, may be cancelled and restarted, can run out of order, and may execute in parallel — so a composable must be side-effect free and idempotent.
The gap buffer holding the composition's structure and every remembered value, keyed by call-site position. It is why remember works and why an unkeyed list reuses the wrong state.
MutableState read inside a composable registers that composable as a reader; writing it invalidates exactly those readers. Automatic, minimal-scope observation.
remember vs rememberSaveableremember survives recomposition and dies with the composition. rememberSaveable also survives configuration change and process death via saved instance state — and therefore only holds what a Bundle can carry.
Move state to the lowest common ancestor of its readers and pass value plus callback down. Produces stateless, previewable, testable composables.
@Composable fun Counter(n: Int, onInc: () -> Unit)
Composition — what to show. Layout — measure and place. Drawing — render. Reading state later rather than earlier skips phases: Modifier.offset { } costs layout only, while a recomposing offset costs all three.
derivedStateOfFor state derived from other state where the derived value changes far less often than its inputs — the canonical case being scrollPosition > 0, which changes twice across hundreds of scroll emissions.
LaunchedEffectRuns a coroutine tied to the composition, restarted when its keys change and cancelled on leaving. key = Unit means once per entry into composition.
DisposableEffectFor non-suspending resources needing symmetric cleanup — registering and unregistering a listener. The onDispose block is mandatory.
SideEffectRuns after every successful composition. For publishing state to non-Compose code that must not see a composition that was discarded.
rememberUpdatedStateKeeps a long-lived effect referencing the latest lambda without restarting it — the fix for a timer whose callback is stale but which must not be cancelled.
produceStateConverts non-Compose async sources into State, with a coroutine scoped to the composition.
A type is stable if equals is consistent, public properties do not change without notifying, and all its properties are stable. Unstable parameters make a composable non-skippable, so it recomposes even when nothing changed.
Restartable — can be recomposed on its own. Skippable — can be skipped when all parameters compare equal. Strong skipping mode relaxes this by comparing unstable parameters by instance.
CompositionLocalImplicit data passed down the tree. Right for genuinely ambient values — theme, density; wrong for domain data, because it makes a composable's dependencies invisible at the call site.
Modifiers apply left to right, so padding().background() and background().padding() render differently. Always accept a modifier parameter, defaulted, applied to the outermost element.
key in lazy listsTies remembered state to identity rather than index. Without it, inserting at the head shifts every item's state and defeats reuse.
SubcomposeLayoutComposes children during layout, so a child's content can depend on measurements. Powerful and expensive — it is why BoxWithConstraints should not be a default.
The parallel tree describing meaning rather than pixels. It serves accessibility services and UI tests from one source, which is why an untestable screen is usually an inaccessible one.
Chapter 221Platform, lifecycle and process
The system reclaims a backgrounded app's process. The task record survives, so the user returns to what looks like their screen with every in-memory value gone. Reproduce with adb shell am kill — rotation does not test this.
Rotation, locale, dark mode, window resize. Recreates the Activity but not the process, which is why a ViewModel survives it and a saved-state bundle survives more.
SavedStateHandleThe ViewModel's bundle-backed store: survives process death, is size-limited, and is the correct home for navigation arguments and small user input — not for cached lists.
viewLifecycleOwnerA Fragment has two lifetimes — the fragment and its view — separated by a back-stack traversal. Anything touching the view must use the view's owner or it leaks into a destroyed hierarchy.
repeatOnLifecycleRuns a block when the lifecycle reaches a state and cancels it when it drops below — restarting on return. Unlike flowWithLifecycle on a hot flow, it stops the upstream too.
Android's IPC mechanism, backed by a fixed ~1 MB per-process transaction buffer shared by all in-flight calls. Exceeding it throws TransactionTooLargeException — the usual cause of a crash when saving a large bundle.
The main thread is a loop draining a message queue. Every frame, touch and lifecycle callback is a message — which is why a blocking call on it stalls all of them at once.
Schedules input, animation and draw against the display's vsync. Missing the frame budget — 16.6 ms at 60 Hz, 8.3 ms at 120 Hz — is jank by definition.
Input unhandled for 5 s, a broadcast for 10–20 s, or a service start for 20 s. Usually main-thread I/O, a lock held by a blocked thread, or a synchronous Binder call to a busy process.
standard, singleTop, singleTask, singleInstance — plus intent flags that can override them. They decide task and back-stack behaviour, and are the usual cause of "back goes somewhere strange".
PendingIntentA token letting another process execute an intent with your identity. Must be FLAG_IMMUTABLE unless mutation is genuinely required; a mutable one wrapping an implicit intent is an intent-redirection vulnerability.
Battery restrictions that batch or defer background work and network access when the device or app is idle. The reason "it works on my device" and "it works for a user overnight" are different claims.
For work the user is actively aware of, with a mandatory notification and a declared type. Since Android 12 it cannot generally be started from the background — WorkManager's expedited work is the supported path.
Revocable at any time, including while your app is backgrounded. Every permission-gated call therefore needs a not-granted-right-now branch, not just a request flow.
Apps get their own directory and access others' media through MediaStore or the picker. The Photo Picker requires no permission at all, which makes a storage-permission request a design smell.
Chapter 222Jetpack, architecture and data
ViewModelA state holder scoped to a ViewModelStoreOwner, surviving configuration change but not process death. It must never hold a reference to a View, Activity or Context that is not the application.
State flows down, events flow up. One writer per piece of state, which is what makes a bug traceable to a single place.
Both are UDF. MVI adds an explicit intent type and a single reducer, buying traceability and replay at the cost of ceremony. The honest answer is that most "MVVM" with one immutable state object already is MVI.
Owns where data comes from and which source is authoritative. It should not know about screens, and it should return domain models rather than DTOs or Room entities.
One place the UI reads; everything else writes into it. The property that makes offline behaviour fall out of the architecture rather than being coded as a special case.
A compile-time-verified SQLite layer. Observable queries invalidate per table, not per row — so an unbounded query over a hot table re-runs on every write.
Every shipped schema version is permanent public API because users skip releases. Export the schema, commit it, test adjacent pairs; fallbackToDestructiveMigration is a scheduled data-loss incident.
DataStoreThe asynchronous, transactional replacement for SharedPreferences, exposing a Flow. Preferences DataStore is untyped; Proto DataStore is schema-backed.
The only Android mechanism guaranteeing deferrable work runs eventually, across process death and reboot. For "must happen eventually", never "must happen now and visibly".
enqueueUnique* with KEEP, REPLACE or APPEND makes scheduling idempotent. Without it, every app launch enqueues another copy and they multiply.
PagingSource supplies pages; RemoteMediator writes network pages into the database when the cache runs low; cachedIn keeps the stream alive across configuration change.
Dagger with Android's component hierarchy predefined. Its real payoff is that clock, dispatcher, network and database arrive through constructors, so the slice runs as a JVM test.
@Binds vs @Provides@Binds for "this implementation satisfies that interface" — abstract, no generated factory. @Provides when construction requires code.
A client-generated id, created at user commit and persisted before the request, that lets a server deduplicate retries. Without it, a retry after a post-send timeout charges twice.
Persist the user's intent in the same transaction as the optimistic UI change, then let a worker drain it. The mechanism behind offline writes surviving process death.
Show the result before the server confirms. Correct only when you can both persist the intent and reverse it coherently on rejection.
Chapter 223Testing, build and performance
A fake is a working lightweight implementation; a stub returns canned values; a mock asserts on interactions. Prefer fakes: mocks encode implementation detail and break on every refactor.
runTestRuns a coroutine test with virtual time, so delay(10_000) completes instantly and deterministically.
StandardTestDispatcher vs UnconfinedStandard queues coroutines until you advance time, exposing ordering bugs. Unconfined runs eagerly, which is convenient and hides them.
Many JVM unit tests, fewer Robolectric or component tests, few instrumented end-to-end tests. Driven by cost and flake rate, not ideology.
The declared rate above which a test is quarantined rather than retried. A suite without one degrades into a suite nobody trusts.
A shipped list of hot methods AOT-compiled at install, typically cutting cold start and first-scroll jank by double-digit percentages. The cheapest large startup win available.
Shrinking, optimisation and obfuscation in one step. Reflection and serialisation need keep rules, and a missing one fails only in release.
Gradle caches the configured task graph, so subsequent builds skip configuration entirely. Requires tasks not to reference Project at execution time.
Cold — new process. Warm — process alive, Activity recreated. Hot — Activity resumed. Measure with Time to Initial Display and Time to Full Display, and report percentiles rather than means.
A frame that misses its deadline. Diagnose with a system trace rather than recomposition counts: the usual causes are main-thread work, unbounded queries and unscaled bitmap decodes.
Almost all Android leaks are one shape: a long-lived object holding a short-lived one — a static holding a Context, a listener never unregistered, or an inner class capturing its outer.
Development-time detection of main-thread disk and network access and of leaked closables. Enable in debug builds and fix what it finds rather than suppressing it.
Chapter 224The sixty-second self-test
If you cannot answer these out loud without hesitating, that topic is your next study block. Each maps to a definition above.
| Prompt | The one-line answer |
|---|---|
| What makes a coroutine cheaper than a thread? | It is an object, not a stack — suspension stores a continuation and releases the thread. |
| What does structured concurrency guarantee? | No coroutine outlives its scope; failure and cancellation propagate along the parent–child tree. |
| Supervised versus unsupervised, in one sentence? | Supervision stops a child's failure from reaching siblings; downward cancellation is unchanged. |
| Why can a coroutine ignore cancellation? | Cancellation is cooperative — code that never suspends and never checks isActive keeps running. |
Why is catching Exception in a coroutine dangerous? | It swallows CancellationException and breaks structured concurrency. |
| When does a cold flow become expensive? | With several collectors — each gets its own execution, so five collectors means five network calls. |
Why WhileSubscribed(5_000)? | Longer than a configuration change, shorter than the user's attention. |
| State, or event? | True after rotation → state. Must happen exactly once → event on a channel. |
Why does _state.value = _state.value.copy() lose updates? | It is a read-modify-write; update {} retries on conflict. |
| What survives process death? | Disk, and SavedStateHandle. Not a ViewModel, not a singleton's fields. |
| Which lifecycle owner for a Fragment's view? | viewLifecycleOwner — the fragment outlives its view by a back-stack traversal. |
Why does an unkeyed LazyColumn misbehave? | Remembered state is bound to index, so a head insert shifts every item's state. |
| What makes a composable skippable? | All parameters stable and comparing equal. |
When is derivedStateOf the right tool? | When the derived value changes far less often than the state it is derived from. |
| Why persist an opaque pagination cursor? | It cannot be recomputed from the data, so paging cannot resume after process death without it. |
| Where must an idempotency key be generated? | At user commit, persisted before the request, reused on every retry. |
| Which errors are safe to retry? | Connection failures freely; post-send timeouts only behind an idempotency key; never a 4xx. |
| Why not last-write-wins on device time? | Device clocks are user-settable, so it is data loss wearing a strategy's name. |
| What does Room invalidate on a write? | Every observing query on that table — hence bounded, indexed queries. |
Why FLAG_IMMUTABLE? | A mutable PendingIntent wrapping an implicit intent lets another app act as you. |
| What does a foreground service require since Android 12? | A declared type, a notification, and a foreground start — background starts are blocked. |
| Cheapest large startup win? | A Baseline Profile. |
| Why prefer fakes over mocks? | Mocks assert on interactions, so they break on refactors that change nothing observable. |
What does flowOn affect? | Everything upstream of it, and nothing downstream. |
Part XXXIV rapid recall
- A coroutine is an object, not a thread; a scope is a lifetime, not a pool.
- Structured concurrency: no work outlives its scope, and errors travel the job tree.
- Supervision changes upward failure propagation only — cancellation still flows down.
- Cancellation is cooperative; never swallow
CancellationException. - Cold flows run per collector; hot flows exist without one.
StateFlowstate,SharedFlowmulticast signal,Channelone-shot event.flowOnis upstream-only;withContextbelongs to the function that blocks.- Recomposition is optimistic, reorderable and cancellable — so composables must be pure.
remembersurvives recomposition;rememberSaveablesurvives process death.- Only disk and
SavedStateHandlesurvive a process kill. - Room invalidates per table; keep observed queries bounded and indexed.
- Persist the cursor, generate the idempotency key at commit, classify errors three ways.
Part XXXV
Best practices by area
Fifty-odd side-by-side pairs organised by the thing you are actually working on — Kotlin, coroutines, Flow, Compose, Fragments, ViewModels, DI, Room, networking, background work, testing and build. Part XXXI catalogued platform defects; this part is the working reference for the code you write today.
Almost every pair below reduces to one of four questions. What lifetime does this belong to? What happens when it fails? Who else can change this at the same time? How would I test it without a device? If you can ask those four about any snippet in review, you will independently rediscover most of this part — which is the point, because the API surface changes every year and those questions do not.
Chapter 225Kotlin — types and API design
fun transfer(from: String, to: String, amount: Double) transfer(to, from, 10.0) // compiles. wrong direction, wrong money type.
Every argument is a String, so the type system cannot catch a swap — and this shape of bug reaches production because it is invisible in review. Double for money compounds it: 0.1 + 0.2 is not 0.3, and rounding errors accumulate into reconciliation tickets.
@JvmInline value class AccountId(val raw: String) @JvmInline value class Minor(val cents: Long) // never Double for money fun transfer(from: AccountId, to: AccountId, amount: Minor)
value class costs nothing at runtime — it erases to the underlying type — and converts a class of runtime bug into a compile error. The argument to make in review: this is not ceremony, it is the cheapest possible test.
data class UiState(
val isLoading: Boolean = false,
val data: List<Item>? = null,
val error: String? = null,
)
// loading AND error AND data — what does the screen render?
Three independent fields encode eight states, of which perhaps three are meaningful. Every consumer then invents its own precedence rules, and two screens reading the same state render differently.
sealed interface UiState {
data object Loading : UiState
data class Ready(val items: List<Item>, val refreshing: Boolean) : UiState
data class Failed(val reason: ErrorReason) : UiState
}
when (state) { // exhaustive; the compiler enforces completeness
Loading -> Skeleton()
is Ready -> List(state.items)
is Failed -> Error(state.reason)
}
Note refreshing lives inside Ready — a refresh with content on screen is a different state from a cold load, and modelling it there is what lets the UI degrade instead of blanking.
sealed class Result<T> {
class Success<T>(val data: T) : Result<T>()
class Error<T>(val e: Throwable) : Result<T>()
}
// Error<User> and Error<Order> are different types for no reason
The type parameter is meaningless on the failure branch, so every mapping function needs a pointless cast, and variance is wrong: Result<Dog> is not a Result<Animal>.
sealed interface Outcome<out T> {
data class Ok<T>(val value: T) : Outcome<T>
data class Err(val reason: ErrorReason) : Outcome<Nothing>
}
out T makes Outcome<Dog> usable as Outcome<Animal>, and Nothing lets one Err instance satisfy every call site. This is the standard shape — recognising it is a Kotlin-depth signal.
data class in a published APIdata class Config(val host: String, val port: Int) // v2 adds a field: every copy() call site in every module breaks
copy and componentN are generated from the constructor, so adding a parameter — even with a default — is a binary-incompatible change. For a library or a shared module this forces a lockstep upgrade across the whole org.
class Config private constructor(val host: String, val port: Int) {
class Builder {
var host: String = "localhost"; var port: Int = 443
fun build() = Config(host, port)
}
}
Use data class freely inside a module; think twice at a boundary other teams compile against. The distinction is binary compatibility, not style.
Chapter 226Kotlin — collections and performance
val names = users // 100k users
.filter { it.active } // allocates a 60k list
.map { it.name } // allocates a 60k list
.take(20) // …to use twenty
Each operator materialises a full intermediate list, so this allocates roughly 120,000 objects to produce twenty. On a scroll frame that is a guaranteed jank spike and a GC pause.
val names = users.asSequence()
.filter { it.active }
.map { it.name }
.take(20)
.toList() // one allocation, 20 elements examined
Sequences process element-by-element and short-circuit. The honest caveat, worth volunteering: for small collections or a single operator, sequences are slower — the iterator overhead dominates. Rule of thumb: two or more operators over hundreds of items.
orders.forEach { order ->
val user = users.first { it.id == order.userId } // O(n) per order
render(order, user)
}
O(n×m). With 1,000 orders and 5,000 users that is five million comparisons on the main thread. It is invisible in a test fixture of ten rows and catastrophic in production — the classic "slow only for our biggest customers" bug.
val byId = users.associateBy { it.id } // O(n) once
orders.forEach { order ->
val user = byId[order.userId] ?: return@forEach
render(order, user)
}
Also note the explicit miss branch. first { } throws on no match; a lookup that returns null forces you to decide what a dangling reference means instead of crashing on it.
class Cart {
val items = mutableListOf<Item>() // anyone can mutate
}
cart.items.clear() // from anywhere, silently
Exposing a mutable collection means the class cannot maintain any invariant, and no reader can trust a value it holds. It also makes concurrent modification a live possibility with no lock to point at.
class Cart {
private val _items = mutableListOf<Item>()
val items: List<Item> get() = _items.toList() // or persistent list
fun add(item: Item) { _items += item; recalculate() }
}
The backing-field-plus-immutable-view convention is the same one used for StateFlow, and for the same reason: exactly one writer. Prefer a genuinely immutable snapshot over a cast, since List is only read-only by convention.
Chapter 227Kotlin — nullability and errors
suspend fun findUser(id: String): User? // not found? offline? bad id?
val user = findUser(id)
if (user == null) showError("Something went wrong")
One null collapses three distinct outcomes that need three distinct UIs: an empty state, a retry affordance, and a bug report. The user gets "something went wrong" for a search that simply had no results.
sealed interface UserLookup {
data class Found(val user: User) : UserLookup
data object NotFound : UserLookup
data class Unavailable(val cause: ErrorReason) : UserLookup
}
null is fine when there is exactly one reason for absence and it needs no explanation. The moment the caller would ask "why?", it needs a type.
!! and blanket catchval id = intent.getStringExtra("id")!! // crash, no context
try { risky() } catch (e: Exception) { Log.e(TAG, "failed") }
The !! crashes with no indication of which extra was missing or who launched the Activity. The blanket catch is worse: it swallows CancellationException, hides programming errors alongside expected failures, and leaves the app in an undefined state while claiming to have handled something.
val id = checkNotNull(intent.getStringExtra("id")) {
"DetailActivity launched without EXTRA_ID from ${callingActivity}"
}
try { risky() }
catch (e: CancellationException) { throw e }
catch (e: IOException) { showRetry() } // an outcome you can act on
checkNotNull with a message turns a crash report into a diagnosis. And a catch clause should name the exception you have a plan for — anything broader is a decision to continue in a state you have not reasoned about.
Chapter 228Coroutines — scopes and lifetime
class SyncManager {
fun start() {
GlobalScope.launch { while (true) { sync(); delay(60_000) } }
}
}
GlobalScope has no lifetime, so nothing can cancel this, nothing knows it failed, and every call to start() adds another copy. It also opts out of structured concurrency entirely — the one guarantee coroutines exist to provide.
class SyncManager @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
) {
private var job: Job? = null
fun start() {
job?.cancel() // idempotent
job = scope.launch { … }
}
}
An application scope is legitimate for work that must outlive any screen — but it must be injected so a test can replace it, and each starter must be idempotent. "Which scope?" is really "what should cancel this?", and that question always has an answer.
coroutineScope {
sources.forEach { launch { refresh(it) } } // one throws…
} // …all are cancelled
Independent refreshes are not one indivisible result, but coroutineScope treats them as one: a single flaky source cancels the other nine and the user sees nothing refresh.
supervisorScope {
sources.map { src ->
async { runCatchingNonCancellation { refresh(src) } }
}.awaitAll()
}
// then render what succeeded, mark what didn't
Choose by asking whether a partial result is meaningful. Rendering nine of ten sources is useful; half a bank transfer is not. That question — not a habit — is what selects the scope.
viewModelScope.launch(Dispatchers.IO) { // caller guesses
val data = repo.load() // …which may itself switch
_state.value = data // now writing state off-main
}
Main-safety becomes a convention every caller must remember, and forgetting it is silent. Worse, the state write now happens on an IO thread, which is fine for StateFlow and a crash for a View.
// repository:
suspend fun load(): Data = withContext(io) { blockingRead() }
// ViewModel — no dispatcher, because it needs none:
viewModelScope.launch { _state.value = repo.load() }
A suspend function should be safe to call from any dispatcher. Once that holds, ViewModels never mention dispatchers, and the injected io makes the repository a JVM test.
Chapter 229Coroutines — cancellation and exceptions
runCatching in coroutine codeviewModelScope.launch {
runCatching { repo.save(draft) } // catches Throwable…
.onFailure { showError() } // …including cancellation
}
runCatching catches Throwable, so a cleared ViewModel reports a save failure to a screen that no longer exists, and the coroutine machinery is told the cancellation was handled. The same defect hides in catch (e: Exception).
suspend inline fun <T> runCatchingNonCancellation(block: () -> T): Result<T> =
try { Result.success(block()) }
catch (e: CancellationException) { throw e }
catch (e: Throwable) { Result.failure(e) }
Write it once, ban runCatching in coroutine code with a lint rule, and the whole class of bug disappears. Naming this unprompted in a code-review round is a strong Kotlin-depth signal.
try {
upload(file)
} finally {
api.releaseSlot(id) // a suspend call — throws immediately
} // if the job was cancelled
After cancellation every suspension point throws, so a suspending call inside finally fails before doing anything. The server-side slot leaks, and the failure is invisible because the exception is swallowed as ordinary cancellation.
NonCancellable for cleanup onlytry {
upload(file)
} finally {
withContext(NonCancellable) {
withTimeout(5_000) { api.releaseSlot(id) }
}
}
Scope it to the cleanup, never around the work itself, and bound it with a timeout — otherwise a cancelled screen can hang on a cleanup that never returns.
viewModelScope.launch(Dispatchers.Default) {
for (frame in frames) { analyse(frame) } // pure CPU, never suspends
}
Cancellation is cooperative and this loop never suspends, so leaving the screen does not stop it. The work runs to completion against a dead ViewModel, holding its captured references alive.
viewModelScope.launch(Dispatchers.Default) {
for (frame in frames) {
ensureActive() // cheap; throws if cancelled
analyse(frame)
}
}
Any CPU-bound loop longer than a frame needs a cooperation point. ensureActive() is nearly free; yield() additionally lets other coroutines run, which matters on a shared dispatcher.
Chapter 230Flow — operators and sharing
class Repo {
fun user(): Flow<User> = flow { emit(api.fetchUser()) }
}
// three composables collect it → three network calls
Flow is cold: each collector re-executes the whole builder. This is the mechanism behind "why does my API get called five times on this screen", and it worsens as the screen grows.
// Best: make the database the shared source.
fun user(): Flow<User> = dao.observeUser() // Room dedupes naturally
// Otherwise share explicitly, with an owner:
val user: StateFlow<User?> = flow { emit(api.fetchUser()) }
.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
Sharing needs a scope, and a scope means an owner — so decide who owns it rather than sprinkling shareIn. A single database-backed source is usually the better answer because it also survives process death.
query.collect { q ->
val results = repo.search(q) // previous search still running
_state.update { it.copy(results = results) }
}
Every keystroke starts a search and none are cancelled, so responses race: a slow query for "an" can land after a fast one for "android" and overwrite it. The user watches the results flicker backwards.
val results = query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q -> repo.search(q) } // cancels the previous
.stateIn(viewModelScope, WhileSubscribed(5_000), emptyList())
flatMapLatest guarantees the newest query wins by cancelling the older one, which is both correct and cheaper. debounce then removes the calls nobody needed.
fun locations(): Flow<Location> = callbackFlow {
val l = LocationListener { trySend(it) }
manager.requestUpdates(l)
// no awaitClose — the flow completes immediately, listener stays
}
Without awaitClose the builder returns at once and the flow completes, but the listener is never unregistered: GPS keeps running and the callback holds its captures forever. A battery complaint with no obvious cause.
awaitClose is mandatoryfun locations(): Flow<Location> = callbackFlow {
val l = LocationListener { trySend(it) }
manager.requestUpdates(l)
awaitClose { manager.removeUpdates(l) }
}.conflate() // drop stale fixes under backpressure
awaitClose both keeps the flow alive and provides the unregister hook. trySend rather than send because a callback is not a coroutine and must not block; conflate because a stale position has no value.
SharedFlow for one-shot UI eventsprivate val _nav = MutableSharedFlow<Route>(replay = 1) val nav = _nav.asSharedFlow() // rotation re-collects → replays → navigates twice
replay = 1 re-delivers on every new subscriber, so a configuration change navigates a second time. With replay = 0 the opposite bug appears: an event emitted while the screen is stopped is dropped entirely.
private val _events = Channel<UiEvent>(Channel.BUFFERED) val events = _events.receiveAsFlow() // buffers while stopped, replays never
Reserve SharedFlow for genuinely multicast signals with several independent observers. One consumer that must not miss and must not repeat is precisely a Channel.
Chapter 231Compose — state and effects
@Composable
fun Screen(vm: VM) {
val state by vm.state.collectAsState()
vm.trackScreenView() // fires on every recomposition
if (state.done) navController.navigate("next") // and again, and again
}
A composable body may run many times per frame, may be cancelled, and may run in parallel. Analytics fire dozens of times and the navigation call re-enters mid-composition, corrupting the back stack.
LaunchedEffect(Unit) { vm.trackScreenView() } // once per entry
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(STARTED) {
vm.events.collect { if (it is Navigate) navController.navigate(it.route) }
}
}
The rule: the composable body describes UI and does nothing else. Anything that touches the world outside belongs in an effect whose keys state exactly when it should restart.
@Composable
fun Timer(onTimeout: () -> Unit) {
LaunchedEffect(onTimeout) { // restarts on every recomposition
delay(10_000); onTimeout() // …so the timer never fires
}
}
A lambda parameter is a new instance on each recomposition, so keying on it restarts the effect continuously and the delay never elapses. Keying on Unit instead fixes the restart but captures the first lambda forever — a stale callback pointing at old state.
rememberUpdatedState@Composable
fun Timer(onTimeout: () -> Unit) {
val current by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) { // runs once…
delay(10_000); current() // …but calls the latest lambda
}
}
This is the canonical answer to "an effect that must not restart but must not go stale". It is asked verbatim in Compose-heavy loops.
var draft by remember { mutableStateOf("") } // lost on rotation
val items = viewModel.repo.loadSync() // re-fetched every recomposition
Two different lifetime errors. remember survives recomposition but not configuration change, so the user's half-typed message vanishes on rotation. And a blocking load in the body re-runs on every frame.
// survives rotation and process death, Bundle-sized:
var draft by rememberSaveable { mutableStateOf("") }
// survives rotation, arbitrary size, owned by the ViewModel:
val items by viewModel.items.collectAsStateWithLifecycle()
Three lifetimes, three tools: recomposition → remember; configuration change → rememberSaveable or a ViewModel; process death → saved state or disk. Naming which one a value needs is the whole decision.
Chapter 232Compose — performance and API design
@Composable
fun Header(scroll: ScrollState) {
// recomposes on EVERY scroll pixel
Box(Modifier.offset(y = (scroll.value / 2).dp).alpha(
if (scroll.value > 100) 0.5f else 1f))
}
Reading scroll.value in composition invalidates this composable on every frame of a scroll, so all three phases re-run for what is purely a visual offset. Recomposition counts look terrible and the fix is not "memoize harder".
@Composable
fun Header(scroll: ScrollState) {
val faded by remember { derivedStateOf { scroll.value > 100 } }
Box(Modifier
.offset { IntOffset(0, scroll.value / 2) } // lambda → layout phase
.graphicsLayer { alpha = if (faded) 0.5f else 1f }) // draw phase
}
The lambda overloads read state in a later phase, so composition is skipped entirely. derivedStateOf collapses hundreds of scroll values into two boolean changes. This pair is the highest-value Compose performance idiom.
@Composable fun ItemRow(item: Item, tags: List<String>, onClick: () -> Unit) // List is an interface — could be mutable — so it is unstable; // the row recomposes even when nothing about it changed.
One unstable parameter makes the whole composable non-skippable, and the cost multiplies down the tree. List, Map, Set and any class from a module without the Compose compiler are all unstable by default.
@Immutable
data class ItemUi(val id: String, val title: String,
val tags: ImmutableList<String>) // kotlinx.collections.immutable
@Composable
fun ItemRow(item: ItemUi, onClick: () -> Unit)
Verify rather than guess: run the compiler metrics report and read which composables are marked skippable. Strong skipping mode softens this, but a stable UI model is still the design that scales — and it belongs to the UI layer, not the domain.
@Composable
fun AppCard(title: String, subtitle: String?, icon: ImageVector?,
showBadge: Boolean, isCompact: Boolean, isSelectable: Boolean,
trailingText: String?, onClick: (() -> Unit)?)
Every new consumer adds a parameter, combinations multiply beyond what anyone can test, and the component ends up encoding eight teams' layout decisions. This is how a design system becomes the thing teams work around.
@Composable
fun AppCard(
modifier: Modifier = Modifier, // first optional param, by convention
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
)
Slots let callers supply arbitrary content without the component knowing about it, so the API stops growing. The modifier parameter is not optional practice: without it callers cannot size, pad or add semantics, and will fork the component instead.
Chapter 233Fragments and Activities
class ListFragment : Fragment() {
private lateinit var binding: FragmentListBinding // never nulled
override fun onCreateView(...) = FragmentListBinding
.inflate(inflater).also { binding = it }.root
}
The fragment outlives its view across a back-stack traversal, so the binding — and the entire view hierarchy it references — leaks for as long as the fragment is retained. Returning to the fragment then touches a destroyed hierarchy.
onDestroyViewprivate var _binding: FragmentListBinding? = null
private val binding get() = checkNotNull(_binding) { "view is destroyed" }
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
The check message turns the eventual misuse into a diagnosis rather than an NPE. A delegate that does this automatically is worth writing once per codebase — this is boilerplate that is always identical and always forgotten.
(parentFragment as FilterHost).onFilterChosen(filter) // or: targetFragment, or a static callback, or an EventBus
A cast to a host type couples the two fragments and crashes when the fragment is reused elsewhere. targetFragment is deprecated because it does not survive process death — the reference is gone and the result never arrives.
// child:
setFragmentResult("filter", bundleOf("value" to filter))
// parent:
setFragmentResultListener("filter") { _, bundle -> apply(bundle) }
The result API is bundle-backed, so it survives process death, and neither fragment names the other's type. For continuous shared state rather than a one-shot result, a ViewModel scoped to the navigation graph is the better fit.
class DetailFragment(private val id: String) : Fragment() // after process death the system calls the no-arg constructor → crash startActivityForResult(intent, 42) // deprecated, unstructured
The system recreates fragments reflectively with the no-arg constructor, so a constructor parameter guarantees a crash on process death — reproducible only with "Don't keep activities" or am kill. Numeric request codes are the same class of problem: unowned global state.
fun newInstance(id: String) = DetailFragment().apply {
arguments = bundleOf(ARG_ID to id)
}
private val picker = registerForActivityResult(PickContact()) { uri -> … }
picker.launch(Unit)
Arguments are part of the saved state, so they survive anything the system does. ActivityResultContracts replaces request codes with a typed contract registered before STARTED — which is also why registration must not be conditional.
Chapter 234ViewModels and state holders
class ProfileViewModel(
private val activity: Activity, // leaks on rotation
private val resources: Resources,
) : ViewModel() {
val title = resources.getString(R.string.title) // wrong after locale change
}
The ViewModel outlives the Activity by design, so holding one leaks the whole view hierarchy on every rotation. Resolving strings there also freezes them: a locale change recreates the Activity but not the ViewModel, so the old language persists.
data class ProfileUi(val titleRes: Int, val name: String) // Compose resolves against the current configuration: Text(stringResource(state.titleRes))
The ViewModel decides what to say; the UI layer decides how to render it in the current configuration. This also makes the ViewModel testable without a Context and keeps it usable from a screenshot test at any locale.
initclass FeedViewModel : ViewModel() {
init { viewModelScope.launch { _state.value = repo.load() } } // fires always
}
The load runs when the ViewModel is constructed, whether or not anything is observing, and there is no way to retry it or to test the class without triggering it. On a screen the user never scrolls to, it is pure cost.
val state: StateFlow<FeedUiState> = repo.observeFeed()
.map(::toUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Loading)
Work starts when the screen collects and stops when it stops — no manual lifecycle handling, no retry plumbing, and the test controls everything by choosing when to collect. Keep init for genuinely eager, subscription-independent work.
Chapter 235Dependency injection
object ServiceLocator {
lateinit var api: Api
val repo by lazy { Repo(api) }
}
class VM : ViewModel() { private val repo = ServiceLocator.repo }
Dependencies are invisible in the constructor, so nothing tells you what this class needs or lets a test substitute it. Tests must mutate global state and therefore cannot run in parallel — and one leaked assignment makes an unrelated suite fail.
@HiltViewModel
class FeedViewModel @Inject constructor(
private val repo: ArticleRepository,
private val clock: Clock,
) : ViewModel()
The constructor is the honest dependency list. If it is uncomfortably long, that is real information about the class doing too much — which a service locator merely hides.
@Provides @Singleton fun mapper(): ArticleMapper = ArticleMapper() @Provides @Singleton fun validator(): Validator = Validator()
Scoping is not an optimisation. A stateless mapper allocates faster than the scope lookup costs, and marking it @Singleton only adds lifetime to something that has no state to share. Meanwhile a genuinely shared cache scoped too narrowly silently duplicates.
@Provides fun mapper(): ArticleMapper = ArticleMapper() // unscoped @Provides @Singleton fun outbox(db: AppDatabase): OutboxDao = db.outboxDao() // one shared queue
The test: would two instances of this cause a correctness problem? Yes → scope it. No → do not. Say it in those terms and the follow-up about component hierarchies answers itself.
Chapter 236Room and data access
suspend fun replaceFeed(items: List<Article>) {
dao.deleteAll() // crash here → empty database
dao.insertAll(items)
}
Two independent transactions. A crash, a cancellation or a process kill between them leaves the user with an empty feed and no way to recover except a manual refresh they do not know to perform.
suspend fun replaceFeed(items: List<Article>) = db.withTransaction {
dao.deleteAll()
dao.insertAll(items)
}
withTransaction is the coroutine-aware form — it uses the transaction dispatcher so a nested suspending call cannot deadlock the way runInTransaction plus runBlocking does. Either the whole swap happens or none of it does.
val orders = dao.orders()
orders.forEach { it.lines = dao.linesFor(it.id) } // one query per order
Fifty orders means fifty-one queries, each with its own cursor and transaction overhead. It looks fine at ten rows in a test and is a visible freeze at fifty on a real device.
data class OrderWithLines(
@Embedded val order: OrderEntity,
@Relation(parentColumn = "id", entityColumn = "order_id")
val lines: List<LineEntity>,
)
@Transaction @Query("SELECT * FROM orders WHERE user_id = :id")
fun ordersWithLines(id: String): Flow<List<OrderWithLines>>
@Relation issues two queries total regardless of row count. @Transaction is required, not decorative: without it the two queries can observe different states and produce a result that never existed.
@Query("SELECT * FROM events ORDER BY ts DESC")
fun all(): Flow<List<Event>> // 200k rows, observed by a badge count
Room invalidates per table, so every insert re-runs this and re-maps 200,000 objects — to display a number. On a table written by a sync worker this is a permanent background CPU load and a steady allocation rate.
@Query("SELECT COUNT(*) FROM events WHERE unread = 1")
fun unreadCount(): Flow<Int> // one integer, index-backed
@Query("SELECT * FROM events WHERE chat_id = :id ORDER BY ts DESC LIMIT 50")
fun recent(id: String): Flow<List<Event>>
Push filtering and aggregation into SQL rather than mapping rows to count them in Kotlin. Confirm with EXPLAIN QUERY PLAN: "SEARCH … USING INDEX" is the goal, "SCAN TABLE" means you have none.
Chapter 237Networking and serialization
@Serializable data class UserDto(val id: String, val display_name: String?) Text(user.display_name ?: "") // JSON naming and server nullability in the UI
The server's field names, nullability and encoding conventions propagate to every screen. A backend rename becomes a UI change; an optional field becomes a null check in forty composables; and the UI cannot be tested without the network's shape.
data class User(val id: UserId, val displayName: String) // domain: no nulls
fun UserDto.toDomain() = User(
id = UserId(id),
displayName = display_name?.takeIf { it.isNotBlank() } ?: "Unknown",
)
One mapper is the single place where server reality is reconciled with what the app guarantees. It is also the natural place for a contract test: feed it a captured payload and assert the domain object.
class AuthInterceptor : Interceptor {
override fun intercept(chain: Chain): Response {
val res = chain.proceed(chain.request().withToken(token))
if (res.code == 401) { token = api.refreshBlocking() } // every thread
return res
}
}
Six parallel requests hitting a 401 trigger six refreshes; five of them invalidate the token the sixth just obtained, and the user is logged out. It also blocks an OkHttp dispatcher thread and never retries the original request.
Authenticator plus single-flightclass TokenAuthenticator(private val store: TokenStore) : Authenticator {
private val mutex = Mutex()
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null // give up, no loop
val fresh = runBlocking {
mutex.withLock {
val current = store.token()
if (current != response.request.token()) current // someone else did it
else store.refresh()
}
}
return response.request.newBuilder().withToken(fresh).build()
}
}
Authenticator is the correct hook — OkHttp calls it on 401 and retries the request for you. The mutex plus the "has someone already refreshed?" check is the single-flight pattern, and the response-count guard is what prevents an infinite refresh loop against a permanently rejecting server.
OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().setLevel(Level.BODY))
.build()
Full bodies at BODY level put tokens, personal data and payment details into logcat — which reaches crash breadcrumbs, support bundles and any app with log access on older devices. It also costs real time serialising strings nobody reads.
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = Level.BASIC
redactHeader("Authorization")
redactHeader("Cookie")
})
}
The build-type guard means the release binary does not contain the interceptor at all. In production, ship structured metrics — status code, latency, endpoint — rather than payloads, which is what you actually need to diagnose an incident.
Chapter 238Background work
WorkRequest inputval data = workDataOf("payload" to gson.toJson(largeList)) // 200 KB
OneTimeWorkRequestBuilder<UploadWorker>().setInputData(data).build()
Data is capped at roughly 10 KB and throws above it. More fundamentally, work input is not a transport for state — the row can sit in the queue for hours, by which time the serialised copy is stale.
val data = workDataOf("batch_id" to batchId) // a few bytes
// in the worker:
val items = dao.itemsForBatch(inputData.getString("batch_id")!!)
The worker reads current state at execution time, so a batch edited between enqueue and run is handled correctly. The same reasoning applies to notifications and deep links: pass identity, resolve state at use.
override suspend fun doWork(): Result =
try { api.upload(file); Result.success() }
catch (e: Exception) { Result.retry() } // including 400 and 413
A malformed payload retries with exponential backoff forever, waking the device on a schedule to fail identically. It also catches CancellationException and reports it as a retryable failure.
override suspend fun doWork(): Result = try {
api.upload(file); Result.success()
} catch (e: CancellationException) { throw e }
catch (e: IOException) { if (runAttemptCount < 5) Result.retry() else Result.failure() }
catch (e: HttpException) {
if (e.code() == 429 || e.code() >= 500) Result.retry() else Result.failure()
}
Three buckets — transient, permanent, cancellation — plus an attempt cap so a persistently unreachable server does not retry indefinitely. And Result.failure() must be visible to the user when it represents lost intent.
Chapter 239Testing
@Test fun loads() {
viewModel.load()
verify(repo).fetch() // passes even if nothing is displayed
verify(analytics).track(any())
}
This asserts that a particular implementation was chosen, not that the feature works. Refactor fetch into observe and the test fails while the app is fine — the definition of a brittle test, and the reason suites get deleted.
@Test fun shows_items_after_load() = runTest {
val repo = FakeArticleRepository(items = listOf(article("a1")))
val vm = FeedViewModel(repo, TestClock)
vm.state.test {
assertEquals(Loading, awaitItem())
assertEquals(listOf("a1"), (awaitItem() as Ready).items.map { it.id })
}
}
A fake plus a state assertion survives any refactor that preserves behaviour, which is exactly the property a test should have. Reserve mocks for genuine boundaries where the interaction is the contract — an analytics call, a payment SDK.
@Test fun debounces() {
vm.onQueryChanged("an")
Thread.sleep(400) // slow, and flaky on a loaded CI box
assertEquals(1, repo.searchCount)
}
Every such test adds real seconds to the suite and fails intermittently when CI is busy — the two properties that make a team stop trusting its tests. It is also untestable for anything longer than a few hundred milliseconds.
@Test fun debounces() = runTest {
vm.onQueryChanged("a"); vm.onQueryChanged("an")
advanceTimeBy(299); assertEquals(0, repo.searchCount)
advanceTimeBy(2); assertEquals(1, repo.searchCount)
}
Virtual time makes a 300 ms debounce assertable to the millisecond, instantly and deterministically. This requires the dispatcher to be injected — which is the concrete return on the DI argument in Chapter 235.
Chapter 240Gradle, modules and release
api everywhere and a :common moduledependencies {
api(project(":common")) // leaks the whole graph transitively
api(libs.retrofit)
}
api puts a dependency on every consumer's compile classpath, so a change to one class recompiles modules that never used it. A :common module everything depends on is the monolith with extra build-graph overhead — the dependency graph is now a star, not a tree.
implementation by default, split by responsibilitydependencies {
implementation(project(":core:database")) // not on consumers' classpath
api(project(":core:model")) // api ONLY for types in the surface
}
Use api only when a type genuinely appears in your public signatures. Replace :common with narrow modules — :core:model, :core:ui, :core:testing — so a change touches a leaf rather than the root.
android { buildTypes { release { isMinifyEnabled = true } } }
// R8 strips the reflectively-created adapter → crash only in production
Debug builds do not run R8, so every keep-rule bug ships. Reflection, serialization adapters and anything referenced only from XML are all invisible to the shrinker and fail at runtime in the one build users get.
// A minified debuggable variant, run in CI on every PR:
create("releaseTest") {
initWith(getByName("release"))
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += "release"
}
Smoke-test the actually-shipped shape of the binary. Prefer compile-time serialization (kotlinx.serialization) over reflective libraries so there are fewer keep rules to get wrong in the first place.
Chapter 241The review checklist
One question per area. If a change touches the area, the question must have an answer in the diff or the description.
| Area | The question |
|---|---|
| Types | Can a caller pass the arguments in the wrong order and still compile? |
| State modelling | How many states does this type represent, and how many are meaningful? |
| Collections | Is there a lookup inside a loop, or an eager chain over something large? |
| Nullability | Does this null have exactly one meaning? |
| Scopes | What cancels this coroutine, and what happens if a sibling fails? |
| Cancellation | Is CancellationException rethrown on every catch path? Can this loop be cancelled? |
| Flow | Cold and collected more than once? Is stale work cancelled? Does the callback unregister? |
| Compose effects | Anything in the body that is not describing UI? Are the effect keys right? |
| Compose perf | Is state read in composition that could be read in layout or draw? Any unstable parameters? |
| Fragments | Is the binding cleared? Do arguments survive process death? |
| ViewModel | Does it hold a Context, a View or a resolved string? |
| DI | Would two instances of this cause a correctness problem? If not, why is it scoped? |
| Room | Multi-step write in one transaction? Bounded, indexed query? A join rather than N+1? |
| Networking | Do DTOs stop at the boundary? Is refresh single-flight? Are bodies logged? |
| Background | Is work input an id rather than a payload? Are permanent failures retried? |
| Testing | Does this test assert behaviour or implementation? Does it use real time? |
| Build | api where implementation would do? Has the minified build been run? |
Part XXXV rapid recall
- Wrap identifiers and money in
value class; money isLongminor units, neverDouble. - Model states as a sealed hierarchy so illegal combinations cannot be constructed.
data classis a binary-compatibility hazard at a module boundary.- Sequences for long chains over large data;
associateByinstead of a lookup in a loop. - Never expose a mutable collection; back it with a private field and a read-only view.
nullneeds exactly one meaning, or it needs a type.- No
GlobalScope; every scope has an owner and every starter is idempotent. coroutineScopewhen children are one result,supervisorScopewhen they are independent.- Main-safety belongs to the function that blocks, with an injected dispatcher.
- Rethrow
CancellationException;NonCancellableonly around bounded cleanup. ensureActive()in any CPU loop longer than a frame.- Cold flows re-execute per collector — share deliberately or read from the database.
flatMapLatestfor superseded work;awaitCloseis mandatory incallbackFlow.Channelfor one-shot events;SharedFlowonly when several observers care.- Composable bodies describe UI and do nothing else; keys decide when effects restart.
rememberUpdatedStatefor an effect that must not restart but must not go stale.- Read state in layout or draw with the lambda modifier overloads, not in composition.
- Slots, not boolean flags; every component takes a
modifier. - Null the view binding in
onDestroyView; fragment arguments go in the bundle. - ViewModels emit resource ids, never resolved strings or a Context.
- Let subscription start the work rather than loading in
init. - Constructor injection always; scope only what shares mutable state.
- Multi-step writes in
withTransaction;@Relationwith@Transactioninstead of N+1. - Ask SQL the real question —
COUNTandLIMIT, not a full-table observe. - DTOs stop at the boundary; refresh is single-flight with a loop guard.
- Work input carries an id, not a payload; classify failures and cap attempts.
- Assert observable state with fakes and virtual time, not interactions and
sleep. implementationby default; run the minified build in CI.