Kotlin Interview Questions and Answers

Last updated:

Check out 45 of the most common Kotlin interview questions, then take an AI-powered practice interview

AndroidSpringMultiplatformCoroutinesJava
45+
Questions
18
Basic
18
Intermediate
9
Advanced
Q1

How does Kotlin's null safety actually work, and where can a NullPointerException still happen?

BasicNull Safety

Answer

Kotlin makes nullability part of the type system: String and String? are different types, and the compiler refuses to dereference a nullable type without a check. The everyday operators are the safe call (user?.name evaluates to null if user is null), the Elvis operator (value ?: fallback), and the not-null assertion (value!!), which throws a NullPointerException at that exact line if the value is null. After an explicit null check the compiler smart-casts the variable, so inside if (input != null) you can call input.length directly.

The interview trap is the second half of the question: NPEs are still possible in Kotlin, and interviewers want you to enumerate the escape hatches. First, !! is an explicit opt-out, and codebases littered with it have simply moved the crash from compile time to runtime. Second, platform types: values coming from Java APIs have types like String! where the compiler cannot verify nullability, so a Java method returning null flows into a Kotlin non-null variable and explodes later, far from the cause.

The fix is to declare explicit nullable types at every Java boundary and to rely on @Nullable/@NotNull annotations (JSR-305, JetBrains, AndroidX), which the compiler does respect. Third, lateinit var properties throw UninitializedPropertyAccessException when read before assignment. Fourth, leaking this from a constructor lets other code observe not-yet-initialised non-null fields. A strong production answer also mentions process discipline: many Android teams lint-ban !! outside tests and treat every platform-type boundary as a validation point, which is why their crash-free rates survive Java SDK interop.

fun describe(input: String?): String {
    // safe call + Elvis
    val len = input?.length ?: 0

    // smart cast after an explicit check
    if (input != null) {
        return "'$input' has ${input.length} chars"
    }

    // input!!.length here would throw NullPointerException
    return "blank input (len=$len)"
}

// Java interop: getName() returns the platform type String!
// Pin the nullability explicitly at the boundary:
val name: String? = javaUser.getName()
val display = name ?: "guest"

Key Points

  • String and String? are distinct types; the compiler enforces checks
  • ?. and ?: cover most cases; !! is an explicit crash opt-in
  • Platform types (String!) from Java bypass the checker
  • lateinit reads before init throw UninitializedPropertyAccessException
  • Declare explicit nullable types at every Java boundary
Q2

What is the difference between val, var, and const val, and does val make an object immutable?

BasicFundamentals

Answer

var declares a mutable variable that can be reassigned; val declares a read-only reference that is assigned exactly once. The distinction interviewers actually test is that val protects the reference, not the object: a val pointing at a MutableList still allows list.add(), because the list itself is mutable. True immutability comes from immutable types (List instead of MutableList, data classes with val properties), not from the val keyword.

A val property can also declare a custom getter, in which case it computes a fresh value on every access and holds no backing field at all, so two reads can return different results, which surprises people who equate val with constant. const val is a genuine compile-time constant: it must be a top-level or companion-object member of a primitive or String type, initialised from a compile-time expression. The JVM detail worth mentioning is that const values are inlined into call sites at compile time. If module A defines const val MAX = 3 and module B uses it, B's bytecode contains the literal 3; bumping the constant in A without recompiling B leaves B running the stale value.

That makes const inappropriate for feature-flag-like values shared across independently deployed artifacts. In interviews, walk the ladder: var for local mutation, val by default everywhere (Kotlin style guides and lint push val-first), const val for real constants like timeouts and keys, and immutable collection types when you need the data itself frozen. Mentioning that val-by-default plus data classes is what makes Kotlin code safe to share across coroutines earns extra credit, since it connects a basic feature to the concurrency model.

val list = mutableListOf(1, 2)
list.add(3)      // allowed: val freezes the reference, not the object

var count = 0
count++          // reassignment requires var

class Session {
    // computed on every read, no backing field
    val ageMs: Long get() = System.currentTimeMillis() - startedAt

    companion object {
        const val MAX_RETRIES = 3            // inlined into call sites
        val startedAt = System.currentTimeMillis() // runtime-initialised
    }
}
💡 Pro Tip: Say "val is a read-only reference, not an immutable object" in exactly those words. It is the phrase interviewers listen for on this question.
Q3

What do data classes generate, and which properties are excluded from equals and copy?

BasicData Classes

Answer

Declaring data class User(val id: Long, val email: String) makes the compiler generate equals(), hashCode(), toString(), copy(), and componentN() functions. The rule that decides most interview follow-ups: only properties declared in the primary constructor participate. A property declared in the class body is carried by instances but ignored by equals, hashCode, toString, and copy, so two objects that differ only in a body property compare as equal.

This is a real production bug pattern in Android DiffUtil and Compose, where an ignored field silently stops triggering UI updates. copy() is shallow: it copies references, so a copied object shares any mutable nested objects with the original, and mutating one mutates both. The clean fix is to make nested types data classes with val properties too. copy() invokes the primary constructor, so init blocks and default-argument logic do run, but note a subtlety that recent Kotlin versions addressed: historically copy() was public even when the constructor was private, letting callers bypass factory-method invariants. Kotlin 2.0.20 started warning about this and added @ConsistentCopyVisibility to make copy() match the constructor's visibility.

Other constraints worth stating: data classes cannot be abstract, open, sealed, or inner, so you cannot subclass one data class from another; they can implement interfaces and extend ordinary abstract classes. In practice data classes are Kotlin's workhorse for API models, database rows, and UI state, and interviewers often close by asking why they make good map keys (stable equals/hashCode from immutable val properties) and when they make terrible ones (any var in the primary constructor).

data class User(val id: Long, val email: String) {
    var lastLogin: Long = 0   // body property: NOT in equals/hashCode/copy
}

val a = User(1, "a@x.com").apply { lastLogin = 100 }
val b = User(1, "a@x.com").apply { lastLogin = 999 }
println(a == b)                     // true: lastLogin is ignored

val renamed = a.copy(email = "b@x.com")  // shallow copy via constructor
val (id, email) = renamed                // componentN() destructuring

Key Points

  • Generated: equals, hashCode, toString, copy, componentN
  • Only primary-constructor properties participate
  • copy() is shallow and calls the primary constructor
  • Kotlin 2.0.20+ aligns copy() visibility with private constructors
  • A var in the primary constructor makes it unsafe as a map key
Q4

How do extension functions work under the hood, and why do they not override member functions?

BasicExtensions

Answer

An extension function compiles to a plain static method whose first parameter is the receiver. fun String.initials() in a file named StringExt.kt becomes a static method StringExtKt.initials(String) in bytecode, which is exactly how Java callers see it. No bytecode is injected into String itself, which explains every behavioural rule that follows. First, extensions are resolved statically against the declared (compile-time) type of the receiver, not the runtime type: if Base and Child each have an extension name(), calling name() on a variable typed Base always runs the Base extension even when the object is a Child.

There is no virtual dispatch because it is just a static method call chosen at compile time. Second, when a class has a member function with the same signature as an extension, the member always wins; you cannot shadow or patch a member with an extension, which interviewers phrase as "extensions cannot override". Third, extensions can declare a nullable receiver, fun String?.orDash(), which is callable on a null value without a safe call because null simply becomes the first argument; the stdlib's isNullOrEmpty() works this way.

Extension properties exist too but cannot have backing fields, so they must be computed from the receiver. Extensions need to be imported like any top-level function, and unresolved-reference errors from a missing import are a common on-boarding stumble. In production Kotlin, extensions are the idiomatic way to add utility behaviour to framework and SDK types you do not own (Context.dp(), String.toSlug(), Modifier extensions in Compose), keeping utility grab-bag classes out of the codebase while remaining trivially testable static functions.

// Bytecode: static StringExtKt.initials(String)
fun String.initials(): String =
    split(" ").mapNotNull { it.firstOrNull()?.uppercaseChar() }.joinToString("")

// Nullable receiver: safe to call on null
fun String?.orDash(): String = this ?: "-"

open class Base
class Child : Base()

fun Base.label() = "base"
fun Child.label() = "child"

val x: Base = Child()
println(x.label())   // "base": resolved statically on the declared type
Q5

Explain object, companion object, and data object. How do they map to static members in Java?

BasicObjects & Singletons

Answer

The object keyword declares a singleton: the class and its single instance in one declaration. Initialisation is lazy and thread-safe because it rides on JVM class loading, so object AnalyticsTracker gives you a correct singleton with zero double-checked-locking boilerplate. Objects can implement interfaces and extend classes, which makes them handy for stateless strategy implementations and default no-op listeners.

A companion object is a singleton scoped inside a class, and it is Kotlin's replacement for static members: factory methods, constants, and shared configuration live there, callable as ApiClient.create() without naming the companion. On the JVM the companion compiles to a nested class (ApiClient.Companion by default), and its members are instance members of that nested object, so Java callers must write ApiClient.Companion.create() unless you annotate the function with @JvmStatic, which generates a true static forwarding method, or expose constants with const val or @JvmField. Interviewers use this to test whether you understand that Kotlin has no static keyword and how interop is bridged. data object, stabilised in Kotlin 1.9, is an object with a generated toString() (the object's name) plus equals/hashCode, designed for singleton cases in sealed hierarchies like Loading or Reset states, so your logs print "Loading" instead of an @-hash. Two production cautions worth volunteering: an object lives for the whole process, so any Android Context or listener it holds is a memory leak by construction, and objects holding mutable state are shared across every thread and coroutine in the process, so they need the same synchronisation discipline as any global.

object AnalyticsTracker {              // lazy, thread-safe singleton
    fun track(event: String) { /* ... */ }
}

class ApiClient private constructor(val baseUrl: String) {
    companion object {
        const val DEFAULT_TIMEOUT_MS = 30_000L

        @JvmStatic                      // real static for Java callers
        fun create(url: String) = ApiClient(url)
    }
}

sealed interface Command
data class Move(val x: Int, val y: Int) : Command
data object Reset : Command            // toString() prints "Reset"
Q6

When do you use let, run, apply, also, and with? What are the rules for choosing?

BasicScope Functions

Answer

The five scope functions differ on exactly two axes: how the receiver is exposed (as it for let/also, as this for run/apply/with) and what they return (the lambda result for let/run/with, the receiver itself for apply/also). Everything else is convention. let is the null-safe transform: value?.let { ... } runs only when the value is non-null and returns whatever the lambda computes, so it is the idiom for mapping a nullable into something else. apply configures an object and hands it back, which is why builder-style setup code (Request.Builder().apply { ... }.build(), Bundle().apply { ... }) reads so cleanly: inside the block, this is the object, so you call its members directly. also performs a side effect (logging, validation, adding to a collection) and returns the receiver unchanged, making it safe to slot into a call chain without altering the result. run is apply's sibling that returns the lambda result, for computing a value from an object's members without repeating the variable name. with(x) { ... } is functionally run but as a plain function, conventionally used when the receiver is guaranteed non-null and you are grouping several calls on it. Interviewers care less about the table and more about judgement: nesting scope functions shadows it and this and is a genuine readability hazard, so name the lambda parameter explicitly when you must nest; do not use let as a substitute for a simple if (x != null) block when you are not producing a value; and do not chain four scope functions to look clever, because the code reviewer at the other end has seen that resume before.

// let: null-check + transform, returns lambda result
val domain = email?.let { it.substringAfter("@") }

// apply: configure, return the receiver
val request = Request.Builder().apply {
    url("https://api.example.com/v1/orders")
    header("Accept", "application/json")
}.build()

// also: side effect, return the receiver
val user = loadUser(id).also { logger.info("loaded user ${it.id}") }

// run: compute a result from the receiver
val summary = order.run { "$id: ${items.size} items, total=$total" }

// with: like run, but a plain function
val area = with(rect) { width * height }
💡 Pro Tip: If asked to pick one rule: let/also expose it, run/apply/with expose this; let/run/with return the result, apply/also return the receiver. Reconstruct everything else from that.
Q7

How is when more powerful than a switch statement, and what are guard conditions in Kotlin 2.1+?

BasicControl Flow

Answer

when is an expression, not just a statement: it returns a value, branches take arbitrary conditions, and there is no fallthrough, so no break keyword and no accidental cascade bugs. Branches can match constants, multiple values separated by commas, ranges (in 90..100), type checks (is String, with the subject smart-cast inside the branch), and when used without a subject, arbitrary boolean expressions, which replaces if/else-if chains. The subject-capture form, when (val state = flow.value) { ... }, declares the subject inline so the smart-cast variable is scoped to the expression.

The feature interviewers now probe as a version check is guard conditions: introduced as a preview in Kotlin 2.1 behind -Xwhen-guards and stabilised in Kotlin 2.2, a branch can add a secondary condition with if, as in is Success if response.items.isEmpty() -> ... This removes the old pattern of matching the type and then nesting another if inside the branch, and the compiler still tracks exhaustiveness on the primary subject. Exhaustiveness is the other production-relevant behaviour: a when expression over a sealed type or enum must cover every case or provide else, and the modern compiler enforces exhaustiveness for when statements over sealed types too, which turns "we added a new state and forgot a screen" from a runtime bug into a compile error. That is precisely why the sealed-class-plus-when combination is the backbone of state rendering in Android codebases: prefer listing every branch over a lazy else, because else silently swallows the new subtype you add next quarter.

