The vocabulary of building for iOS and Android: platform lifecycles, SwiftUI and Compose state models, coroutines and structured concurrency, offline sync, and the specific things that go wrong under memory pressure and on a real network.
Foreground/active, where the app is visible and receiving input; background, where it is still running but not visible, usually with a short grace period before it is suspended; and suspended or terminated, where the process is frozen or gone entirely. Both platforms give an app very little guaranteed time in the background before the OS reclaims memory from it, which is why anything that must finish - a network upload, a database write - has to either complete fast, register for a background-execution extension, or hand off to a durable job the OS can restart later. Treating "background" as a stable place to keep doing work is the single most common lifecycle mistake candidates describe from experience.
Walk me through what happens when a user backgrounds and returns to an app.
The app moves to background, may be given a short window to finish urgent work, and is then eligible for suspension - frozen in memory but not yet killed, so it can resume instantly with everything intact. Under memory pressure the OS may go further and terminate the process outright while the user is elsewhere, and there is no reliable callback promised at that exact moment, which is why state that must survive has to be persisted proactively rather than in a handler for termination. When the user returns after suspension, the app resumes exactly where it left off; when they return after termination, the OS relaunches the process from scratch and the app must reconstruct its state from whatever it actually saved.
What is the difference between a cold start and a warm start?
A cold start means the OS has to create the process from nothing - load the binary, initialise the runtime, run application startup code, then build the first screen - and it is the slowest and most visible startup path a user experiences. A warm start means the process already exists in memory, usually because the app was recently backgrounded rather than killed, so the OS just brings it back to the foreground and the app resumes from where it paused, skipping process and runtime initialisation entirely. The design consequence is that cold-start time is a metric worth actively managing - deferring non-essential initialisation, lazy-loading subsystems the first screen does not need - because it is the number that determines whether a user waits or bounces.
Why do interviewers ask about the manifest or Info.plist instead of just code?
Because these declarative files are where an app states its permissions, its capabilities, and its entry points to the operating system, and misconfiguring them causes failures that look nothing like the actual cause - a crash on launch that is really a missing permission declaration, a feature silently unavailable because a background mode was never declared. On Android the manifest declares components (activities, services, receivers), permissions, and hardware features the app requires; on iOS Info.plist declares similar territory - background modes, required device capabilities, and the usage-description strings the system shows the user when requesting a permission. Interviewers use this as a proxy for whether a candidate has actually shipped something past a tutorial, because these files only become familiar through hitting their errors.
What is deep linking, and what makes it fragile?
A deep link is a URL that opens the app directly to a specific screen rather than to its default entry point, and both platforms support it through URL schemes or, more robustly, through platform-verified web links (Universal Links on iOS, App Links on Android) that require a domain to publish a file proving it controls the association. It is fragile because the target screen must be reachable with only the information encoded in the URL - if opening it assumes prior navigation state or a logged-in session that a cold-started process does not yet have, the link either crashes the screen or silently falls back to the home screen. The correct test is opening every deep link from a fully killed process, not from within a running session, because that is the actual condition a marketing email or a push notification produces.
Show me the app-state transitions a mobile engineer actually has to design around.
Both platforms converge on roughly the same shape even though the exact callback names differ.
Loading diagram…
The edge to look at is Suspended --> NotRunning. It happens with no guaranteed notification to the app, silently, purely because the OS needed the memory for something else - usually another app the user switched to. Anything the app has not already persisted by that point is gone, and the app has no way to know in advance which backgrounding event will be the one that triggers it.
That is why state restoration is designed around "assume termination could happen at any background transition" rather than around a specific callback that fires right before it. On iOS, scene(_:willDeactivate:) and encoding restoration state happen well before termination is even a possibility. On Android, onSaveInstanceState and, for a ViewModel, writing into SavedStateHandle happen at essentially the same point - proactively, on every backgrounding, not reactively when death is imminent.
The other edge worth naming is Foreground --> Suspended skipping straight past any assumption of a long-lived background phase. A modern OS suspends an app quickly specifically to save battery, so "the app has a few minutes in the background to finish this" is not a safe design assumption on either platform; it is closer to a few seconds unless the work has specifically registered for extended execution.
What is the difference between an Activity and a Fragment, and why does the distinction matter for state?
An Activity is a single, focused screen with its own window and its own entry in the OS's back stack, while a Fragment is a reusable portion of UI and behaviour hosted inside an Activity, letting one activity host several fragments simultaneously or swap between them without recreating the activity itself. The distinction matters for state because a fragment's lifecycle is driven by its host activity's lifecycle but is not identical to it - a fragment can be destroyed and recreated (for instance when it leaves the back stack and later returns) independently of the hosting activity being recreated, so state owned by a fragment needs the same rotation-versus-process-death thinking applied at the fragment level, not just the activity level.
iOS-specific fundamentals
What is ARC, and what does it not do?
Automatic Reference Counting: the compiler inserts retain and release calls at compile time based on static analysis of ownership, so an object is deallocated the instant its retain count reaches zero, with no separate garbage-collection pause. What it does not do is detect or break a reference cycle - two objects strongly referencing each other keep each other's count above zero forever, invisibly, because ARC only counts references, it never inspects the graph for reachability the way a tracing garbage collector would. That is precisely why weak and unowned exist as a manual override on top of an otherwise automatic system.
What is the difference between weak and unowned?
Both break a strong-reference cycle without ARC ever counting that particular reference, but they differ in what happens if the referenced object is deallocated first. weak makes the reference an implicitly unwrapped Optional that automatically becomes nil, so accessing it after deallocation is safe and simply returns nothing. unowned assumes the referenced object's lifetime strictly exceeds the reference's and does not become nil - accessing it after the object is gone is undefined behaviour that typically traps at runtime. Use weak whenever the referenced object could plausibly outlive or be independent of the holder; use unowned only when you can prove, from the actual object graph, that it cannot be deallocated first.
What is UIKit's view controller lifecycle, in the order it actually fires?
loadView (or the storyboard equivalent) builds the view hierarchy, viewDidLoad runs once per instance for one-time setup, viewWillAppear fires every time the view is about to become visible, viewDidAppear fires once it is on screen and safe to start animations or expensive work, and the corresponding viewWillDisappear/viewDidDisappear pair fires on the way out. The trap candidates fall into is putting expensive setup in viewDidLoad assuming it fires once per appearance - it fires once per instance, so if a view controller is pushed, popped, and pushed again as a new instance, viewDidLoad runs again, but if it is merely covered and uncovered on the same instance, only the viewWillAppear/viewDidAppear pair fires.
What does @escaping mean on a closure parameter, and why does it matter for memory?
A closure parameter is non-escaping by default, meaning the compiler can guarantee it will be called before the function it was passed to returns, and can therefore avoid the overhead of heap-allocating and retaining it. Marking a closure @escaping tells the compiler it may be stored or called after the function returns - passed to a completion handler for an asynchronous operation, for instance - which means anything the closure captures, including self, must be kept alive for however long the closure might still be called, and that is exactly the situation where a capture list ([weak self]) becomes necessary to avoid a retain cycle.
Show me the delegate-cycle fix, and why it has to be declared weak at the protocol usage site rather than fixed later.
The mistake is invisible in the protocol declaration itself and only shows up once you look at who holds whom.
What is the difference between UIKit and SwiftUI's view models?
UIKit is imperative: you write code that mutates a view's properties directly in response to an event, and the view retains whatever state you gave it until you explicitly change it again. SwiftUI is declarative: you describe what the UI should look like as a pure function of state, and the framework itself decides what to redraw when that state changes, meaning you never write "hide this label" - you write "the label's visibility is a function of this boolean" and let the framework apply it. The practical consequence is that SwiftUI bugs are far more often about the state model producing the wrong value than about a missed UI update, whereas UIKit bugs are far more often about a UI update site that was simply never called.
What does @MainActor do, and why does UIKit and SwiftUI code need it?
@MainActor is a global actor that confines execution of the marked function, closure, or type to the main thread, and the Swift compiler enforces this at compile time by refusing to let non-isolated code call into it without an explicit await. UIKit and SwiftUI both require nearly all UI mutation to happen on the main thread - the historical rule enforced only by convention and runtime crashes - so marking view models and UI-facing code @MainActor turns what used to be a runtime crash discovered in testing into a compile-time error, which is a meaningfully different category of bug to have caught.
What is the difference between a struct and a class in Swift, and why does SwiftUI lean on structs?
Android-specific fundamentals
What is the Android Activity lifecycle, in the order it fires?
What is the difference between a Service and a WorkManager job?
What is Doze mode and app standby, and why do they matter for background work?
What does Jetpack mean, and why is it more than "a UI library"?
What is the difference between Parcelable and Serializable for passing data between components?
Why does Room exist when SQLite is already available on the device?
Show me why a manifest permission declaration alone is not enough on modern Android.
UI frameworks - SwiftUI and Compose
What problem do SwiftUI and Compose solve that UIKit and the View system did not?
What is view identity in SwiftUI, and why does it matter separately from view state?
Show me why keying a Compose or SwiftUI list by array index is a specific, nameable bug.
What is @State for in SwiftUI, and when is it the wrong tool?
What is remember doing in Compose, and what happens if you forget it?
What is state hoisting, and what problem does it actually solve?
Show me a stateful composable turned stateless through hoisting, and what you gain.
Concurrency and background work
What is structured concurrency, in one sentence that actually distinguishes it from "using async code"?
What is a suspend function in Kotlin, and what does suspending actually mean at the machine level?
What is Swift's async/await actually replacing, and what does it fix?
What is an actor in Swift concurrency, and what problem does it remove?
What is a race condition, concretely, and why do mobile interviews ask about it even for UI code?
Show me the request-cancellation pattern that fixes a race in a search field, and what breaks without it.
Networking, offline and sync
What does "offline-first" actually mean as a design choice, rather than as a slogan?
What is a sync conflict, and what are the realistic ways to resolve one?
Show me the request flow a mobile client actually needs for a resilient network call, not just the happy path.
What does certificate pinning protect against, and what does it cost you operationally?
Why is exponential backoff with jitter the standard retry strategy for a mobile client, specifically?
What is GraphQL's appeal for a mobile client specifically, versus a typical REST endpoint?
Performance and memory
What is memory pressure, and what does the OS actually do about it on each platform?
Show me what onTrimMemory's levels actually mean for what you should release, and why one level is misleading.
What is a memory leak on mobile, specifically, given that both platforms have automatic memory management?
Why does holding a plain Activity reference in a singleton or a static field leak on Android specifically?
What is bitmap memory, and why does it cause out-of-memory crashes disproportionately on mobile?
What is jank, measured precisely rather than described impressionistically?
Cross-platform and the interview round
What trade-off does a cross-platform framework like React Native or Flutter actually make, framed honestly?
What does "native performance" mean precisely, when comparing a cross-platform framework to a fully native app?
How does Kotlin Multiplatform differ in approach from Flutter or React Native, and why does that matter for an interview answer?
What is the actual difference between an iOS interview and an Android interview, beyond the language?
What do interviewers actually mean by "no ViewController is an island" or the equivalent architectural framing, and what are they testing?
The two versions look almost identical in the child, and the difference is entirely about who is allowed to decide the value.
// Stateful: this composable owns its own state. It cannot be driven// from outside, previewed with a fixed value, or synced with a sibling.@Composable
{
query remember { mutableStateOf() }
TextField(value = query, onValueChange = { query = it })
}
{
TextField(value = query, onValueChange = onQueryChange)
}
Why does a side effect (a network call, a log line) not belong directly in a composable function body?
Because a composable function can be called multiple times per second during recomposition, and can be skipped, restarted, or run out of order relative to what a developer might expect from reading top-to-bottom code, none of which is true of an ordinary function call. Placing a network request or a log statement directly in the body executes it once per recomposition rather than once per meaningful event, producing duplicate network calls or a flood of log lines that has nothing to do with how many times the underlying event actually happened. LaunchedEffect (Compose) and .onAppear/.task (SwiftUI) exist specifically to tie a side effect's execution to a stable key or to the view's actual appearance in the hierarchy, rather than to how many times the surrounding function body happens to run.
What does @Composable's ability to run on any thread (or off the main thread) imply for how you write side effects inside it?
Composition itself is not guaranteed to run on the main thread in every case, though most of a typical app's Compose work does, which means code inside a composable body cannot assume main-thread-only APIs are automatically safe to call directly, and any actual UI side effect (that is not itself expressed through Compose state) may need explicit dispatch back to the main thread. In practice this rarely bites application developers directly because platform-provided composables already handle it, but it explains why Compose's effect APIs (LaunchedEffect, rememberCoroutineScope) exist as the sanctioned way to launch work rather than reaching for a raw thread or executor inside a composable and hoping it behaves.
Show me actor isolation preventing a data race that a plain class would allow.
The bug this prevents is two coroutines updating a shared counter and losing one of the updates, silently, because both read the old value before either write landed.
Show me what a local write queue actually needs to hold to make offline-first work.
The state looks small written out, and writing it out is most of the design work.
pending_operation
id -> local uuid, generated on the device, not the server
entity_type -> "order", "comment", ...
operation -> create | update | delete
payload -> the change itself
created_at -> local timestamp, for ordering and conflict resolution
status -> pending | syncing | failed | synced
retry_count -> how many attempts so far, for backoff and giving up
The id being client-generated rather than server-assigned is the detail that makes the rest work: the UI can display the new item immediately, before the network call ever happens, because the local record already has a stable identifier the rest of the app can reference - a comment can be liked, edited, or deleted locally while its creation is still queued, and all three operations reference the same client-generated id consistently.
retry_count and status together are what let a background sync process resume correctly after the app is killed and relaunched: it can find every pending or failed row and simply try again, and the queue is exactly as durable as the local database it lives in, which is why it belongs in the same persistent store as the rest of the offline-first data rather than in memory.
Show me the bitmap-sizing fix that prevents an image list from running out of memory.
Nothing in this code looks wrong until the image is large and the view showing it is small.
// Decodes at full native resolution regardless of how large the// ImageView actually is - a 4000x3000 photo decodes to roughly 48MB// as an uncompressed bitmap, for a thumbnail 200 pixels wide.val bitmap = BitmapFactory.decodeFile(path)
imageView.setImageBitmap(bitmap)
// Decode only the pixel data needed for the target size.val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, options)
val sampleSize = calculateInSampleSize(options, reqWidth = 200, reqHeight = 200)
scaledOptions = BitmapFactory.Options().apply { inSampleSize = sampleSize }
bitmap = BitmapFactory.decodeFile(path, scaledOptions)
imageView.setImageBitmap(bitmap)
Show me the trade-off table an interviewer actually wants when you're asked to compare native and cross-platform.
Naming the columns is worth more than picking a winner, because the honest answer is that the right column depends on the product, not on which framework is currently fashionable.
concern Fully native React Native / Flutter Kotlin Multiplatform
---------------------- --------------------- ------------------------- ---------------------
UI code shared None Shared UI layer Native UI, per platform
Newest platform APIs Immediate access Wait for bridge, or Immediate (UI is native)
write one yourself
Team structure Two teams, or one One team, two platforms One shared logic team,
engineer per platform two native UI teams
Performance ceiling Highest, no bridge Bridge/rendering layer Native UI performance,
layer overhead varies shared-logic overhead
is minimal
Where the real cost is Duplicated feature The abstraction's gaps, Maintaining two UI
work across platforms patched by hand implementations
The row worth naming unprompted is the last one, because it is where each approach's marketing pitch and its actual cost diverge. Fully native's cost is obvious and constant - you write the feature twice. Cross-platform's cost is intermittent and concentrated - most days you write it once, and then on the day a feature needs a platform capability the framework has not caught up to, you are back to writing a native bridge by hand, which can cost more than just having written it natively would have. Kotlin Multiplatform's cost is structural rather than intermittent: you always maintain two UI implementations, in exchange for never needing a bridge, because the UI was never shared to begin with.
"Walk me through what you'd check first for an app that crashes only for some users in production."
A strong answer starts with the crash-reporting tool's aggregation, not with guessing: which OS version, device model, and app version cluster the crashes, because "some users" is almost always "one specific combination" rather than a truly random subset. It then separates the two most common root causes by that clustering - an OS-version-specific API behaviour change (a permission that became stricter, a background limit that got tighter) if the cluster is version-bound, versus a device-specific memory or performance ceiling (a low-RAM device hitting an out-of-memory condition a flagship device never reaches) if the cluster is device-bound. A weak answer jumps straight to reproducing the crash locally on the candidate's own device, which is exactly the device least likely to reproduce a crash that is "only for some users" in the first place, and describes a debugging process with no theory behind which users to even try to match.
protocolDownloadDelegate: AnyObject {
funcdownloadDidFinish()
}
finalclassDownload {
weakvar delegate: DownloadDelegate? // must be weak here
}
finalclassScreenController: DownloadDelegate {
let download =Download() // ScreenController owns Download stronglyfuncstart() { download.delegate =self }
funcdownloadDidFinish() { /* ... */ }
}
If delegate were declared var delegate: DownloadDelegate? without weak, the two objects would hold each other strongly - ScreenController through its download property, Download through its delegate property - and neither could ever be deallocated, regardless of how carefully the rest of the app is written.
The AnyObject constraint on the protocol is not decoration. weak relies on ARC's reference-counting machinery, which only class instances participate in, so a protocol intended for a weak delegate property must be restricted to reference types or the compiler cannot accept the declaration at all. The fix therefore lives in exactly two places - the protocol's AnyObject constraint and the property's weak keyword - and both have to be right for the pattern to work; getting one right without the other either fails to compile or fails to break the cycle.
A struct is a value type: assigning it or passing it copies its value, and each copy is independent. A class is a reference type: assigning it copies a reference, so two variables can point at the same mutable instance and a change through one is visible through the other. SwiftUI views are structs deliberately, because a view is meant to be a cheap, disposable description of what should be on screen right now - recreated freely every time state changes - and value semantics guarantee that recreating one cannot accidentally leave stale shared state behind the way a shared class instance could.
onCreate runs once when the activity is first created, onStart when it becomes visible, onResume when it gains focus and is interactive, then on the way out onPause when it loses focus but is still visible, onStop when it is no longer visible, and onDestroy when the instance is being torn down entirely - which may be for good, or as the visible half of a configuration-change recreation. The interview-relevant detail is that onDestroy fires in both the "gone for good" case and the "about to be recreated for a config change" case, so code in onDestroy cannot assume the app is actually going away, and cleanup that should only happen on genuine termination belongs elsewhere.
A Service is a component that runs independently of any UI, for work that is ongoing while the app is in use or, for a foreground service, ongoing and visible to the user via a notification; it gives you direct control but no persistence or retry semantics of its own. WorkManager sits above the OS's own scheduling primitives (JobScheduler and, on very old versions, AlarmManager) and adds a persistent request queue with constraints and automatic retry, so a request survives process death and reboot without the developer managing that durability by hand. Reaching for a raw Service when the actual requirement is "this must eventually run and survive a reboot" means re-implementing WorkManager's persistence layer by hand.
Doze mode is a system-wide power-saving state the OS enters when the device has been stationary and unplugged for a while, during which it defers network access, background jobs and standard alarms for every app to batch them together and let the CPU stay asleep longer. App standby buckets separately classify each app by how recently and how often the user actually interacts with it, and an app in a rarely-used bucket gets its background work deferred far more aggressively than one the user opens daily. Together they mean a background job's actual execution time is a negotiation with the system, not a promise, which is precisely why WorkManager's contract is "will run when constraints are met," never "will run at this time."
Jetpack is Google's umbrella term for a large set of libraries covering architecture (ViewModel, Room, Navigation, WorkManager), UI (Compose), and testing, released independent of the Android OS version so an app can adopt new capabilities without waiting for users to update their device's OS. The reason it matters beyond naming is that most of what a modern Android interview probes - ViewModel survival, SavedStateHandle, structured navigation, background scheduling - is Jetpack behaviour layered on top of the OS's own lifecycle, not the OS lifecycle itself, so understanding both layers separately is what lets a candidate explain why a given API behaves the way it does rather than only that it does.
Serializable is a plain Java/Kotlin interface that uses reflection to serialise an object, which is simple to implement but slow and allocates heavily, because the JVM has to inspect the object's structure at runtime on every pass. Parcelable requires writing explicit code describing exactly how to flatten and rebuild the object into Android's own Parcel format, which is more code but avoids reflection entirely and is what Android's own IPC mechanisms (passing data between activities, into a Bundle) are optimised for. The practical rule is Parcelable for anything passed frequently or across process boundaries, and Serializable only where the volume is low enough that the reflection overhead genuinely does not matter.
Room is a compile-time-verified layer over SQLite: it generates the boilerplate for mapping query results to objects, and critically it validates your @Query annotations against your actual schema at compile time, so a typo in a column name or a query referencing a table that does not exist fails the build rather than crashing at runtime the first time that code path executes. Below that convenience, it is still SQLite doing the actual storage and querying, with the same transaction and indexing behaviour, so performance reasoning about the underlying database still applies; Room removes hand-written mapping code, not the need to understand the query plan underneath it.
Both replace imperative mutation of a mutable view hierarchy with a declarative description of the UI as a pure function of state, and both frameworks take over the job of diffing the old and new description to compute the minimal actual update - a job that used to be the developer's to do by hand, one setText or setVisibility call at a time, and one that was the single largest source of "the UI is out of sync with the data" bugs in both ecosystems. The shift moves the hard part of the job from "did I remember to update every place this value is shown" to "is my state model correct," which is a real reduction in one category of bug traded for a new category around making the state model itself efficient to observe.
SwiftUI decides whether a view in a new description is "the same view, updated" or "a brand-new view, insert here" based on identity - either structural position in the view tree, or an explicit id() - before it even looks at the view's properties. Two views with identical content but different identity are treated as an insertion and a removal, which means any transition or animation attached fires as though the content genuinely came and went, and any local state the view held (like a @State scroll position) resets to its default rather than carrying over, because as far as SwiftUI is concerned it is a different view. Getting identity wrong - typically by keying a ForEach on array index instead of a stable id - produces animation glitches and lost local state that look like state-management bugs but are actually identity bugs.
The failure only shows up once the list is mutated, so it survives a demo on static data and then breaks in production the first time an item is removed.
List identified by position (index), three items:
index 0: "Alice" index 1: "Bob" index 2: "Carol"
Remove "Bob" (index 1). Framework re-diffs by index, not by identity:
index 0: "Alice" index 1: "Carol" <- was Bob's slot
The framework sees index 1's content change from "Bob" to "Carol" -
not "Bob's row was removed." Any local state that row was holding
(a text field draft, an expanded/collapsed flag, a checkbox) stays
attached to index 1 and now displays against Carol's row instead.
Nothing crashes and no error appears anywhere, which is why this is a recurring interview scenario rather than a rare one - it produces a subtle, hard-to-reproduce-on-demand bug report ("sometimes the wrong item is expanded after I delete something") that looks like a data bug and is actually a framework identity bug.
The fix in both ecosystems is the same shape: key by a value stable to the item, not to its position. In SwiftUI, conforming to Identifiable with a stable id (not an index) or passing id: \.someStableProperty to ForEach. In Compose, passing a key = { item -> item.id } lambda to LazyColumn's items(). Both tell the framework "this content moved" instead of "this content changed," which lets it correctly carry local state and animations with the item rather than leaving them behind at a now-stale position.
The detail worth adding unprompted: this identity mechanism is entirely separate from the equality-based skipping mechanism that decides whether a composable or view body re-executes at all. A correctly keyed row can still recompose unnecessarily if its parameters are unstable, and an unkeyed row can skip recomposition correctly while still misattributing local state on a list mutation - they are two different mechanisms solving two different problems, and fixing one does not fix the other.
@State marks a piece of value-type state as owned by a specific view, with SwiftUI managing its storage outside the view struct's own lifetime so it survives that view being recreated on every re-render. It is the wrong tool the moment the state needs to be shared with, or driven from, more than the one view that declared it - a value another screen needs to read, or one that must survive the view disappearing and reappearing in a list, belongs in an observable model object passed down instead, because @State is specifically scoped to the view instance's position in the hierarchy, not to any broader lifetime.
remember stores a value across recompositions of the composable that calls it, so a value created inside a composable function survives that function being re-executed on the next recomposition rather than being recreated from scratch every time - which, absent remember, is exactly what would happen, since the function body runs again in full on every recomposition. Forgetting remember around, say, a mutableStateOf used for local UI state means the state resets to its initial value on every recomposition rather than persisting, which usually presents as a control that visually flickers back to its default the instant the user interacts with it, because the interaction itself triggered the very recomposition that wiped the state.
State hoisting means moving state up out of a child composable into its caller, and passing the value down as a parameter alongside a callback the child uses to request a change, rather than letting the child own and mutate the state itself. It solves the problem of a child needing to be controlled by, or synchronised with, something outside itself - a parent that needs to know the current value, a sibling that needs to react to the same change, or a preview and a test that need to supply a specific value without running the child's own internal logic. The child becomes what is sometimes called stateless: it renders whatever value it is given and reports intent to change it, and the actual decision of what the new value should be lives entirely with whoever hoisted the state.
Every unit of concurrent work has an explicit parent scope that owns its lifetime, so cancelling or awaiting the parent automatically cancels or awaits everything launched inside it, rather than leaving orphaned work with no owner - which is the failure mode structured concurrency was specifically designed to make impossible by construction, not merely by convention. Before this model, both Swift and Kotlin let you fire off asynchronous work with nothing tracking who was responsible for stopping it, so a screen could be dismissed while its background task kept running, updating state nobody was reading and holding resources nobody remembered were held. Structured concurrency makes that ownership question mandatory at the point you start the work rather than optional at the point someone eventually debugs the leak.
A function marked suspend can pause its execution at a well-defined point without blocking the underlying thread, and the compiler transforms it into a state machine that can be resumed later from wherever it left off. "Suspending" means the thread is freed to do other work while this particular coroutine is waiting - for a network response, for a delay - which is the opposite of a blocking call, where the thread sits idle holding a whole OS thread's worth of resources for the duration of the wait. This is why thousands of coroutines can be in flight on a handful of actual threads: most of them are suspended, not consuming a thread at all, at any given moment.
It replaces the completion-handler pattern, where an asynchronous function took a closure to call with its result, and chaining several of them nested closures inside closures in a shape informally called "pyramid of doom," with linear code that reads top to bottom despite still being asynchronous under the hood. Beyond readability, it fixes a category of bug endemic to completion handlers: forgetting to call the completion closure on one code path (silently hanging the caller forever), or calling it twice (violating the caller's assumption that a callback fires exactly once) - both mistakes the compiler cannot catch with closures, and both structurally impossible with async/await, because a function marked async returns exactly once, by construction.
An actor is a reference type that guarantees only one task executes its methods at a time, serialising access to its mutable state automatically rather than requiring the developer to remember to take a lock around every access. It removes data races on the actor's own state as a category, at the cost of every cross-actor call now potentially suspending - calling an actor's method from outside the actor is implicitly asynchronous, because the caller may have to wait for the actor to become free, which is a real design consequence, not free safety with no trade-off.
A race condition is a bug whose outcome depends on the unpredictable relative timing of two or more concurrent operations touching shared state, so the same code can produce a correct result on most runs and a wrong one occasionally, making it a bug that resists ordinary reproduction. Mobile interviews ask about it for UI code specifically because a screen backed by asynchronous data - a search-as-you-type field firing a network request per keystroke - can easily display the response to an earlier query after a later one, if the later request happens to finish first; the fix is either cancelling the previous request when a new one starts, or tagging each response with a request id and discarding any that do not match the latest one issued.
Nothing here looks unusual until two responses arrive in the wrong order.
privatevar searchJob: Job? = nullfunonQueryChanged(query: String) {
searchJob?.cancel() // cancel whatever was still in flight
searchJob = viewModelScope.launch {
delay(300) // debounce: wait for typing to pauseval results = repository.search(query)
_results.value = results
}
}
Without the cancel, typing "ca", then "cat" fires two searches. If the network happens to return the "ca" results after the "cat" results - which is entirely possible, since network timing is not guaranteed to match request order - the screen ends up showing results for "ca" while the visible query text reads "cat," and nothing in the code looks broken because each individual request-response pair worked correctly in isolation.
Cancelling the previous job before launching a new one means an in-flight, now-stale request either never delivers its result (its coroutine is cancelled before repository.search returns) or delivers into a coroutine that is no longer the one connected to the current query, either way preventing the stale write to _results. The debounce delay is a separate, complementary optimisation - it avoids firing a request on every keystroke at all - and fixes a different problem (network load) from the one cancellation fixes (correctness of which result wins).
An offline-first app treats the local database as the source of truth the UI always reads from, and treats the network as a background process that updates the local store, rather than the UI reading directly from the network with the local store as a mere cache. The consequence is that the UI works identically, from the screen's point of view, whether the network is present or not - it never has a code path that only works online - and syncing becomes the job of reconciling local writes with the server asynchronously, including deciding what happens when both sides changed the same record while disconnected from each other.
A sync conflict happens when the same record is modified locally while offline and also modified on the server (by another device, or another user) before the local change is uploaded, so the two changes disagree about what the record should currently be. Last-write-wins, keyed by timestamp, is the simplest resolution and is acceptable when losing one side's change silently is tolerable - useful for something like a read/unread flag. Field-level merging, where non-conflicting fields from both versions are kept and only genuinely conflicting fields need a decision, works when records have several independent fields. And presenting the conflict to the user for a manual choice is the only correct option when silently discarding either version would be a real problem, such as two edits to the same document.
Almost every mobile networking bug interview traces back to one of these branches being skipped, not to the happy path being wrong.
Loading diagram…
The branch candidates skip most often is the middle one: whether a timed-out request is safe to retry depends entirely on whether the operation is idempotent, and a mobile client retrying a non-idempotent "create" call after a timeout is exactly how a flaky connection produces a duplicate order or a duplicate post - the request may well have succeeded server-side even though the response never made it back to the device.
The other branch worth naming explicitly is the leftmost one, queuing locally when the network is unreachable rather than simply failing the action. A mobile app spends a meaningful fraction of its life with no connectivity or a bad one, so a design that treats "network unreachable" as an error to show the user, rather than as an ordinary condition to queue against, produces a visibly worse offline experience than the platform's own capabilities would allow.
Certificate pinning means the app hard-codes which certificate or public key it trusts for a given server, rather than trusting any certificate signed by any certificate authority the device trusts, which defeats an attacker who has compromised or coerced a certificate authority into issuing a fraudulent certificate for your domain - a real, if uncommon, attack against ordinary TLS. The operational cost is that rotating your server's certificate now requires shipping an app update with the new pin before the old certificate expires, or the app itself starts refusing to connect to its own legitimate server; teams that pin without a rotation plan have genuinely locked users out of their own app during a routine certificate renewal.
Because a mobile client retrying immediately and repeatedly after a failure both drains battery pointlessly on a connection that is not coming back instantly, and, at scale across many devices reconnecting after the same outage, produces exactly the retry-storm pattern that turns a recovering server back into a struggling one the instant it comes back up. Exponential backoff spaces out each successive retry attempt further apart, and jitter (a small random offset added to each wait) prevents every device that failed at the same moment - which, after a shared network blip, is most of them - from retrying in lockstep and re-creating the same overload wave a moment later.
A mobile client on a variable-quality connection benefits disproportionately from fetching exactly the fields a given screen needs in a single round trip, rather than either over-fetching a fixed REST response shape or making several separate REST calls to assemble one screen - both of which cost more bytes and more round trips on a connection where each round trip has real, sometimes highly variable, latency. The trade a mobile team takes on in exchange is that query cost and caching become the client's problem to manage explicitly, since a flexible query language makes it harder for a simple HTTP cache to reason about equivalent requests the way it could with REST's fixed, predictable URLs.
Memory pressure is the state where available system memory is running low enough that the OS needs to reclaim some, and both platforms respond by asking or forcing apps to give memory back before resorting to killing a process outright. iOS delivers a low-memory warning to the foreground app (and to background apps before killing them), giving the app a chance to release caches and non-essential data voluntarily; Android's equivalent is onTrimMemory, delivered with a severity level so an app can respond proportionately - releasing UI-related caches at a mild level, releasing everything non-essential at a severe one. Ignoring both signals means the app is simply first in line to be killed when the OS runs out of patience.
The levels look like an escalating scale, and mostly are, but one of them is a trap for a naive reading.
level what it signals what to release
----------------------- ---------------------------------- --------------------------------
TRIM_MEMORY_RUNNING_LOW device overall is low on memory, large caches, decoded images
your app is still visible not currently on screen
TRIM_MEMORY_UI_HIDDEN your UI is no longer visible, anything purely UI-related -
app itself is not being killed bitmaps only the visible
screen needed
TRIM_MEMORY_BACKGROUND your app is now in the non-critical cached data;
background process list app is not imminently dying
TRIM_MEMORY_COMPLETE your app is one of the first everything non-essential;
to be killed if memory is not this is close to the end
freed system-wide
The trap is TRIM_MEMORY_UI_HIDDEN: it sounds like a background-related signal because it fires when the app is no longer visible, but it is not primarily about memory scarcity - it fires every time the app is simply backgrounded normally, memory pressure or not. Treating it as "the app is in danger" and releasing aggressively there means every ordinary app-switch triggers a cache flush the user pays for as a slower resume, when the level is really just "release what only the visible UI needed," a much narrower instruction than the other three.
The practical shape most apps end up with is releasing UI-only caches (decoded bitmaps for off-screen views) at UI_HIDDEN, and reserving the release of genuinely expensive-to-rebuild data - a large parsed dataset, an in-memory search index - for BACKGROUND or COMPLETE, where the signal is actually about survival rather than about visibility.
Automatic memory management (ARC on iOS, garbage collection on the Android/JVM side) prevents the classic C-style leak of forgetting to free memory explicitly, but it cannot prevent an object being kept reachable - referenced, directly or through a chain, from something that is never going away - when nothing in the app actually still needs it. A retain cycle on iOS and a static field or long-lived listener holding an Activity context on Android are the platform-specific versions of the identical underlying mistake: something that should have a short lifetime got attached, deliberately or accidentally, to something with a much longer one.
An Activity holds a comparatively large object graph - its entire view hierarchy, its resources, often a Context reference used throughout the screen - and a singleton or a static field lives for the lifetime of the whole process, not for the lifetime of any one screen. If a static field or long-lived object captures a reference to a specific Activity instance - registering it as a listener callback and forgetting to unregister, or simply assigning this to a static var for convenience - that Activity instance, and everything it in turn references, cannot be garbage collected even after the user has navigated away and the system believes the screen is gone, because the static field is still a live reference to it. The fix is either not doing this at all, or explicitly holding the application Context (which does live for the process's lifetime and is meant to be held long-term) instead of the activity's.
A decoded bitmap's memory footprint is determined by its pixel dimensions and colour format, not by its compressed file size on disk - a modern phone camera photo can decode to tens of megabytes in memory even though its JPEG file on disk is a few hundred kilobytes, because decoding expands the compressed representation into one uncompressed value per pixel. Loading full-resolution images into a scrolling list of thumbnails, without downsampling to the size the thumbnail will actually be displayed at, multiplies that expansion by however many images are in memory at once, which is why image-heavy screens are a disproportionate source of out-of-memory crashes relative to how simple the feature looks. The fix on both platforms is decoding (or requesting) an image at close to its display size rather than its native resolution, and most platform image-loading libraries do this by default once given the target view's dimensions.
Jank is a frame that takes longer to produce than the display's refresh interval allows - typically about 16.7 milliseconds for 60Hz, or roughly 11.1 milliseconds for 90Hz - so the display shows the previous frame again rather than the new one, which the user perceives as a stutter or a skipped beat rather than smooth motion. It is a precise, measurable threshold rather than a vague feeling of slowness, which is why both platforms' profiling tools report it directly as a frame-time metric rather than as a subjective rating, and why "the scrolling feels laggy" as a bug report should always be followed by "what does the frame-time graph actually show during that scroll."
It trades a single codebase's development speed and consistency across platforms for a layer of abstraction between the app and each platform's native APIs, meaning the framework's maintainers must keep pace with platform changes on the app's behalf, and any feature the abstraction has not yet caught up to needs a platform-specific bridge written and maintained separately, defeating some of the original benefit for exactly the features that needed it. The honest framing an interviewer wants is not "cross-platform is worse" or "native is dying," but naming which specific costs a given product's requirements actually incur - a content-driven app with modest platform-specific needs pays little for the abstraction, while an app leaning heavily on the newest platform-specific capabilities pays for it constantly.
It usually means how close the framework gets to directly using the platform's own rendering and UI toolkit rather than running through an interpreted bridge layer for every UI update - Flutter draws its own UI with its own rendering engine rather than mapping to native platform widgets at all, while React Native has moved toward calling native components more directly rather than bridging through a JavaScript-driven layout pass for every frame. The precise, testable version of the comparison is frame time and startup time on real devices under real workloads, not a general impression, because both ecosystems have shipped genuinely fast apps and genuinely slow ones, and the framework choice alone rarely explains which one a specific app turned out to be.
Kotlin Multiplatform shares business logic - networking, data models, use cases - written once in Kotlin, while leaving the actual UI layer fully native on each platform (SwiftUI on iOS, Compose or the native view system on Android), rather than sharing the UI layer itself the way Flutter and React Native do. That matters for an interview answer because it changes which trade-off you are actually describing: Kotlin Multiplatform's cost is maintaining two UI implementations rather than one, in exchange for a UI on each platform that is genuinely indistinguishable from a fully native one, whereas Flutter and React Native's cost is the UI abstraction layer itself, in exchange for writing that UI once.
The underlying engineering concerns - lifecycle-driven state loss, memory pressure, background-execution limits, offline resilience - are close to identical, because they follow from what a mobile OS is, not from which language wraps it; what differs is the specific vocabulary and the specific mechanisms each platform gives you to address the same concern. A candidate who has only prepared one platform's terms can usually still reason correctly about the other platform's version of the same problem if the interviewer states it concretely enough, which is itself something interviewers sometimes probe for deliberately when hiring for a role that might eventually touch both.
They are testing whether a candidate structures a screen so that business logic, data access, and UI rendering are separable and independently testable, rather than writing a ViewController or Activity that directly fetches its own data, parses it, and renders it in one undifferentiated block - the shape that makes screens impossible to unit test and impossible to reuse logic from without duplicating it. The specific pattern name (MVVM, MVI, or a particular flavour of Clean Architecture) matters far less than whether the candidate can articulate, for their own past code, which part they would test without standing up a UI at all, because that is the concrete, checkable version of the architectural claim.
Declaring a permission and actually holding it at runtime are two separate checks, and conflating them is a specific, recurring crash.
<!-- AndroidManifest.xml: declares the app MAY request this permission --><uses-permissionandroid:name="android.permission.CAMERA" />
// This alone crashes with a SecurityException on API 23+ if the user// has not yet granted the permission, even though it is in the manifest.val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
startActivityForResult(intent, REQUEST_CODE)
(ContextCompat.checkSelfPermission(, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.CAMERA), REQUEST_CODE)
} {
startActivityForResult(intent, REQUEST_CODE)
}
// Correct: check first, request if needed, and only proceed in the callback.
if
this
else
The manifest entry is a declaration to the OS and to the Play Store's review process that the app may ask for this capability; it does nothing to grant it. Since Android 6.0 (API 23), dangerous permissions - camera, location, contacts, and similar - must be requested from the user at runtime, and the user can revoke a previously granted permission at any time from system settings without the app being reinstalled or updated.
That revocation path is the part candidates most often miss: an app that checked the permission once at launch and cached the result can still crash later in the same session if the user backgrounds the app, revokes the permission from settings, and returns - which is why the check belongs immediately before the permission-gated action, not once at startup.
funSearchField()
var
by
""
// Stateless after hoisting: the caller decides the current value and
// what happens when the user types, so this composable is reusable,
// previewable, and testable with any value the caller wants to supply.
The stateless version costs one thing: the caller must now actually own a mutableStateOf (or an observable model property) somewhere and wire it in, so hoisting every leaf composable all the way to the root of the app produces a single object holding every field of every screen, which is its own kind of bad design, mirroring the "one enormous ObservableObject" problem SwiftUI code hits from the opposite direction. The actual skill is hoisting exactly as far as the state needs to be shared, and no further - a search field used on one screen and read by nothing else can happily stay stateful, while a value a parent must react to has to be hoisted to that parent, no higher.
// Plain class: two coroutines can both read count=5, both compute 6,// both write 6 - one increment is lost with no error anywhere.classPlainCounter {
var count = 0funincrement() { count = count + 1 }
}
// Actor-equivalent in Kotlin: a Mutex serialises access, so the// read-modify-write is atomic with respect to other coroutines.classSafeCounter {
privateval mutex = Mutex()
privatevar count = 0suspendfunincrement() = mutex.withLock { count = count + 1 }
}
Swift's actor keyword makes this the default rather than something you opt into per property: declaring actor Counter { var count = 0; func increment() { count += 1 } } gives every access to count the same serialisation as the Mutex version above, enforced by the compiler rather than by a developer remembering to acquire a lock.
The cost that shows up in both versions is that calling increment() from outside the protected boundary becomes a suspending call - the caller may have to wait if another coroutine or task is already inside. That is not a bug being hidden; it is the actual price of the safety, and a design that calls into an actor or a mutex-protected class from a tight, latency-sensitive loop will feel that cost directly.
val
val
The first pass with inJustDecodeBounds = true reads only the image's dimensions from its header, without decoding any actual pixel data, which is what makes computing the right inSampleSize cheap enough to do before every decode. The second pass then decodes at a resolution close to what will actually be displayed, rather than at the source file's native resolution.
Across a scrolling list of dozens of thumbnails, the difference between full-resolution and target-sized decoding is not a modest optimisation - it is frequently the entire difference between the screen working and the screen crashing with an out-of-memory error, because the wasted memory multiplies by every image simultaneously held for a smoothly scrolling list. Most platform image-loading libraries (Coil, Glide, Apple's own image pipelines) do this resizing automatically once given the target view's dimensions, which is the practical reason to prefer one of them over hand-rolled decoding.