fun describe(response: ApiResponse): String = when (response) {
    is ApiResponse.Success if response.items.isEmpty() -> "empty result" // guard, 2.1+
    is ApiResponse.Success -> "got ${response.items.size} items"
    is ApiResponse.Error -> "failed: HTTP ${response.code}"
}

fun bucket(score: Int) = when (score) {
    in 90..100 -> "A"
    in 75..89 -> "B"
    else -> "C"
}

when (val state = viewModel.state.value) {   // subject capture
    is UiState.Loading -> showSpinner()
    is UiState.Loaded -> render(state.data)  // smart cast on state
    is UiState.Error -> showError(state.cause)
}
Q8

What are sealed classes and sealed interfaces, and why do they pair with when for state modelling?

BasicSealed Hierarchies

Answer

A sealed class or sealed interface declares a hierarchy whose direct subtypes are all known at compile time: subclasses must live in the same package and module (relaxed from the original same-file rule in Kotlin 1.5, which also introduced sealed interfaces). Because the compiler knows the complete set of subtypes, a when expression over a sealed type is checked for exhaustiveness without an else branch. That is the entire practical payoff: model your screen state as sealed interface UiState with Loading, Success(data), and Error(cause) subtypes, render it in a when, and the day someone adds an Offline state, every non-updated when in the codebase becomes a compile error instead of a screen that silently ignores the new state.

This pattern dominates Android ViewModels, network result wrappers, and payment-flow state machines at Indian fintechs, and interviewers expect you to produce it fluently. Sealed versus enum is the standard follow-up: an enum is a fixed set of constant instances, while a sealed hierarchy is a fixed set of types whose instances carry per-instance data, so Error(cause: Throwable, retryable: Boolean) is expressible with sealed but not with enum. Sealed interfaces add flexibility over sealed classes: a subtype can implement several sealed interfaces, and even enum classes can implement a sealed interface, letting you unify constant states and data-carrying states in one exhaustive when.

Use data class for subtypes that carry data and data object (Kotlin 1.9+) for singleton states so logs print readable names. One design note that lands well: keep sealed hierarchies for closed domains you own; if third parties must add subtypes, sealed is the wrong tool by definition.

sealed interface UiState<out T> {
    data object Loading : UiState<Nothing>
    data class Success<T>(val data: T) : UiState<T>
    data class Error(val cause: Throwable, val retryable: Boolean) : UiState<Nothing>
}

fun <T> render(state: UiState<T>) = when (state) {
    UiState.Loading -> showSpinner()
    is UiState.Success -> bind(state.data)
    is UiState.Error -> showError(state.cause, state.retryable)
    // no else: a new subtype breaks compilation at every render site,
    // which is exactly the behaviour you want
}

Key Points

  • Subtypes known at compile time: same package and module
  • Exhaustive when without else; new subtypes become compile errors
  • Sealed carries per-instance data; enum is constant instances only
  • Sealed interfaces (1.5+) allow multiple inheritance, even for enums
  • Use data object for singleton states, data class for data states
Q9

Kotlin's List is read-only rather than immutable. What actually breaks because of that distinction?

BasicCollections

Answer

kotlin.collections.List declares no add or remove; MutableList adds them. Both are compiler-mapped types that erase to java.util.List at runtime, so the read-only guarantee is a compile-time contract on the interface you hold, not a property of the object. Three things follow, and interviewers chase all three.

First, a List reference can point at a MutableList that someone else still holds, so the data can change underneath you while your type says read-only; the fix is toList(), which copies. Second, Java code that receives your List sees a plain java.util.List and can call add() with no complaint, so defensive copies matter at any Java or SDK boundary. Third, casting is not a reliable escape hatch: listOf(1, 2, 3) returns a fixed-size Arrays.asList-style wrapper, so (list as MutableList).add(4) compiles and then throws UnsupportedOperationException at runtime.

Views behave the same way: subList(), asReversed(), and map-key views are live windows onto the source, not snapshots. The stdlib has no persistent immutable collections; kotlinx.collections.immutable provides PersistentList and persistentListOf() when you genuinely need structural sharing plus immutability. The most commonly hit production consequence is in Jetpack Compose: because a List parameter might be a MutableList, the Compose compiler cannot treat it as stable, so a composable taking List<Item> can recompose on every parent recomposition.

Teams fix this with ImmutableList from kotlinx.collections.immutable, an @Immutable wrapper class, or strong skipping mode in recent Compose compiler versions. Say "read-only interface, mutable object" and then name the Compose consequence; that pair is what separates a memorised answer from a used-it answer.

val mutable = mutableListOf(1, 2, 3)
val readOnly: List<Int> = mutable   // same object, narrower interface
mutable.add(4)
println(readOnly.size)              // 4: the view is live

val snapshot = mutable.toList()     // real copy, safe to hand out

val fixed = listOf(1, 2, 3)
// (fixed as MutableList<Int>).add(4)  // compiles, throws UnsupportedOperationException

// Compose stability: List<Item> is unstable, ImmutableList is stable
@Composable
fun ItemRow(items: kotlinx.collections.immutable.ImmutableList<Item>) { /* ... */ }

Key Points

  • List is a read-only interface; the underlying object may still be mutable
  • toList() copies; subList/asReversed return live views
  • Casting listOf() to MutableList throws UnsupportedOperationException
  • Java callers see java.util.List and can mutate it
  • List is unstable to the Compose compiler; ImmutableList is not
Q10

How do default and named arguments compile, and why does Java code need @JvmOverloads?

BasicFunctions & Interop

Answer

A function with default arguments compiles to one real method with the full parameter list plus a synthetic bridge named fun$default that takes an extra int bitmask indicating which parameters the caller omitted. Kotlin call sites go through the bridge, which fills in the missing defaults and delegates. Java has no notion of that bitmask, so from Java you only see the full-arity method and must pass every argument.

Annotating with @JvmOverloads makes the compiler emit a real overload per trailing default, which is why every Kotlin custom View on Android is written as class Chip @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0): the Android inflater calls the two-argument constructor reflectively and would otherwise crash with a NoSuchMethodException. Named arguments let you pass parameters out of order and skip middle defaults, and they are the readability fix for boolean-parameter soup (retry(force = true, silent = false) instead of retry(true, false)). Defaults are evaluated at the call site, left to right, and a later default may reference an earlier parameter, so fun page(limit: Int = 20, offset: Int = limit * 0) is legal.

Two gotchas worth volunteering. Overriding functions may not redeclare default values: the default is inherited from the base declaration, and specifying one is a compile error, because dispatch happens on the base signature. And adding a new defaulted parameter is source-compatible for Kotlin but binary-incompatible for anything already compiled against the old signature, including Java callers, reflection, and separately published library modules, so libraries usually pair defaults with @JvmOverloads and treat signature changes as breaking.

class ChipView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr)

fun fetch(
    url: String,
    timeoutMs: Long = 30_000,
    retries: Int = 3,
    logTag: String = url.substringAfterLast('/'),
) { /* ... */ }

fetch("https://api.example.com/orders", retries = 5)  // skip the middle default

// Bytecode: fetch(String, long, int, String)
//           fetch$default(String, long, int, String, int mask, Object)
💡 Pro Tip: If the role is Android, mention the @JvmOverloads custom-View constructor by name. It is the single most common real use of the annotation and signals hands-on work.
Q11

How do string templates, raw strings, and trimIndent behave, and how does Kotlin compile string concatenation?

BasicStrings

Answer

String templates interpolate with $name for a simple identifier and ${expression} for anything else, including calls and index access. To emit a literal dollar sign inside a template you either escape it as \$ in an escaped string or, in a raw string where backslash escapes do not work, write ${'$'}, which is the classic ugly line in Kotlin code that generates shell scripts or Gradle files. Recent Kotlin versions added multi-dollar interpolation, so you can open a string with $$ and then only $$name interpolates while a bare $ stays literal, which makes JSON and template files far cleaner.

Raw strings are delimited by triple quotes, span lines, and perform no escaping, which is why they are the right choice for regex patterns, SQL, and JSON fixtures. They preserve your source indentation, so you almost always chain trimIndent(), which finds the minimal common indent across non-blank lines, removes it, and drops the leading and trailing blank lines. trimMargin() is the alternative when you want explicit control: every line is trimmed up to and including a prefix character, | by default. On the compilation side, for JVM target 9 and above the compiler uses invokedynamic string concatenation (the JDK's StringConcatFactory) rather than emitting StringBuilder chains, which the JIT can shape better; you can force the old behaviour with -Xstring-concat=inline if you are chasing a specific bytecode issue.

Inside loops, buildString { } is still the right tool because it gives you one StringBuilder for the whole loop instead of one concat per iteration. Interviewers occasionally end here with a logging point: templates evaluate eagerly, so log.debug("$expensiveThing") pays the cost even when debug logging is off, which is what lambda-taking log APIs exist to avoid.

val name = "Priya"
val greeting = "Hi $name, you have ${orders.size} orders"

val sql = """
    SELECT id, email
    FROM users
    WHERE created_at > ?
""".trimIndent()

val script = """
    |export TOKEN=${'$'}API_TOKEN
    |echo "deploying ${'$'}{VERSION}"
""".trimMargin()

val csv = buildString {          // one StringBuilder, not N concats
    rows.forEach { append(it.id).append(',').append(it.email).append('\n') }
}
Q12

When does a smart cast fail to apply, and what do you do about it?

BasicType System

Answer

After a check like if (x is Order) or if (s != null), the compiler smart-casts x inside that scope so you can use it without an explicit as. The cast only applies when the compiler can prove the value cannot change between the check and the use, so it refuses in a predictable list of cases: a var property (another thread or another method could reassign it), any property with a custom getter (two reads can return different values), an open property (a subclass override could change behaviour), a property declared in another module (that module can be recompiled independently), a var local captured and modified by a lambda, and delegated properties. The error message is the memorable one to quote: "smart cast to 'String' is impossible, because 'x' is a mutable property that could have been changed by this time".

The idiomatic fix is a local val copy, val order = this.order, then check and use the local; the compiler owns the local so it can prove stability. Alternatives are the safe cast operator as? paired with Elvis, a when subject capture, when (val s = state), or value?.let { }, which all bind an immutable local. The K2 compiler that shipped with Kotlin 2.0 widened smart casts noticeably: it propagates type information out of local variables that hold the check result, handles more cases inside lambdas after the last assignment, and unifies types across the branches of a logical or, so some code that needed an explicit cast on 1.9 compiles clean on 2.x. Also note that as on a generic type is an unchecked cast because of erasure, and the compiler only warns; the ClassCastException then surfaces at the first real use, far from the cast.

class Screen {
    var state: UiState? = null

    fun render() {
        // if (state is UiState.Loaded) bind(state.data)
        //   error: smart cast to 'UiState.Loaded' is impossible,
        //   because 'state' is a mutable property

        val snapshot = state              // stable local
        if (snapshot is UiState.Loaded) bind(snapshot.data)  // smart cast works

        (state as? UiState.Error)?.let { showError(it.cause) }
    }
}
💡 Pro Tip: Rehearse the phrase "the compiler cannot prove the value did not change between the check and the use". It answers every variant of this question in one sentence.
Q13

What are Any, Unit, and Nothing, and where does Nothing show up in real code?

BasicType System

Answer

Any is the root of the non-nullable type hierarchy, the analogue of java.lang.Object but with only equals(), hashCode(), and toString(). The legacy Object methods wait(), notify(), and getClass() are deliberately absent, which is why you write javaClass or ::class instead. Any? sits above Any and is the only type that can hold null in a fully general way.

Unit is a real singleton object returned by functions that produce no meaningful value; a function declared without a return type returns Unit, and a lambda whose expected type returns Unit performs coercion, so the last expression's value is discarded rather than causing a type error. That coercion is why a forEach lambda can end with an expression of any type. Nothing is the interesting one: it has no instances and is a subtype of every type, which lets the compiler use it as the type of expressions that never complete normally. throw is an expression of type Nothing, TODO() returns Nothing, and so does a helper like fun fail(msg: String): Nothing = throw IllegalStateException(msg).

Because Nothing is a subtype of everything, such a helper slots into the right-hand side of Elvis (val id = user.id ?: fail("missing id")) and into a when branch without breaking type inference, and the compiler marks the following code unreachable. Nothing also appears in generic positions: emptyList() is a List<Nothing>, and a sealed UiState<out T> declares its Loading and Error singletons as UiState<Nothing> so they are assignable wherever any UiState<T> is expected. The nullable form Nothing? is inhabited by exactly one value, null, which is the inferred type of a bare null literal. Java interop maps Unit and Nothing returns to void and to a method the compiler treats as never returning.

fun fail(message: String): Nothing = throw IllegalStateException(message)

val userId = payload.userId ?: fail("userId missing in webhook payload")
// userId is String here; the compiler knows fail() never returns

sealed interface UiState<out T> {
    data object Loading : UiState<Nothing>          // assignable to UiState<Order>
    data class Loaded<T>(val data: T) : UiState<T>
}

val anything: Any = 42
println(anything.javaClass.name)   // no getClass(): Any has only equals/hashCode/toString
Q14

What is the difference between == and === in Kotlin, and which equality edge cases bite in production?

BasicEquality

Answer

== is structural equality and compiles to a null-safe equals call: a == b becomes a?.equals(b) ?: (b === null), so unlike Java you never write an NPE-prone .equals() on a nullable and never need Objects.equals(). === is referential identity, true only when both operands point at the same object. For primitives that are not boxed, === is not allowed as a meaningful distinction and the compiler treats identity as value equality. The classic interview trap is boxing: assign the same small number to two Int? variables and === is true because the JVM's Integer cache covers minus 128 to 127, but do it with 128 and === is false while == stays true.

Never use === for value comparison, and be suspicious of any identity check on boxed numbers. The second edge case is floating point, and it is genuinely Kotlin-specific. When the compiler statically knows both operands are Double or Float it emits IEEE 754 semantics: Double.NaN == Double.NaN is false and 0.0 == minus 0.0 is true.

When the same values are compared through Any, a generic type parameter, or Comparable, Kotlin switches to a total ordering in which NaN equals itself and minus 0.0 sorts below 0.0, so listOf(Double.NaN).contains(Double.NaN) returns true while a direct == returns false. Third, arrays use identity equals, so data classes holding a ByteArray compare by reference; use contentEquals or override equals and hashCode by hand. Fourth, if you override equals you must override hashCode with a consistent implementation, otherwise HashMap and HashSet lookups silently miss, which is exactly how caches start returning duplicates.

val a: Int? = 127
val b: Int? = 127
println(a == b)   // true
println(a === b)  // true: inside the Integer cache

val c: Int? = 128
val d: Int? = 128
println(c == d)   // true
println(c === d)  // false: two boxed objects

println(Double.NaN == Double.NaN)              // false: IEEE semantics
println(listOf(Double.NaN).contains(Double.NaN)) // true: total ordering

data class Payload(val bytes: ByteArray)       // equals compares references
println(Payload(byteArrayOf(1)) == Payload(byteArrayOf(1)))  // false

Key Points

  • == is null-safe equals; === is reference identity
  • Integer cache (-128..127) makes === misleadingly true for small boxed Ints
  • Double/Float use IEEE semantics statically, total ordering when boxed
  • Arrays compare by reference; use contentEquals
  • Overriding equals without hashCode breaks HashMap lookups
Q15

Walk through Kotlin's initialisation order: primary constructor, init blocks, property initialisers, and secondary constructors.

BasicClasses & Initialisation

Answer

Primary-constructor parameters are available to property initialisers and init blocks. Those two run in the order they appear in the class body, interleaved, which matters because an init block cannot read a property declared below it (the compiler flags it, or it reads a default zero value in the corner cases it cannot detect). Secondary constructors must delegate, either to the primary constructor with this(...) or, when there is no primary, to the superclass with super(...); the primary constructor's initialisation always completes before the secondary constructor's own body runs.

Across the inheritance chain, the base class constructor and all of its init blocks and property initialisers finish before the subclass's do, which produces the single most-asked bug in this area: calling an open function or reading an open property from a base-class constructor or init block executes the subclass override at a moment when the subclass's own properties have not been assigned. The override then observes null in a property whose declared type is non-null, and you get an NPE that no amount of reading the subclass explains. The rules to state: never call open members from constructors or initialisers, mark such helpers private or final, and if a subclass really must contribute initialisation, do it lazily with by lazy or through an explicit init() method the caller invokes.

Related tools are lateinit var for dependencies that arrive after construction (Android views and injected fields) and by lazy for values that are expensive and may never be needed. Interviewers sometimes ask this as a puzzle where you predict printed output, so practise reading a class top to bottom and narrating the order out loud.

open class Base {
    init { render() }                       // runs BEFORE Child's initialisers
    open fun render() = println("base")
}

class Child(private val label: String) : Base() {
    private val prefix: String = "["        // not yet assigned when render() fires
    override fun render() = println(prefix + label)  // NPE on prefix
}

class Order(val id: Long, val items: List<Item>) {
    val total: Int = items.sumOf { it.price }   // 1
    init { require(id > 0) { "bad id $id" } }   // 2
    val label: String = "#$id ($total)"         // 3

    constructor(id: Long) : this(id, emptyList())  // primary runs first
}
Q16

Explain Kotlin's visibility modifiers, including what internal compiles to and how protected differs from Java.

BasicVisibility & Modules

Answer

Kotlin has four modifiers and the default is public, unlike Java's package-private default. private on a class member limits it to the class; on a top-level declaration it limits it to the file, which is Kotlin's replacement for package-private and the reason file-scoped helpers are so common. protected means visible to the class and its subclasses only; it does not include the package, so Java code in the same package cannot see a Kotlin protected member, and protected is not allowed on top-level declarations. internal means visible everywhere inside the same module, where a module is a compilation unit: a Gradle source set, an IntelliJ module, or a Maven compilation. That definition has consequences people get wrong. Two Gradle modules in the same repo do not share internal visibility, but the test compilation of a module does see the main compilation's internal declarations because the Kotlin Gradle plugin associates them, which is why internal is the usual visibility for things you want testable but not public API.

In bytecode, internal members are public with a mangled name that appends the module name (transform$app_debug), so Java in the same package can technically reach them but the mangled identifier makes accidental use unlikely, and internal classes are simply public classes because the JVM has no equivalent concept. Library authors pair this with explicit API mode, enabled with explicitApi() in the Kotlin extension of build.gradle.kts, which makes the compiler require an explicit visibility modifier and an explicit return type on every public declaration, so nothing leaks into your published surface by accident. Android teams use the same setting on feature modules to keep implementation details from being imported across module boundaries.

// build.gradle.kts
kotlin {
    explicitApi()   // public declarations must state visibility and return type
}

internal class OrderMapper {              // public class in bytecode, module-scoped in Kotlin
    internal fun map(row: Row): Order = /* ... */
    // bytecode name: map$feature_orders_debug
}

private fun formatAmount(paise: Long): String =  // visible only in this file
    "₹" + (paise / 100)

open class Repo {
    protected open fun cacheKey(id: Long) = "repo:$id"  // subclasses only, not the package
}
Q17

lateinit var versus by lazy: what does each cost, and when does each one leak?

BasicProperties & Delegates

Answer

lateinit var is a promise to the compiler that you will assign a non-null value before any read. It works only on var, only on non-null non-primitive types, and only when the property has no custom accessors. Reading it before assignment throws UninitializedPropertyAccessException with a message naming the property, which is much easier to debug than a bare NPE, and you can check state with the isInitialized reference (this::view.isInitialized), which is only accessible from a scope that can see the backing field.

There is no synchronisation and no wrapper object, so the runtime cost is a null check on read. It is the right tool for dependencies that arrive after construction: fields injected by Dagger or Hilt, views bound in onCreate, and test subjects created in @Before. by lazy is different in every respect: it applies to val, computes the value on first access via a delegate object, caches it, and returns the cached value forever after. The mode argument matters in interviews.

The default LazyThreadSafetyMode.SYNCHRONIZED double-checks a lock so exactly one thread runs the initialiser. PUBLICATION allows concurrent initialisers and keeps the first result to win. NONE skips synchronisation entirely and is only correct when access is confined to one thread, which is the common Android case for main-thread-only properties and is worth naming because it removes the lock.

The leak stories are the practical payoff: an object or singleton holding a lateinit Activity or View reference pins it for the process lifetime, and a lazy property in a Fragment is computed once for the Fragment instance while the view is recreated on every configuration change, so a lazy holding a view or binding keeps the destroyed view alive. Use lazy for pure derived values, not for anything tied to a lifecycle.

class ProfileFragment : Fragment() {
    @Inject lateinit var repo: ProfileRepository   // assigned by Hilt before onCreate

    // main-thread only: skip the lock
    private val formatter by lazy(LazyThreadSafetyMode.NONE) {
        NumberFormat.getCurrencyInstance(Locale("en", "IN"))
    }

    // WRONG: survives view recreation and leaks the destroyed view
    // private val header by lazy { requireView().findViewById<TextView>(R.id.header) }

    fun onRetry() {
        if (::repo.isInitialized) repo.refresh()
    }
}

Key Points

  • lateinit: var, non-null, non-primitive, no custom accessors, throws UninitializedPropertyAccessException
  • ::prop.isInitialized checks state from inside the declaring scope
  • lazy: val, cached after first access, SYNCHRONIZED by default
  • LazyThreadSafetyMode.NONE removes the lock for single-threaded access
  • lazy on a Fragment view reference outlives the view and leaks it
Q18

How do ranges and progressions work in Kotlin loops, and when do they allocate?

BasicRanges & Loops

Answer

0..n is an IntRange (inclusive on both ends), 0 until n and the newer 0..<n operator, stabilised in Kotlin 1.9, are exclusive at the top, n downTo 0 counts backwards, and step k changes the stride. All of these are IntProgression objects with first, last, and step properties, and last is normalised so that a progression like 1..10 step 3 ends at 10 rather than 13. Ranges also work with Long, Char, and any Comparable through the in operator, so 'a'..'z' and startDate..endDate are valid, though only the primitive ones give you iteration.

The performance detail interviewers like is that for (i in 0 until n) does not allocate: the compiler recognises the pattern and lowers it to an ordinary indexed loop with no IntRange object and no Iterator, exactly the bytecode a Java for loop produces. That optimisation disappears the moment the range escapes the loop header. Assign it to a variable typed Iterable, pass it to a function, or attach functional operators such as (0 until n).map { }, and you get a real object plus boxed Integers from the iterator.

In hot code, that is the difference between zero allocation and one allocation per element. The everyday helpers built on ranges are indices (0 until size, for indexing), lastIndex, withIndex() for an index-value pair without a manual counter, and repeat(n) { } for a plain loop where the counter is incidental. Two behaviours to remember: step must be positive even when counting down, because direction comes from downTo, and an empty range like 5..1 iterates zero times rather than throwing, which quietly hides an off-by-one when your bounds are computed.

for (i in 0 until items.size) { /* no allocation: lowered to an index loop */ }
for (i in items.indices) { /* same */ }
for (i in 10 downTo 1 step 3) print("$i ")   // 10 7 4 1
for ((index, item) in items.withIndex()) { /* ... */ }

val r = 1..10 step 3
println(r.last)          // 10, normalised (not 13)

val evens = (0..<n).filter { it % 2 == 0 }   // allocates a range + iterator + list

val isWeekend = day in DayOfWeek.SATURDAY..DayOfWeek.SUNDAY  // Comparable range
Q19

What does the suspend keyword actually compile to, and why is a coroutine not a thread?

IntermediateCoroutines Internals

Answer

suspend is a compiler instruction, not a runtime primitive. The compiler applies a continuation-passing-style transform: a function declared suspend fun load(id: Long): Order compiles to load(long, Continuation) returning Object, and the body is rewritten into a state machine. Each suspension point becomes a labelled state; the generated class stores the local variables it needs across that point in fields, and when the function suspends it returns the marker object COROUTINE_SUSPENDED instead of a value.

Later, whatever completes the work (a network callback, a timer, a dispatcher) calls resumeWith on the continuation, the state machine re-enters at the saved label, restores the locals, and continues. Nothing blocks. That is why a coroutine is not a thread: it is a resumable computation plus a small heap object, so tens of thousands of them run happily on a pool of a few dozen threads, while the same number of threads would exhaust memory at roughly a megabyte of stack each.

Interviewers use this to test three derived facts. First, suspend functions can only be called from another suspend function or from a coroutine builder, because the caller must supply the continuation. Second, delay(1000) suspends without holding a thread, whereas Thread.sleep(1000) blocks the underlying worker and is the number one cause of a coroutine codebase running slower than the callbacks it replaced.

Third, suspend by itself provides no concurrency and no thread switch: a suspend function runs on whatever dispatcher its caller is on, so marking a CPU-heavy or blocking JDBC call as suspend does not make it safe; it needs withContext(Dispatchers.IO) or Dispatchers.Default. A good answer names the state machine, the Continuation interface, and COROUTINE_SUSPENDED explicitly, then closes with the suspend-is-not-a-thread-switch point.

// What you write
suspend fun load(id: Long): Order {
    val row = repo.fetch(id)          // suspension point 1
    val price = pricing.quote(row)    // suspension point 2
    return Order(row, price)
}

// Roughly what the compiler emits
// Object load(long id, Continuation<Order> cont) {
//     switch (state) {
//         case 0: state = 1; result = repo.fetch(id, this);
//                 if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
//         case 1: row = result; state = 2; result = pricing.quote(row, this);
//                 if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
//         case 2: return new Order(row, result);
//     }
// }
💡 Pro Tip: Never say "suspend runs the function on a background thread". It is the fastest way to fail a coroutines round.
Q20

Compare launch, async, and runBlocking. When is each one the wrong choice?

IntermediateCoroutine Builders

Answer

launch is fire-and-forget: it starts a coroutine and returns a Job, which you can join(), cancel(), or inspect. Its lambda returns Unit, so any value it computes is discarded, and any exception it throws propagates immediately to the parent and then to the CoroutineExceptionHandler. async is for concurrent decomposition: it returns a Deferred<T>, a Job that also carries a result, and you call await() to get the value. The rule for choosing is simple: if you need a value back, use async; otherwise use launch.

Running two independent network calls in parallel is the canonical async pattern, val a = async { userApi() }; val b = async { ordersApi() }; then a.await() plus b.await(). Calling await() immediately after each async, rather than starting both first, silently serialises them and is a common trap in take-home reviews. async also holds its exception until await(), which is why try/catch placement is examined so often. runBlocking is the bridge from blocking code into suspending code: it blocks the calling thread until the coroutine completes. It belongs in main() functions, JVM CLI entry points, and legacy call sites you cannot yet make suspending.

It does not belong in Android production code, in a ViewModel, or anywhere on the main thread, because it defeats the entire point of coroutines and produces ANRs. In tests it has largely been replaced by runTest from kotlinx-coroutines-test, which gives you virtual time so delay() completes instantly. The final layer interviewers probe is where these builders live: launch and async are extension functions on CoroutineScope, so you should be calling them on a scoped receiver such as viewModelScope or a scope you own, not on GlobalScope, which has no lifecycle and produces work nobody can cancel.

// Concurrent: both requests are in flight before either await()
suspend fun loadDashboard(): Dashboard = coroutineScope {
    val profile = async { api.profile() }
    val orders  = async { api.orders() }
    Dashboard(profile.await(), orders.await())
}

// Accidentally sequential: await() before the second async starts
// val p = async { api.profile() }.await()
// val o = async { api.orders() }.await()

class CartViewModel : ViewModel() {
    fun refresh() = viewModelScope.launch {   // fire and forget, lifecycle-scoped
        _state.value = runCatching { repo.load() }.fold(::Loaded, ::Failed)
    }
}
Q21

Explain structured concurrency. Why does a try/catch around async fail to catch the exception?

IntermediateStructured Concurrency

Answer

Structured concurrency means every coroutine has a parent, a parent does not complete until all its children complete, cancelling a parent cancels all children, and a failing child cancels the parent. That is enforced by the Job hierarchy: coroutineScope { } creates a scope whose Job is a child of the caller's, suspends until every coroutine started inside finishes, and rethrows the first failure after cancelling the siblings. The practical benefit is no leaked work: when a ViewModel is cleared, viewModelScope cancels, and every request, retry loop, and timer underneath it stops.

The try/catch puzzle is the standard follow-up. Wrapping async in try/catch does not work because the exception is delivered twice by two different mechanisms. await() rethrows it at the await call site, which your catch can see, but async also immediately notifies its parent Job of the failure, and that parent-directed propagation happens the moment the child fails, regardless of whether anyone ever calls await(). So the enclosing scope cancels and the exception surfaces there too, escaping the try block that only guarded await().

The correct patterns are to catch inside the async lambda so the failure never reaches the parent, to use supervisorScope when children should fail independently, or to model failure as a value with runCatching inside the block. supervisorScope is the other half of the answer: it installs a SupervisorJob so failure travels downwards only, a failing child does not cancel its siblings, and each child needs its own handler. Use coroutineScope for all-or-nothing work such as a screen that cannot render with partial data, and supervisorScope for independent widgets where one failed API should not blank the whole page. Interviewers also expect you to say that CancellationException is special: it is treated as normal cooperative cancellation and does not trigger the failure path.

// Does NOT work: the failure also goes straight to the parent Job
suspend fun broken() = coroutineScope {
    val d = async { error("boom") }
    try { d.await() } catch (e: Exception) { "handled" }  // scope still cancels
}

// Works: contain the failure inside the child
suspend fun fixed() = coroutineScope {
    val d = async { runCatching { risky() } }
    d.await().getOrElse { Fallback }
}

// Independent children: one failure must not blank the others
suspend fun widgets() = supervisorScope {
    launch { runCatching { feed() } }
    launch { runCatching { banners() } }
}

Key Points

  • Parent waits for children; cancelling a parent cancels all children
  • async reports failure to the parent Job immediately, not only at await()
  • Catch inside the async block, or use supervisorScope
  • coroutineScope = all or nothing; supervisorScope = independent children
  • CancellationException is normal completion, not a failure
Q22

What do Dispatchers.Main, IO, Default, and Unconfined do, and how does limitedParallelism change the IO pool?

IntermediateDispatchers

Answer

A dispatcher decides which thread a coroutine resumes on. Dispatchers.Default is backed by a shared pool sized to the number of CPU cores (minimum two) and is for CPU-bound work: parsing, sorting, image maths, JSON transforms. Dispatchers.IO is for blocking calls (file IO, JDBC, legacy synchronous SDKs) and defaults to 64 threads or the core count if higher, tunable with the kotlinx.coroutines.io.parallelism system property.

The detail that impresses is that IO and Default share the same underlying thread pool: switching between them with withContext usually does not park a thread or hand off work, it just changes the parallelism limit the scheduler applies, which makes the switch cheap. Dispatchers.Main is provided by a platform integration (the Android artifact installs a handler-backed main dispatcher; in tests you swap it with Dispatchers.setMain). Dispatchers.Unconfined starts the coroutine in the caller's thread and resumes it in whatever thread the suspending function completed on, which makes it useful in a handful of tests and almost never correct in production. limitedParallelism(n) is the modern replacement for creating your own executor: Dispatchers.IO.limitedParallelism(4) returns a view of IO that lets at most four of your coroutines run concurrently while still drawing from the shared pool, so you can bound access to a database connection pool or a rate-limited vendor API without spawning threads you then have to shut down.

Recent kotlinx.coroutines versions also let you name such a view for diagnostics. The production rules to state: never block on Dispatchers.Main, never do blocking IO on Default (you starve CPU work), put the withContext inside the repository function rather than at every call site so callers are main-safe by default, and remember that withContext is a suspension point, so wrapping a tight loop of tiny operations in it costs more than it saves.

class OrderRepository(private val db: JdbcTemplate) {
    // main-safe by construction: callers never need to know
    suspend fun load(id: Long): Order = withContext(Dispatchers.IO) {
        db.queryForObject("SELECT * FROM orders WHERE id = ?", mapper, id)
    }

    suspend fun summarise(rows: List<Row>): Report = withContext(Dispatchers.Default) {
        rows.groupBy { it.city }.mapValues { it.value.sumOf(Row::amount) }.toReport()
    }
}

// Bound concurrency against a vendor API without owning a thread pool
private val vendorDispatcher = Dispatchers.IO.limitedParallelism(4)
Q23

Cancellation in coroutines is cooperative. What does that mean, and how do you write cancellable code?

IntermediateCancellation

Answer

Cancelling a Job does not stop code; it flips the job's state to cancelling and arranges for the next suspension point to throw CancellationException. Every suspending function in kotlinx.coroutines checks this, so delay(), withContext(), join(), and Flow collection all cancel promptly. A coroutine that never suspends never notices.

A while (true) loop doing CPU work runs to completion after cancel(), which is why a screen you navigated away from can keep burning battery. Making code cooperative means either suspending periodically or checking explicitly: yield() suspends and throws if cancelled, ensureActive() throws without suspending, and isActive lets you exit a loop with your own logic. Cleanup is the second half.

Because cancellation surfaces as an exception, try/finally works for releasing resources, but any suspending call inside that finally block will itself throw immediately because the job is already cancelling; wrap the cleanup in withContext(NonCancellable) when it must complete, for example flushing an analytics event or closing a transaction. Never swallow CancellationException: a bare catch (e: Exception) around suspending code catches it and breaks the cancellation chain so parents never learn the child stopped, which is the most common structured-concurrency bug in review. Rethrow it explicitly, or catch the specific exception you expect.

The same trap hides inside runCatching, which catches Throwable. For blocking third-party work that cannot check a flag, wrap it in suspendCancellableCoroutine and use invokeOnCancellation to cancel the underlying call (an OkHttp Call, a Retrofit request, a JDBC statement), which is exactly what the adapter libraries do internally. Finally, know that cancellation is a request with a timing gap: withTimeout throws TimeoutCancellationException at the next suspension point, so a blocking operation that ignores interrupts will overrun its timeout.

val job = scope.launch {
    try {
        while (isActive) {          // cooperative: exits on cancel
            val frame = decodeNext()
            ensureActive()          // throws immediately if cancelled
            render(frame)
        }
    } finally {
        withContext(NonCancellable) { flushAnalytics() }  // must complete
    }
}

// Never do this: it swallows CancellationException
// try { load() } catch (e: Exception) { log(e) }
try {
    load()
} catch (e: CancellationException) {
    throw e
} catch (e: IOException) {
    log(e)
}
💡 Pro Tip: The line interviewers wait for: "cancellation only takes effect at a suspension point, so CPU loops must check isActive or call ensureActive()".
Q24

How do exceptions propagate through coroutines, and where does CoroutineExceptionHandler actually run?

IntermediateError Handling

Answer

An uncaught exception in a launch coroutine cancels its Job, propagates up to the parent, cancels the parent and therefore all sibling coroutines, and keeps going until it reaches the root of the hierarchy. Only at the root is it handed to a CoroutineExceptionHandler if one is installed in the context, otherwise it goes to the platform default (Thread.uncaughtExceptionHandler on the JVM, which crashes the app on Android). This is why installing a handler on a child coroutine does nothing: handlers are consulted only at the root of the tree, so scope.launch(handler) works while scope.launch { launch(handler) { } } does not. async behaves differently: the exception is stored in the Deferred and rethrown at await(), but it is also propagated to the parent unless the parent job is a SupervisorJob, which is the source of the classic try/catch confusion.

SupervisorJob changes the direction of failure: children fail independently and do not cancel siblings or the parent, so a scope built as CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) is the right shape for a long-lived UI or service scope where one failed request must not kill the rest. supervisorScope is the suspending, scoped equivalent. Two further rules matter in production. CancellationException is invisible to this machinery: it means normal cooperative shutdown, not failure, and is neither reported to the handler nor treated as a child failure.

And when several children fail, the first exception wins and the rest are attached as suppressed exceptions, so read the suppressed array when debugging. Practically, most teams catch at the boundary they care about, usually inside the launch block in a ViewModel, convert the failure into a UI state, and keep a handler installed at the scope root purely to log unexpected escapes rather than to control flow.

val handler = CoroutineExceptionHandler { ctx, e ->
    Log.e("scope", "unhandled in ${ctx[CoroutineName]?.name}", e)
}

// Handler is honoured: installed at the root coroutine
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler)
appScope.launch { throw IOException("sync failed") }   // logged, scope survives

// Handler ignored: it is on a child, not the root
appScope.launch {
    launch(handler) { throw IOException("still goes to the parent") }
}

// Everyday shape: convert failure into UI state at the boundary
viewModelScope.launch {
    _state.value = try { Loaded(repo.load()) }
                   catch (e: CancellationException) { throw e }
                   catch (e: Exception) { Failed(e) }
}
Q25

What makes a Flow cold, and what does flowOn actually change about the collection context?

IntermediateFlow

Answer

A Flow built with the flow { } builder is cold: the block does not run until a terminal operator such as collect(), first(), or toList() subscribes, and it runs again from scratch for every collector. Two collectors mean two network calls. This is the opposite of a hot stream like StateFlow or a Channel, which produces values whether anyone is listening or not.

Cold flows are also sequential by default: emissions happen on the collector's coroutine, so a slow collector slows the producer, and everything shares one coroutine unless you add buffering. Flow enforces context preservation: the flow builder may not emit from a different coroutine context than the one collect was called in, and violating it throws IllegalStateException with the message "Flow invariant is violated". That is precisely why withContext inside a flow { } block around an emit() is illegal and flowOn exists. flowOn(Dispatchers.IO) changes the context of everything upstream of it, leaving the downstream operators and the collector on the collector's own context.

Because upstream and downstream now run in different coroutines, flowOn inserts a channel between them with a default buffer, which also gives you concurrency between producer and consumer for free. Placement matters: operators written after flowOn are unaffected, so map { } .flowOn(IO) puts the map on IO while .flowOn(IO).map { } does not. For callback-based sources you use callbackFlow, which allows emission from other threads through a channel and requires awaitClose { } to unregister the listener when collection stops; forgetting awaitClose leaks the listener and throws in debug builds.

Other everyday builders are flowOf, asFlow on collections, and channelFlow when you need concurrent emitters. On the terminal side, the collector's own scope controls the lifetime, so a flow collected in viewModelScope stops when the ViewModel clears.

fun searchResults(query: String): Flow<List<Item>> = flow {
    emit(cache.read(query))          // runs per collector, on subscribe
    emit(api.search(query))
}.flowOn(Dispatchers.IO)             // upstream on IO, collector stays where it is

// Callback source: emission from another thread + mandatory cleanup
fun locationUpdates(client: LocationClient): Flow<Location> = callbackFlow {
    val listener = LocationListener { trySend(it) }
    client.register(listener)
    awaitClose { client.unregister(listener) }   // omit this and the listener leaks
}

// Illegal: withContext around emit throws "Flow invariant is violated"
// flow { withContext(Dispatchers.IO) { emit(load()) } }
Q26

StateFlow, SharedFlow, and LiveData: how do you choose, and what emissions get dropped?

IntermediateFlow

Answer

StateFlow is a hot flow that always holds exactly one current value, exposed as .value, and every new collector immediately receives that value. It is conflated, so a fast producer's intermediate values are dropped and slow collectors see only the latest, and it is equality-based distinct: setting the same value twice, or a value that is equals() to the current one, emits nothing at all. That last property causes the most production bugs.

If your state is a data class, updating a field that is not in the primary constructor produces an equal object and the UI never updates; if your state contains a mutable list you mutate in place, the reference and the equality are unchanged and again nothing emits. The fix is immutable state objects updated with copy(), and MutableStateFlow.update { } for atomic read-modify-write rather than a racy value = value.copy(). StateFlow is also a poor fit for one-shot events (show a toast, navigate), because it replays the last value to every new collector and your snackbar fires again after a rotation.

SharedFlow is the configurable hot flow: replay controls how many past values new collectors get (zero for events), extraBufferCapacity adds room beyond replay, and onBufferOverflow chooses SUSPEND (the default, which applies backpressure to emit), DROP_OLDEST, or DROP_LATEST. tryEmit only succeeds when there is buffer space, so a SharedFlow with replay 0 and no extra buffer silently drops tryEmit calls when nobody is collecting. Against LiveData, the Flow types win on operators, testability, and being platform-independent (they work in a KMP shared module), while LiveData's one advantage, lifecycle awareness, is covered by collecting with repeatOnLifecycle or collectAsStateWithLifecycle in Compose. New Android code uses StateFlow for state and SharedFlow or a Channel for events.

private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()

// atomic and safe under concurrent updates
_state.update { it.copy(isLoading = true) }

// WRONG: mutating in place leaves the reference (and equals) unchanged
// _state.value.items.add(item)   // no emission, UI never updates

// one-shot events: no replay, so rotation does not re-fire them
private val _events = MutableSharedFlow<UiEvent>(
    replay = 0,
    extraBufferCapacity = 1,
    onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val events: SharedFlow<UiEvent> = _events.asSharedFlow()

Key Points

  • StateFlow: always one value, conflated, equality-based distinct
  • Equal state objects emit nothing; use copy() and update { }
  • SharedFlow: replay, extraBufferCapacity, onBufferOverflow control drops
  • tryEmit silently fails when the buffer is full
  • StateFlow for state, SharedFlow or Channel for one-shot events
Q27

Explain flatMapLatest, flatMapMerge, and flatMapConcat, and when combine beats zip.

IntermediateFlow Operators

Answer

The three flatMap variants all take a flow of values, map each value to a new flow, and flatten the results; they differ in what happens when a new upstream value arrives while the previous inner flow is still running. flatMapConcat waits: inner flows run one after another in order, which is what you want for sequential dependent requests. flatMapMerge runs them concurrently up to a concurrency limit (default 16, changeable via the parameter), so results interleave and ordering is not guaranteed; use it for fan-out where you want throughput. flatMapLatest cancels the previous inner flow the moment a new value arrives, which makes it the correct operator for search-as-you-type, filter changes, and any case where a stale in-flight request should be abandoned. The idiomatic Android search pipeline is a query StateFlow, then debounce, then distinctUntilChanged, then flatMapLatest into the repository call, and interviewers frequently ask you to write exactly that. mapLatest and collectLatest are the same cancel-the-previous semantics for non-flow bodies. combine versus zip is the other half of this question. zip pairs values positionally: it waits for one new value from each flow before emitting, so it stops at the shorter flow and is right for pairing two streams that advance together. combine emits every time either flow produces a value, using the latest value from the other, and only starts once both have produced at least one value, which makes it the right tool for deriving UI state from several independent sources such as a filter selection plus a data stream plus a connectivity flag. The gotcha with combine is that it fires on every upstream change and can produce intermediate states you did not intend, so pair it with distinctUntilChanged or conflate when downstream work is expensive.

// search-as-you-type: debounce, dedupe, cancel stale requests
val results: StateFlow<List<Item>> = queryFlow
    .debounce(300)
    .distinctUntilChanged()
    .filter { it.length >= 2 }
    .flatMapLatest { q -> repo.search(q) }   // cancels the previous search
    .catch { emit(emptyList()) }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())

// combine: re-emit whenever ANY source changes
val screen = combine(user, orders, isOnline) { u, o, online ->
    ScreenState(u, o, offlineBanner = !online)
}

// zip: strictly pairwise, stops at the shorter flow
val pairs = idsFlow.zip(namesFlow) { id, name -> id to name }
Q28

How does backpressure work in Flow, and what do buffer, conflate, and collectLatest each do?

IntermediateFlow

Answer

By default a Flow is sequential: the producer's emit() suspends until the collector has finished processing the previous value, so a collector that takes 100 ms per item throttles a producer that could emit every 10 ms. That is backpressure by suspension, and it is the correct default because it bounds memory. The operators exist to trade that safety for throughput in specific ways. buffer(capacity) moves the producer into its own coroutine with a channel between them, so the producer keeps working while the collector processes; the default capacity is 64, and you can pass BufferOverflow behaviour to decide what happens when it fills. conflate() is buffer with capacity 1 and DROP_OLDEST: the collector always jumps to the newest value and intermediate values are discarded, which is exactly right for UI progress or sensor readings where stale values are worthless. collectLatest (and mapLatest, transformLatest) does something different: it does not drop values, it starts processing each one and cancels that processing when a newer value arrives, so partially rendered work is abandoned rather than skipped. Choosing between conflate and collectLatest is a real interview discriminator: conflate keeps whichever value is newest at the moment the collector is free, collectLatest starts everything and keeps only the last to finish. flowOn implicitly adds a buffer because it splits producer and consumer across contexts, so an explicit buffer after flowOn is often redundant.

On the hot side, a SharedFlow with the default SUSPEND overflow policy applies backpressure to its emitters, which can stall a producer if a single slow collector is attached, so long-lived event flows usually specify DROP_OLDEST plus a small extraBufferCapacity. The debugging heuristic to mention: if a Flow pipeline is slower than the sum of its parts, look for a missing buffer; if memory grows unbounded, look for a buffer with UNLIMITED capacity.

// no buffer: producer waits for the collector on every item
sensor.readings().collect { render(it) }

// producer and collector run concurrently, bounded queue
sensor.readings().buffer(capacity = 64).collect { render(it) }

// only the newest value matters; older ones are dropped
uploadProgress().conflate().collect { updateBar(it) }

// start every value, cancel unfinished work when a newer one arrives
queryFlow.collectLatest { q -> renderExpensivePreview(q) }

// hot event flow that must never stall its emitters
MutableSharedFlow<Event>(extraBufferCapacity = 32, onBufferOverflow = BufferOverflow.DROP_OLDEST)
Q29

What does inline do to a higher-order function, and when do you need noinline, crossinline, or reified?

IntermediateInline Functions

Answer

A non-inline higher-order function allocates: each lambda becomes a Function object, and a lambda that captures variables becomes a new instance per call. Marking the function inline tells the compiler to copy the function body and the lambda bodies into the call site, so there is no Function allocation and no virtual invoke. That is why the stdlib collection operators, let, apply, and synchronized are inline.

Inlining also enables two things you cannot otherwise do. Non-local return: because the lambda body is spliced into the caller, a bare return inside forEach { } returns from the enclosing function, not just the lambda. And reified type parameters: the actual type argument is known at each call site, so inline fun <reified T> Gson.parse(json: String): T can call T::class.java and is::T despite JVM erasure, which is how Retrofit-style helpers and Intent extras utilities are written.

The modifiers exist to opt out of parts of that. noinline on a specific lambda parameter keeps it as a real object, needed when you store the lambda in a field, return it, or pass it to a non-inline function. crossinline forbids non-local returns from that lambda, which you need when the lambda will be invoked from a different execution context, typically inside an object expression or a Runnable, where returning from the outer function is impossible. There is also a cost side interviewers like to hear: inlining duplicates bytecode at every call site, so inlining a large function called from hundreds of places grows the method count and hurts the instruction cache; on Android that also feeds into the DEX method budget. The compiler warns with "expected performance impact from inlining is insignificant" when you inline a function with no lambda parameters, which is usually a sign the keyword is cargo cult.

inline fun <reified T> String.parseAs(json: Json = Json): T =
    json.decodeFromString(serializer(), this)   // T is real at the call site

val order: Order = body.parseAs()

inline fun runTimed(tag: String, crossinline block: () -> Unit) {
    val started = System.nanoTime()
    executor.execute { block() }   // crossinline: no non-local return allowed here
    Log.d(tag, "queued in ${System.nanoTime() - started}ns")
}

inline fun withRetry(times: Int, noinline onFail: (Throwable) -> Unit): Unit {
    // onFail is stored/passed elsewhere, so it must stay a real object
    handlers += onFail
}
💡 Pro Tip: If asked why the stdlib inlines forEach, answer with both reasons: zero lambda allocation and support for non-local return.
Q30

How does the by keyword work for class delegation and for property delegates?

IntermediateDelegation

Answer

Kotlin has two unrelated features sharing one keyword. Class delegation, class Repo(private val cache: Cache) : Cache by cache, makes the compiler generate a forwarding implementation of every interface member that calls through to the delegate, which gives you composition without hand-writing dozens of one-line overrides. You can override any member selectively and the generated forwarders handle the rest.

The gotcha to name: the delegate object does not know it is being delegated to, so if an interface method of the delegate calls another of its own methods, it calls its own implementation, not your override. Delegation forwards outward, not inward. Property delegation, val x by SomeDelegate(), rewrites property access into calls on the delegate's getValue and setValue operator functions, which may come from the ReadOnlyProperty and ReadWriteProperty interfaces or simply be operator functions with the right signature.

The stdlib ships the useful ones: lazy for compute-once values, Delegates.observable for a callback on every write, Delegates.vetoable for a write that can be rejected, Delegates.notNull() as a lateinit equivalent for primitives, and map delegation, where val name: String by map reads from a Map using the property name as the key, which is handy for JSON-backed configuration objects. Custom delegates are how Android teams build SharedPreferences wrappers, saved-state handles, and view bindings: one delegate class replaces the same twenty lines in every screen. Two implementation facts worth knowing: each delegated property creates a delegate instance per property per object unless the delegate is shared, and the KProperty parameter passed to getValue gives you the property name, which is what makes preference and map delegates work without repeating string keys. provideDelegate lets you run validation or registration at construction time rather than first access.

// class delegation: forwarders generated for every Cache member
class LoggingCache(private val inner: Cache) : Cache by inner {
    override fun get(key: String): String? =
        inner.get(key).also { Log.d("cache", "$key -> ${it != null}") }
}

// custom property delegate: typed SharedPreferences access
class PrefString(private val prefs: SharedPreferences, private val default: String) :
    ReadWriteProperty<Any?, String> {
    override fun getValue(thisRef: Any?, property: KProperty<*>): String =
        prefs.getString(property.name, default) ?: default
    override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) =
        prefs.edit().putString(property.name, value).apply()
}

var authToken: String by PrefString(prefs, default = "")
var retries: Int by Delegates.observable(0) { _, old, new -> log("retries $old -> $new") }
Q31

Explain variance in Kotlin generics: out, in, star projection, and where type erasure still hurts.

IntermediateGenerics

Answer

Generics are invariant by default: List<String> is not a MutableList<Any> and Box<Child> is not a Box<Base>. Declaration-site variance fixes this at the class level. out T makes the class covariant, meaning T may only appear in output positions (return types), so Producer<Child> is usable as Producer<Base>; kotlin.collections.List is declared List<out E>, which is why List<String> is assignable to List<Any>. in T makes the class contravariant, T may only appear in input positions (parameters), so Comparator<Any> is usable as Comparator<String>. The mnemonic interviewers accept is producer-out, consumer-in, the same PECS rule as Java wildcards, with the difference that Kotlin lets you declare it once on the class instead of at every use site.

Use-site variance exists too: a function parameter typed Array<out Number> is the equivalent of Java's Array<? extends Number> and is how copy-style functions accept a wider set of arrays. Star projection, Box<*>, says the type argument is some unknown type: you can read values as the upper bound (Any? by default) and cannot write anything except null, so it is the safe way to hold a heterogeneous collection of generic objects for logging or counting. Erasure is the wall behind all of this.

At runtime a List<String> is just a List, so you cannot write value is List<String> (the compiler rejects it), an unchecked as List<String> only warns and throws later at the first element access, and you cannot create an instance of T or call T::class inside a normal generic function. The escape hatches are reified type parameters on inline functions, passing a Class<T> or a KSerializer<T> explicitly, or capturing the type through an anonymous subclass the way TypeToken does in Gson. Naming the unchecked-cast warning and the delayed ClassCastException is what makes this answer sound like experience.

interface Producer<out T> { fun next(): T }          // T only in output position
interface Consumer<in T> { fun accept(item: T) }     // T only in input position

val anyProducer: Producer<Any> = object : Producer<String> { override fun next() = "x" }
val stringConsumer: Consumer<String> = object : Consumer<Any> { override fun accept(item: Any) {} }

fun logSize(box: Box<*>) = println(box.size)   // star projection: read-only view

// erasure: this compiles with a warning and throws on first use
@Suppress("UNCHECKED_CAST")
fun asStrings(any: Any): List<String> = any as List<String>

inline fun <reified T> Bundle.require(key: String): T = get(key) as T  // reified survives
Q32

Which Kotlin/Java interop annotations matter, and what breaks without them?

IntermediateJava Interop

Answer

Interop is a real interview topic in India because most codebases are mixed: an Android app with legacy Java Activities, or a Spring service migrating package by package. The annotations to know by name and effect: @JvmStatic on a companion member emits a genuine static method so Java writes ApiClient.create() instead of ApiClient.Companion.create(), which also matters for frameworks that look up statics reflectively (JUnit @BeforeClass, Android's CREATOR field). @JvmField exposes a property as a public field with no getter or setter, needed for Parcelable CREATOR and for libraries that read fields directly. @JvmOverloads generates one overload per trailing default parameter. @JvmName renames a function or, applied as @file:JvmName("StringUtils"), renames the synthetic file class Java callers must reference; it also resolves the platform declaration clash you hit when two functions differ only by generic type after erasure. @Throws declares checked exceptions in the bytecode signature, without which Java callers cannot write catch (IOException e) around your Kotlin function because the compiler thinks it is never thrown; Kotlin itself has no checked exceptions. @JvmSuppressWildcards and @JvmWildcard control the wildcards generated from declaration-site variance, which is what you reach for when a Java framework rejects your List<? extends Foo> signature. Beyond annotations, the two behaviours that bite are platform types, where a Java method with no nullability annotation yields String! and the compiler stops protecting you, and SAM conversion, where Kotlin lets you pass a lambda for a Java single-abstract-method interface but not for a Kotlin interface unless it is declared fun interface (introduced in Kotlin 1.4). The practical guidance to close with: annotate Java code with @Nullable and @NotNull, keep the Kotlin side explicit at the boundary, and add the interop annotations as you write the API rather than after a Java team files a bug.

@file:JvmName("OrderUtils")   // Java: OrderUtils.format(...)

class ApiClient private constructor(val baseUrl: String) {
    companion object {
        @JvmStatic fun create(url: String) = ApiClient(url)   // ApiClient.create() from Java
        @JvmField val DEFAULT_HEADERS = mapOf("Accept" to "application/json")
    }
}

@Throws(IOException::class)   // without this, Java cannot catch IOException
fun readConfig(path: String): Config = File(path).readText().toConfig()

fun interface RetryPolicy { fun shouldRetry(attempt: Int): Boolean }
val policy = RetryPolicy { it < 3 }   // SAM conversion needs 'fun interface' in Kotlin

Key Points

  • @JvmStatic for real statics, @JvmField for raw fields, @JvmOverloads for defaults
  • @Throws is required for Java callers to catch your exceptions
  • @file:JvmName renames the synthetic file class and fixes platform clashes
  • Platform types (String!) disable null checking at the Java boundary
  • SAM conversion for Kotlin interfaces needs 'fun interface' (1.4+)
Q33

Kotlin has no checked exceptions. How should error handling be designed, and what is wrong with runCatching?

IntermediateError Handling

Answer

Kotlin dropped checked exceptions deliberately: the compiler never forces you to catch or declare anything, and @Throws exists only so Java callers can. That puts the design burden on you. The three approaches used in production are exceptions for genuinely exceptional conditions, a sealed result type for expected failures, and kotlin.Result for narrow local use.

A sealed hierarchy is what most teams settle on for network and domain errors, because it makes the failure modes exhaustive at the call site: sealed interface ApiResult with Success, NetworkError, HttpError(code), and ParseError forces every screen to handle the timeout case in a when instead of discovering it in Crashlytics. kotlin.Result is a value class wrapping either a value or a Throwable, usable as a return type since Kotlin 1.5, and runCatching is its convenience builder with fold, getOrElse, getOrNull, and recover. The problem with runCatching, and this is the answer interviewers are fishing for, is that it catches Throwable. In coroutine code that includes CancellationException, so a runCatching around a suspending call swallows cancellation, the coroutine keeps going after its scope is cancelled, structured concurrency breaks, and you get work that outlives the screen that started it.

It also catches OutOfMemoryError and StackOverflowError, which you almost never want to convert into a value. The fix is a small helper that rethrows CancellationException before treating anything else as failure, and most large Kotlin codebases have exactly that function. Other points worth making: require, check, and error are the idiomatic precondition helpers and throw IllegalArgumentException, IllegalStateException, and IllegalStateException respectively; use { } on Closeable resources is the try-with-resources equivalent; and a bare catch (e: Exception) around suspending code has the same cancellation problem as runCatching.

// The helper almost every coroutine codebase ends up writing
inline fun <T> suspendRunCatching(block: () -> T): Result<T> = try {
    Result.success(block())
} catch (e: CancellationException) {
    throw e                        // never swallow cancellation
} catch (e: Exception) {
    Result.failure(e)
}

sealed interface ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>
    data class HttpError(val code: Int, val body: String?) : ApiResult<Nothing>
    data object NetworkError : ApiResult<Nothing>
}

fun handle(result: ApiResult<Order>) = when (result) {
    is ApiResult.Success -> render(result.data)
    is ApiResult.HttpError -> if (result.code == 402) showPaywall() else showError()
    ApiResult.NetworkError -> showRetry()
}
💡 Pro Tip: "runCatching catches CancellationException and breaks structured concurrency" is a one-line answer that reliably impresses on coroutine-heavy teams.
Q34

How do you test suspending functions and Flows with kotlinx-coroutines-test?

IntermediateTesting

Answer

The entry point is runTest, which replaced the deprecated runBlockingTest in coroutines 1.6. It runs the test body in a TestScope backed by a virtual clock, so delay(5_000) returns instantly while still ordering events correctly, and it fails the test if any child coroutine is still running at the end, which catches leaked work. Inside it you choose a scheduler behaviour.

StandardTestDispatcher, the default, queues coroutines rather than running them eagerly, so a launch inside the test does not execute until you call advanceUntilIdle(), advanceTimeBy(n), or runCurrent(); this is the realistic choice because it exposes ordering assumptions. UnconfinedTestDispatcher runs new coroutines eagerly to their first suspension point, which makes simple tests read linearly and is useful when you just want the launch body to have started before you assert. Both share one TestCoroutineScheduler, so pass testScheduler when you construct extra dispatchers, otherwise virtual time is not shared and advanceUntilIdle does nothing to the other dispatcher.

On Android, production code hard-codes Dispatchers.Main, which does not exist under JVM unit tests and throws "Module with the Main dispatcher had failed to initialize"; the fix is Dispatchers.setMain(StandardTestDispatcher()) in a @Before with Dispatchers.resetMain() in @After, usually packaged as a JUnit rule. The better long-term fix, and the one interviewers want to hear, is injecting a dispatcher provider so tests pass a test dispatcher instead of patching a global. For Flows, use the Turbine library (awaitItem, awaitComplete, expectNoEvents, cancelAndIgnoreRemainingEvents) rather than collecting into a list in a launch, because Turbine handles the collector lifecycle and fails loudly on unconsumed emissions. For a StateFlow created with stateIn(WhileSubscribed()), remember there must be an active collector or the upstream never starts, which is the usual reason a state test sees only the initial value.

@Test
fun `retry backs off and succeeds`() = runTest {
    val repo = FakeRepo(failFirst = 2)
    val vm = OrderViewModel(repo, UnconfinedTestDispatcher(testScheduler))

    vm.load(id = 42)
    advanceUntilIdle()                 // drain the virtual clock, no real waiting

    assertEquals(3, repo.attempts)
}

@Test
fun `search emits results after debounce`() = runTest {
    vm.state.test {                    // Turbine
        assertEquals(UiState.Idle, awaitItem())
        vm.onQueryChanged("kot")
        advanceTimeBy(300)
        assertTrue(awaitItem() is UiState.Loaded)
        cancelAndIgnoreRemainingEvents()
    }
}
Q35

When does converting a collection chain to a Sequence actually make it faster?

IntermediateSequences & Performance

Answer

Collection operators are eager and horizontal: list.map { }.filter { }.take(5) builds a full intermediate list at each step, so a 100,000-element list runs map 100,000 times, allocates a new list, runs filter 100,000 times, allocates another, and then discards all but five elements. Sequences are lazy and vertical: asSequence() turns the chain into a pull-based pipeline where each element flows through every operator before the next element is fetched, nothing is materialised until a terminal operator such as toList(), first(), or sum() runs, and short-circuiting terminals stop early. That gives sequences two wins: no intermediate collections, and the ability to skip work entirely when a terminal like first() or any() is satisfied early.

It also gives them a loss. Every sequence operator allocates a wrapper object and every element passes through a chain of iterator calls, so for small collections (roughly the low hundreds, though the crossover depends on the operators) the eager version is measurably faster because the JIT flattens simple loops well and the allocation is one array. The rules to state in an interview: use sequences when the source is large, when the chain has three or more steps, when the terminal short-circuits, or when the source is genuinely unbounded or expensive to produce (generateSequence, File.useLines, database cursors).

Use plain collections for small in-memory lists and single-step transforms. Two behavioural notes that separate a used-it answer: sequences are single-use unless built from a constrainOnce-free source, so collecting twice from a sequence backed by an iterator throws IllegalStateException, and operators such as sorted() and groupBy() are stateful, meaning they buffer the entire sequence internally and erase the laziness benefit at that point. Flow is essentially the asynchronous sibling of Sequence, which is a good closing comparison.

// eager: two intermediate lists of 100k elements, then take 5
val eager = orders.map { it.toDto() }.filter { it.amount > 1000 }.take(5)

// lazy: at most a handful of elements ever touch toDto()
val lazySeq = orders.asSequence()
    .map { it.toDto() }
    .filter { it.amount > 1000 }
    .take(5)
    .toList()

// unbounded and file sources are sequence-native
val ids = generateSequence(1L) { it + 1 }.take(10).toList()
File("orders.csv").useLines { lines -> lines.filter { it.startsWith("IN") }.count() }

// sorted() buffers everything: laziness stops here
val buffered = orders.asSequence().sortedBy { it.amount }.take(5).toList()
Q36

How does kotlinx.serialization differ from Gson or Moshi, and which configuration flags matter in production?

IntermediateSerialization

Answer

kotlinx.serialization is compiler-plugin based: applying the org.jetbrains.kotlin.plugin.serialization Gradle plugin and annotating a class with @Serializable makes the compiler generate a serializer at build time. That is the fundamental difference from Gson, which uses runtime reflection, and Moshi, which offers both reflection and a codegen path. Three consequences matter.

First, it is multiplatform, so the same model serialises in a KMP shared module on Android, iOS, and JVM, which reflection-based libraries cannot do on Kotlin/Native. Second, it respects Kotlin semantics: Gson constructs objects through Unsafe, bypassing constructors, so a non-null field missing from the JSON becomes null in a variable the compiler swears cannot be null, and default values never run. kotlinx.serialization calls the real constructor, honours default values, and throws MissingFieldException when a required field is absent, which turns a class of silent NPEs into a clear parse error. Third, it costs build time and adds generated code rather than reflection overhead at runtime.

The configuration to know: ignoreUnknownKeys = true is mandatory against evolving server APIs or every new backend field crashes the client with "Unexpected JSON token"; coerceInputValues = true replaces an explicit null with the property default; explicitNulls = false stops writing null-valued properties into output; encodeDefaults controls whether defaulted values are emitted at all (off by default, which surprises people whose server expects the field); and @SerialName maps a wire name onto a Kotlin property. For sealed hierarchies, polymorphic serialisation writes a type discriminator, configurable through classDiscriminator, and closed polymorphism works automatically for sealed classes with @Serializable subtypes. On Android with Retrofit you plug it in with the Kotlin serialization converter factory. The interview close: name the Gson default-values bug, because it is the single most common real-world reason teams migrate.

@Serializable
data class Order(
    val id: Long,
    @SerialName("created_at") val createdAt: String,
    val currency: String = "INR",     // Gson would leave this null; kotlinx honours it
    val notes: String? = null,
)

val json = Json {
    ignoreUnknownKeys = true      // survive new server fields
    coerceInputValues = true      // explicit null -> default
    encodeDefaults = false        // omit defaulted properties on the wire
    isLenient = false
}

val order = json.decodeFromString<Order>(body)

@Serializable
sealed interface Event {          // closed polymorphism, adds a "type" discriminator
    @Serializable @SerialName("click") data class Click(val id: String) : Event
    @Serializable @SerialName("view")  data class View(val screen: String) : Event
}
Q37

What changed with the K2 compiler in Kotlin 2.0, and what breaks when a large codebase migrates?

AdvancedCompiler & Toolchain

Answer

K2 is a rewritten compiler frontend, shipped as the default in Kotlin 2.0. The old frontend built descriptors and a separate BindingContext; K2 builds a single unified semantic model (FIR) and feeds a common intermediate representation to every backend, which is what makes JVM, Native, JS, and Wasm behave consistently instead of drifting. The headline benefits are compilation speed, particularly in the analysis phase, and much better type inference and smart casting, including cases that previously needed an explicit cast.

Because the analysis is stricter and more correct, migration is not free, and interviewers want the failure modes, not the marketing. Code that compiled by accident now fails: overload resolution ambiguities that the old frontend resolved arbitrarily, nullability warnings on Java interop that were previously silent, unreachable-code and unused-expression diagnostics that were missed, and stricter checks on when exhaustiveness. Compiler plugins are the bigger operational risk: anything that hooks the frontend (kapt, Compose, serialization, Parcelize, Dagger's Kotlin support, Room) needs a K2-compatible version, and mismatched plugin versions produce internal compiler errors rather than helpful messages.

Annotation processing is where teams spend the most migration time. kapt is legacy and does not use the new frontend by default, so it stays a build-time tax; the recommended path is KSP, whose K2-compatible generation reads Kotlin declarations directly instead of generating Java stubs, and which is typically several times faster on annotation-heavy Android modules. Practical migration advice to give: bump Kotlin and every compiler plugin together, run with -Xuse-fir-lt or the language-version flags module by module for a staged rollout, treat new warnings as work rather than noise, and measure with Gradle build scans before and after so the speed claim is yours, not a blog's.

// gradle/libs.versions.toml: plugins move in lockstep with Kotlin
// kotlin = "2.x.y"
// ksp    = "2.x.y-1.0.z"   // KSP version is pinned to the Kotlin version

// build.gradle.kts
plugins {
    kotlin("jvm")
    id("com.google.devtools.ksp")            // replace kapt where the processor supports it
    id("org.jetbrains.kotlin.plugin.serialization")
}

kotlin {
    compilerOptions {
        // stage a rollout: pin the language version while the toolchain moves
        // languageVersion.set(KotlinVersion.KOTLIN_1_9)
        allWarningsAsErrors.set(false)       // K2 surfaces warnings K1 missed
    }
}

Key Points

  • K2 replaces descriptors with FIR and one shared IR across all backends
  • Faster analysis plus wider smart casts and better inference
  • Previously silent errors now fail: overload ambiguity, interop nullability
  • Every compiler plugin needs a matching K2-compatible version
  • Move kapt to KSP; kapt does not benefit from the new frontend
Q38

A Kotlin Android build takes twelve minutes on CI. How do you diagnose and cut it?

AdvancedBuild Performance

Answer

Measure before changing anything. Run the build with --scan, or enable Kotlin build reports with kotlin.build.report.output=file, which breaks time down per task and per compilation phase so you can see whether the cost is Kotlin compilation, annotation processing, dexing, or resource merging. On a typical Android codebase the ranking is usually annotation processing first, then Kotlin compilation of a few oversized modules, then dexing.

Attack them in that order. Annotation processing: kapt generates Java stubs for every Kotlin source in the module before running processors, which is often more expensive than the processing itself, so migrating to KSP is the single biggest win where the processor supports it (Room, Moshi, and Glide all do; Dagger and Hilt have KSP support in recent versions). If a processor is kapt-only, at least isolate it so kapt does not run on every module.

Compilation: incremental compilation is on by default but is defeated by ABI churn, so a constant or a public signature in a widely depended-on module invalidates everything downstream; the structural fix is module boundaries with internal visibility and api versus implementation configurations set correctly, because a dependency declared api leaks onto every consumer's compile classpath. Turn on the Gradle build cache and the configuration cache, give the Kotlin daemon enough heap via kotlin.daemon.jvmargs (an undersized daemon that garbage-collects constantly looks exactly like a slow compiler), and check you are not accidentally running non-incremental because of a custom task without proper inputs and outputs. On CI specifically, a remote build cache shared across runs and a warm daemon matter more than raw machine size, and building only the affected modules with a change-detection plugin beats optimising a full build nobody needed. Report the result as before-and-after numbers; that is what the interviewer is actually assessing.

# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
kotlin.incremental=true
kotlin.daemon.jvmargs=-Xmx4g
kotlin.build.report.output=file

# Diagnose
./gradlew :app:assembleDebug --scan
./gradlew :app:assembleDebug --profile

# build.gradle.kts: leak less onto downstream compile classpaths
dependencies {
    api(project(":core:model"))          // only for types in your public signatures
    implementation(project(":core:network"))  // everything else
    ksp(libs.room.compiler)              // was kapt(libs.room.compiler)
}
Q39

How do inline value classes work, and when does the wrapper still get boxed?

AdvancedValue Classes

Answer

@JvmInline value class UserId(val value: Long) creates a type that exists at compile time for safety but is represented at runtime by its single underlying property wherever the compiler can manage it. The payoff is domain typing without allocation: functions that take UserId, OrderId, and Paise can no longer be called with each other's arguments, and the bytecode still passes primitives. The rules of representation are the interesting part.

The wrapper is unboxed when the value class is used directly as a parameter or return type of a function compiled with knowledge of the type. It is boxed when it is used as a generic type argument (List<UserId>), when it is nullable (UserId?), when it is stored in a field typed as a supertype or interface it implements, when it crosses a boundary that expects Any, and when it is used in reflection or serialization paths. So a value class in a hot loop is free, but the same value class inside a collection is not, and interviewers who have used them will ask exactly that.

The second behaviour to know is name mangling: because two functions differing only in their value-class parameter would erase to the same JVM signature, the compiler appends a hash to the method name (charge-abc123). That makes the function awkward to call from Java, and @JvmName does not fully rescue it, so value classes are a poor fit on a Java-facing API surface. Value classes may implement interfaces and declare properties with custom getters and functions, but they cannot have a backing field other than the single underlying one, cannot participate in inheritance, and equals/hashCode delegate to the underlying value.

On Android, the common uses are typed IDs, units (Dp, Millis, Paise), and wrapping a String token so it cannot be logged by accident when you override toString. A nice production note: value classes give you type safety without the object churn that made people avoid wrapper types in the first place.

@JvmInline
value class Paise(val value: Long) {
    val rupees: Double get() = value / 100.0
    override fun toString(): String = "₹%.2f".format(rupees)
}

@JvmInline value class UserId(val value: Long)

fun charge(user: UserId, amount: Paise) { /* compiled as charge(long, long) */ }
// charge(orderId, amount)  // compile error: OrderId is not UserId

val cart: List<Paise> = listOf(Paise(19900))  // boxed: generic type argument
val maybe: Paise? = null                      // boxed: nullable

// Java sees a mangled name such as charge-8Xm7pQ, so keep value classes
// off Java-facing API surfaces.
Q40

What problem do context receivers and context parameters solve, and what is their status?

AdvancedLanguage Evolution

Answer

The problem is implicit ambient dependencies. Kotlin has always had one implicit receiver through extension functions, so you can write fun Order.total(): Int, but you cannot write a function that requires two contexts, say a LoggingContext and a TransactionContext, without threading them as parameters through every call or resorting to a member extension inside a class. Context receivers, introduced as an experimental feature behind the -Xcontext-receivers compiler flag in Kotlin 1.6.20, let a declaration state context(Logger, Transaction) and then use members of both implicitly, with the compiler resolving them from the call site's scope.

In practice teams used them for scoped capabilities: an analytics scope, a database transaction scope, a Compose-style styling scope. The design had known problems (the receivers were anonymous, so nested contexts of the same type were ambiguous, and there was no way to name one), and JetBrains replaced the design with context parameters in Kotlin 2.2, available in preview behind -Xcontext-parameters. Context parameters are named, written context(logger: Logger, tx: Transaction), which removes the ambiguity, makes the code readable, and allows a context to be referenced explicitly when shadowed.

Context receivers are deprecated in favour of them. The reason this is an advanced interview question rather than trivia is what it tests: whether you can articulate the trade-off between implicit and explicit dependencies. The honest answer is that context parameters remove ceremony for genuinely ambient concerns (logging, tracing, transactions, dependency scopes) but make call sites harder to read when overused, because a reader cannot see where a capability came from. Practical advice for 2026: know what they are and why they exist, do not build production architecture on a preview feature that still requires an opt-in flag, and be ready to say what you would use instead today, which is usually constructor injection or an explicit scope receiver via a lambda with receiver.

// Context parameters (preview, -Xcontext-parameters): named and unambiguous
context(logger: Logger, tx: Transaction)
fun settleOrder(id: OrderId) {
    logger.info("settling $id")
    tx.execute("UPDATE orders SET status = 'SETTLED' WHERE id = ?", id.value)
}

// What most production code does today instead: an explicit scope receiver
class TransactionScope(val tx: Transaction, val logger: Logger)

fun <T> withTransaction(block: TransactionScope.() -> T): T = /* ... */

withTransaction {
    logger.info("settling")
    tx.execute("...")
}
💡 Pro Tip: Say plainly that context parameters are preview and flag-gated. Claiming a preview feature is production-ready is a credibility hit with senior interviewers.
Q41

How does Kotlin Multiplatform structure a shared module, and what does expect/actual cost you?

AdvancedMultiplatform

Answer

A KMP module declares targets (androidTarget, iosArm64, iosSimulatorArm64, jvm, js) and organises code into source sets: commonMain holds platform-independent code and can only use the common stdlib plus multiplatform libraries, while androidMain, iosMain, and friends hold platform code and can see their platform's APIs. Intermediate source sets such as appleMain let two native targets share code. The dependency rule is that platform source sets depend on common, never the other way round. expect/actual is the escape hatch: commonMain declares expect fun currentTimeMillis(): Long or an expect class, and every target must supply an actual.

The costs interviewers want you to name are real. expect/actual classes still produce a Beta warning and are stricter than functions, since every member must match exactly. It is easy to over-apply: for most cases the better pattern is a plain interface in common with platform implementations injected at the edge, because that is testable and does not require the compiler to line up declarations. The build story is heavier than a single-platform project: iOS consumption goes through an XCFramework or the CocoaPods plugin, compile times for native targets are long, and debugging across the Kotlin/Swift boundary is worse than either side alone.

On the runtime side, the modern Kotlin/Native memory manager (default since 1.7.20) removed the old object-freezing model and InvalidMutabilityException, so shared mutable state and coroutines behave much closer to the JVM, which is the single biggest reason KMP became practical. What teams actually share is business logic, networking (Ktor client), serialization, and persistence (SQLDelight or Room's KMP support), while keeping UI native; Compose Multiplatform has matured enough on iOS to also be a real option, though most Indian product teams sharing logic today still keep SwiftUI on top. A grounded answer names one thing you would not share, usually anything touching platform permissions or push notifications.

// build.gradle.kts
kotlin {
    androidTarget()
    iosArm64(); iosSimulatorArm64()

    sourceSets {
        commonMain.dependencies {
            implementation(libs.ktor.client.core)
            implementation(libs.kotlinx.serialization.json)
            implementation(libs.kotlinx.coroutines.core)
        }
        androidMain.dependencies { implementation(libs.ktor.client.okhttp) }
        iosMain.dependencies { implementation(libs.ktor.client.darwin) }
    }
}

// commonMain
expect class PlatformStorage { fun put(key: String, value: String) }

// androidMain
actual class PlatformStorage(private val prefs: SharedPreferences) {
    actual fun put(key: String, value: String) { prefs.edit().putString(key, value).apply() }
}
Q42

In Jetpack Compose, what makes a composable skippable, and how do you find the ones that are not?

AdvancedCompose & Kotlin

Answer

Recomposition is driven by state reads, and the Compose compiler tries to skip a composable whose parameters have not changed. It can only do that when every parameter type is stable, which means the type's public properties are immutable (val) and comparisons with equals are consistent, or the type is annotated @Stable or @Immutable to promise that contract for something the compiler cannot infer. Unstable parameters are the usual cause of a screen that recomposes on every frame: List and Map (they might be mutable implementations), classes from modules not compiled with the Compose compiler, interfaces the compiler cannot see through, and lambdas that capture unstable values.

Strong skipping mode, on by default in recent Compose compiler releases, changes the economics by allowing composables with unstable parameters to be skipped using instance equality and by memoising lambdas automatically, which removed a large amount of hand-written @Immutable ceremony; still, knowing the underlying rule is what the interview is testing. To find offenders, generate compiler metrics and reports through the Compose compiler Gradle plugin, which writes a per-composable table of restartable, skippable, and the stability of each parameter, and cross-check with recomposition counts in the Layout Inspector. The everyday fixes: hoist state so only the leaf reading it recomposes, pass lambdas that are stable references rather than newly captured ones, wrap collections in ImmutableList from kotlinx.collections.immutable or an @Immutable holder, use derivedStateOf when a frequently changing state produces a rarely changing derived value (a scroll offset driving a boolean isScrolled), use key() in lazy lists so identity survives reordering, and defer state reads into a lambda for animations rather than reading them in composition. One version fact worth having: from Kotlin 2.0 the Compose compiler lives in the Kotlin repository and is applied via the org.jetbrains.kotlin.plugin.compose Gradle plugin, versioned in lockstep with Kotlin rather than tracked separately.

@Immutable
data class OrderRow(val id: Long, val label: String, val amount: Paise)

@Composable
fun OrderList(
    rows: ImmutableList<OrderRow>,          // stable: List<OrderRow> would not be
    onClick: (Long) -> Unit,
) {
    LazyColumn {
        items(rows, key = { it.id }) { row ->  // key preserves identity on reorder
            OrderCard(row = row, onClick = onClick)
        }
    }
}

// derivedStateOf: scrollState changes every frame, isScrolled rarely does
val isScrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }

// build.gradle.kts
plugins { id("org.jetbrains.kotlin.plugin.compose") }
composeCompiler { reportsDestination = layout.buildDirectory.dir("compose_reports") }
Q43

Which coroutine scope belongs where on Android, and how do you collect a Flow without leaking or wasting work?

AdvancedAndroid Lifecycle

Answer

Scope choice is a lifecycle decision. viewModelScope is cancelled in onCleared, so it is right for work that should follow the screen's data, including a request that must survive a rotation. lifecycleScope belongs to an Activity or Fragment and dies with it, so it is right for view work. A custom application-level CoroutineScope(SupervisorJob() + Dispatchers.Default) is right for work that must outlive any screen, such as flushing analytics or writing a draft; using a WorkManager job is better still when the work must survive process death. GlobalScope is effectively never correct in application code because nothing can cancel it.

The Flow collection problem is subtler than cancellation. Collecting in lifecycleScope.launch keeps collecting while the app is in the background, so a location or websocket flow keeps burning battery behind a locked screen. repeatOnLifecycle(Lifecycle.State.STARTED) fixes it by cancelling the collection when the lifecycle drops below STARTED and restarting it when it comes back, and it must be launched from lifecycleScope in a Fragment using viewLifecycleOwner, not the Fragment's own lifecycle, or you leak across view recreation. In Compose the equivalent is collectAsStateWithLifecycle() from lifecycle-runtime-compose, which is the correct default over collectAsState().

The third piece is upstream sharing. A cold flow converted with stateIn(scope, SharingStarted.WhileSubscribed(5_000), initial) starts the upstream only when there is a subscriber and stops it five seconds after the last one goes away, which is long enough to survive a configuration change without restarting a network call and short enough to stop work when the user leaves. SharingStarted.Eagerly starts immediately and never stops; Lazily starts on the first subscriber and never stops. Getting these three layers right (scope, lifecycle-aware collection, sharing policy) is what an Android interviewer means by production coroutine knowledge, and misusing any one of them shows up as either a leak or a stream that goes silent.

class OrdersViewModel(repo: OrderRepository) : ViewModel() {
    val state: StateFlow<UiState> = repo.orders()
        .map(::toUiState)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),  // survives rotation
            initialValue = UiState.Loading,
        )
}

// Fragment: viewLifecycleOwner, not 'this'
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.state.collect(::render)   // stops in the background, restarts on resume
    }
}

// Compose
val state by viewModel.state.collectAsStateWithLifecycle()

Key Points

  • viewModelScope for data work, lifecycleScope for view work, app scope for the rest
  • repeatOnLifecycle(STARTED) stops collection in the background
  • In Fragments always use viewLifecycleOwner for view-bound collection
  • collectAsStateWithLifecycle beats collectAsState in Compose
  • SharingStarted.WhileSubscribed(5_000) survives rotation without leaking
Q44

How would you build a type-safe DSL in Kotlin, and what does @DslMarker prevent?

AdvancedDSL Design

Answer

A Kotlin DSL is built from three ingredients: lambdas with receiver, extension functions, and the trailing-lambda call syntax. A function typed block: Builder.() -> Unit makes the builder the implicit this inside the block, so callers write property assignments and nested function calls without any qualifier, which is how Gradle Kotlin DSL, Ktor routing, Compose, kotlinx.html, and Anko-style builders all work. You then apply the block to a fresh builder and return the built object, usually with apply.

Add infix functions for readability where a two-argument call reads better as a phrase, and use operator functions like unaryPlus to make bare strings meaningful inside a block. The problem @DslMarker solves is receiver leakage in nested builders. When you nest a block inside another block, both receivers are in scope, so inside a nested table block you can accidentally call a method belonging to the outer html receiver and the compiler will happily resolve it, producing structurally wrong output that compiles.

Annotating an annotation class with @DslMarker and applying it to your builder types tells the compiler that only the nearest receiver of that marker is accessible implicitly, so outer members require an explicit qualified this@Outer. That single annotation turns an entire class of silent bugs into compile errors, and naming it is what marks the answer as coming from someone who has shipped a DSL rather than read about one. Practical design advice to close with: keep the DSL small and make the built object immutable, validate in the build step with require so misconfiguration fails loudly, avoid returning the builder from the block (return Unit so the last expression cannot be mistaken for a result), and remember that a DSL is only worth it when the configuration is repeated and nested. For a single flat config object, named arguments with defaults already give you readable, type-safe construction with none of the machinery.

@DslMarker
annotation class RouteDsl

@RouteDsl
class RouteBuilder(val path: String) {
    private val children = mutableListOf<Route>()
    var authenticated: Boolean = false

    fun route(path: String, block: RouteBuilder.() -> Unit) {
        children += RouteBuilder(path).apply(block).build()
    }

    fun build(): Route {
        require(path.startsWith("/")) { "path must start with /: $path" }
        return Route(path, authenticated, children.toList())
    }
}

fun routes(block: RouteBuilder.() -> Unit): Route = RouteBuilder("/").apply(block).build()

val api = routes {
    route("/orders") {
        authenticated = true
        route("/{id}") { }
        // without @DslMarker the outer builder's members would also resolve here
    }
}
Q45

Production incident: a Kotlin service becomes unresponsive under load and thread dumps show idle CPU. How do you diagnose it?

AdvancedDebugging & Production

Answer

Idle CPU with a stalled service points at exhausted concurrency rather than slow code, and in a coroutine codebase there is a short list of causes. First, blocking calls on a bounded dispatcher: a JDBC query, a synchronous HTTP client, Thread.sleep, or a file read executed on Dispatchers.Default occupies a worker thread for its whole duration, and Default is sized to the CPU count, so a handful of blocked calls stops all CPU-bound work in the process. The same pattern on Android's main dispatcher produces an ANR.

Second, a deadlock created by a coroutine waiting on a result that can only be produced by the same exhausted pool, which is the classic reason a limitedParallelism view or a single-threaded dispatcher hangs. Third, unbounded suspension on a channel or a SharedFlow with the default SUSPEND overflow policy where one slow collector backs up every emitter. Fourth, a leaked scope that never cancels, so completed requests still hold workers.

The diagnostic path: take a JVM thread dump and look at what the DefaultDispatcher-worker and kotlinx.coroutines threads are actually parked on, then add the kotlinx-coroutines-debug artifact and call DebugProbes.install() so DebugProbes.dumpCoroutines() prints every live coroutine with its state and creation stack trace, which is the only view that shows suspended coroutines at all, since they have no thread and therefore no stack in a normal dump. Run the JVM with -Dkotlinx.coroutines.debug so coroutine names appear in thread names, and give every scope a CoroutineName so the dump is readable. Stack-trace recovery, enabled with -ea or the kotlinx.coroutines.stacktrace.recovery property, restores the caller frames that the state-machine transform would otherwise erase. The fixes follow the diagnosis: wrap blocking work in withContext(Dispatchers.IO), give a bounded dependency its own limitedParallelism view, set explicit buffer overflow policies, and add a metric for active coroutine count so the next occurrence is visible before users report it.

// Instrumentation you want before the incident, not after
DebugProbes.install()
val dump = DebugProbes.dumpCoroutinesToString()   // suspended coroutines + creation traces

// JVM flags
// -Dkotlinx.coroutines.debug        coroutine names in thread names
// -ea                               enables stack-trace recovery

val scope = CoroutineScope(
    SupervisorJob() + Dispatchers.Default + CoroutineName("settlement-worker")
)

// The bug: blocking JDBC on the CPU pool starves every other coroutine
// scope.launch { jdbc.query("SELECT ...") }

// The fix: bounded IO view sized to the connection pool
private val dbDispatcher = Dispatchers.IO.limitedParallelism(10)
scope.launch { withContext(dbDispatcher) { jdbc.query("SELECT ...") } }
💡 Pro Tip: Lead with the diagnosis method, not the fix. Interviewers scoring incident questions are listening for thread dump, DebugProbes, and "blocking call on a bounded dispatcher" in that order.

Companies Hiring Kotlin

PhonePe
CRED
Swiggy
Zomato
Flipkart
Meesho
Google
Groww

Salary Insights

Average in India
₹8-25 LPA

Frequently Asked Questions

What does a Kotlin developer earn in India in 2026?

The working band is ₹8-25 LPA, and the spread inside it is wide because the title covers very different jobs. Android freshers at service companies start around ₹4-7 LPA; product-company freshers at the Flipkart, Meesho, and Groww tier start closer to ₹12-20 LPA including stock. Two to four years of Android with real coroutines and Compose work sits at ₹12-22 LPA, five to eight years at ₹22-40 LPA, and senior or staff Android engineers at PhonePe, CRED, Swiggy, and Google India go well beyond that once equity is counted. Kotlin backend roles (Spring Boot or Ktor) track normal JVM backend bands rather than commanding a premium, since teams hire for backend depth and treat Kotlin as the syntax. The two genuine multipliers are Compose plus coroutines depth, and Kotlin Multiplatform experience, which is still scarce enough that KMP-capable engineers are pulled into cross-platform team leads.

How long should I prepare for a Kotlin interview?

Coming from Java, three to four weeks of focused evenings is realistic: one week on language features that have no Java equivalent (null safety, data classes, sealed hierarchies, extensions, scope functions, delegation), two weeks on coroutines and Flow until you can explain cancellation and structured concurrency without notes, and the rest on the platform you are interviewing for (Compose and lifecycle for Android, Spring or Ktor for backend). Coming from Python or JavaScript, budget eight to ten weeks, because the JVM and static typing are the real learning curve, not the syntax. Whatever your starting point, spend at least half your time writing code rather than reading: build one small app with a real API, a ViewModel exposing StateFlow, a search screen with debounce and flatMapLatest, and tests using runTest. Interviewers can tell within five minutes whether you have debugged a coroutine or only read about one.

Do Indian companies hire Kotlin freshers, or is it experienced-only?

Freshers get hired, but almost always as Android freshers rather than Kotlin freshers. Service companies (TCS, Infosys, Wipro, Cognizant, LTIMindtree) recruit through campus drives and train on the job, and they still ask heavily about language fundamentals plus data structures rather than coroutine internals. Product companies hire fewer freshers for mobile and screen them on DSA first, with Kotlin depth as the differentiator in later rounds. The realistic fresher route is three things: one published Play Store app you actually built (not a tutorial clone), a GitHub repo showing modern architecture (Compose, ViewModel, StateFlow, Hilt, Retrofit, tests), and enough DSA to clear the screening round, since most product companies gate on that before anyone looks at your Kotlin. Internships at Bangalore and Gurgaon product startups are the highest-conversion path, and the Google Developer Groups and Kotlin user group circuits in those cities are where the referrals happen.

Is Kotlin still worth learning in 2026, or is Flutter taking the Android market?

Kotlin is worth learning, and the framing that it competes with Flutter is mostly wrong. Flutter and React Native have real share in India, particularly at startups optimising for one team shipping both platforms, but the largest consumer apps and every performance-sensitive or deeply platform-integrated product stay native, and Google keeps positioning Kotlin as the Android default with Compose, KMP, and first-party libraries all Kotlin-first. The demand is also no longer only mobile: Kotlin on Spring Boot and Ktor is a normal backend choice, and Kotlin Multiplatform lets a single team share business logic across Android and iOS while keeping native UI, which is the direction several Indian product teams are moving. The honest risk is that generic Android CRUD roles are commoditising, so the career-safe version of the bet is Kotlin plus depth: coroutines and Flow, Compose performance, and either backend JVM or KMP as the second axis.

Kotlin or Java for a JVM career in India right now?

For Android there is no real debate: new code is Kotlin, Google's libraries and samples are Kotlin-first, and a Java-only Android profile now reads as dated. For backend the picture is different. Java still has far more open roles in India, the enterprise and services market runs on Spring with Java, and recent Java versions have closed part of the syntax gap with records, sealed classes, pattern matching in switch, and virtual threads. Kotlin backend roles exist at product companies and fintechs but are a minority of JVM postings. The pragmatic answer that lands well in interviews is that they are not alternatives: the JVM, the collections framework, garbage collection, and the Spring ecosystem are shared, and interop means most real teams run both. Learn the JVM properly, use Kotlin where you can, and be able to read Java fluently, because you will be maintaining it either way.

Which Kotlin topics take up the most interview time?

Coroutines and Flow dominate, comfortably more than half the technical time on any Android or Kotlin backend loop above fresher level. Expect structured concurrency, why try/catch around async fails, cooperative cancellation, dispatcher selection, and StateFlow versus SharedFlow, often asked as a debugging story rather than a definition. Second is state modelling with sealed hierarchies and exhaustive when, usually posed as a design question about representing a screen or a payment flow. Third, for Android, is Compose: recomposition, stability, and lifecycle-aware collection. Language fundamentals (null safety, data classes, extensions, scope functions, inline and reified) come up early as a filter and are expected to be effortless. Build tooling appears at senior level, usually as a question about kapt versus KSP or why a build is slow. Pure algorithm rounds in Kotlin are common at product companies but are testing DSA, not the language, so prepare those separately.

Introduction

Kotlin runs the Indian mobile economy. Every large consumer app you use daily (PhonePe, CRED, Swiggy, Zomato, Flipkart, Meesho) ships an Android client written substantially in Kotlin, and since Google made it the preferred Android language the hiring market has never looked back. The language has also outgrown its Android identity: the K2 compiler that arrived with Kotlin 2.0 rebuilt the frontend for speed and smarter type analysis, Kotlin Multiplatform is stable and shipping in production iOS apps, and backend teams at fintechs run Kotlin on Spring Boot and Ktor. In 2026 a Kotlin interview can therefore go in three directions, and the strongest candidates prepare for all of them.

What actually separates candidates in Indian interviews is coroutines depth. Almost everyone can explain null safety and data classes; far fewer can explain why a try/catch around async fails to contain an exception, why cancellation is cooperative, or when StateFlow silently drops an emission. Interviewers at product companies also probe sealed hierarchies for state modelling, Flow operators and backpressure, Compose stability and recomposition, Java interop annotations, and the Gradle/KSP toolchain that decides real build times. Service companies like TCS and Infosys stay closer to language fundamentals, but the coroutines bar has risen everywhere as codebases have migrated off RxJava and AsyncTask.

This guide contains 45 questions arranged from basic through advanced, and each answer is written the way a strong candidate would actually speak: what the feature does, how it behaves on the JVM, where it breaks in production, and what the interviewer is really checking. More than half the questions include a runnable Kotlin example. Work through the basic section to lock down fundamentals, then spend most of your time on the coroutine and Flow questions in the middle, because that is where offers at CRED, PhonePe, and Swiggy-tier companies are decided.

Ready to practice Kotlin interviews?

Don't just read, practice these Kotlin questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview