Kotlin Backend Developer Top 100 Interview Questions
100 Kotlin backend developer interview questions with structured answers, comparison tables, flow diagrams, worked calculations and runnable code. Covers null safety and the type system, coroutines and structured concurrency, flows, web services, data access, testing, JVM performance and system design, from the basics to production incidents.
Questions 21-40 of 100
Q21.How do strings, templates and raw strings work in Kotlin?
Kotlin strings are immutable sequences of characters with built-in templates:
"$name has ${items.size} items"inserts values directly. Raw strings, written with triple quotes, keep line breaks and backslashes as written, which suits SQL, JSON and regular expressions.Useful features:
- Templates:
$variableand${expression}. - Raw strings:
"""...""", often withtrimIndent()ortrimMargin()to remove leading indentation. buildString: builds a string efficiently with aStringBuilderreceiver.- Text helpers:
split,lines,substringBefore,removePrefix,padStartandtoIntOrNull. - Regular expressions:
Regexwithfind,matchesandreplace.
Practical cautions:
- Never build SQL by inserting values into templates; use parameters instead.
- Case conversion and comparison for identifiers should be locale-independent:
lowercase()anduppercase()already use invariant rules. - Character counts:
lengthcounts UTF-16 code units, so some characters, such as many emoji, count as two. - Repeated concatenation in loops creates many intermediate strings; use
buildStringorjoinToString.
- Templates:
Q22.How does equality work in Kotlin: `==`, `===`, `equals` and `hashCode`?
==checks structural equality by callingequals, and it is null-safe:a == bis false when only one side is null, without throwing.===checks referential equality: whether both references point to the same object.Behavior by kind of class:
- Regular classes inherit
equalsfromAny, which compares references, so two instances with the same data are not equal. - Data classes generate
equalsandhashCodefrom their constructor properties, giving value equality. - Value classes compare their wrapped values.
Rules for custom equality:
- Override
equalsandhashCodetogether, using the same properties, so hash-based collections work. - Base equality on properties that do not change while the object is stored in a set or used as a map key.
- Be careful with arrays: arrays compare by reference; use
contentEqualsor lists instead. - Floating-point fields can make equality fragile because of rounding.
In entities with identity, such as database records, equality is often based on the ID, while data classes for values compare every property; choose deliberately for each type.
Example
Plain classes compare by reference, data classes by value and
===checks identity.class PlainSku(val code: String) data class Sku(val code: String) fun main() { println(PlainSku("LAMP") == PlainSku("LAMP")) // false println(Sku("LAMP") == Sku("LAMP")) // true val a = Sku("LAMP") val b = a println(a === b) // true println(Sku("LAMP") === Sku("LAMP")) // false val missing: Sku? = null println(missing == Sku("LAMP")) // false println(arrayOf(1, 2).contentEquals(arrayOf(1, 2))) // true }- Regular classes inherit
Q23.How do visibility modifiers and modules work in Kotlin?
Kotlin has four visibility levels:
public, the default, visible everywhere;internal, visible only within the same module;protected, visible to subclasses; andprivate, visible only inside the containing class or file. A module is a set of files compiled together, such as one build subproject.Using
internalfor architecture:- Split a service into modules, such as
orders,paymentsandapi, and mark implementation classesinternal, so other modules can depend only on public interfaces. - The compiler enforces these boundaries, which keeps a modular codebase from degrading over time.
Private at file level: top-level functions and classes marked
privateare visible only in their file, useful for helpers that should not be reused elsewhere.Good practices:
- Minimize public surface: make things
internalorprivateunless other modules genuinely need them. - Explicit API mode for library modules makes the compiler require visibility modifiers and return types on public declarations, preventing accidental exposure.
- Test visibility: tests in the same module can see
internalmembers, so you rarely need to make things public just for testing.
- Split a service into modules, such as
Q24.How does Kotlin interoperate with existing JVM libraries?
Kotlin compiles to JVM bytecode and can call any library written for the JVM directly, and those libraries can call Kotlin code. This lets teams adopt Kotlin gradually and use the large existing ecosystem, but a few differences need care.
Things to know when calling existing libraries:
- Platform types: values from code without nullability annotations have unknown nullability, shown as
String!. Kotlin lets you treat them as nullable or not, and a wrong guess causes a null pointer exception at run time; declare the type explicitly at the boundary. - Collections: read-only Kotlin collections are passed as normal mutable collections, so library code could modify them.
- Checked exceptions from libraries are not enforced by Kotlin, so remember to handle documented failures.
- Getters and setters appear as Kotlin properties.
Things to know when exposing Kotlin to other JVM code:
@JvmStaticmakes companion functions callable as static methods.@JvmOverloadsgenerates overloads for default arguments.@JvmFieldexposes a property as a plain field.@Throwsdeclares exceptions for callers that expect checked exceptions.
Gradual adoption: convert one file or module at a time, starting with tests or new code.
- Platform types: values from code without nullability annotations have unknown nullability, shown as
Q25.What does idiomatic Kotlin backend code look like?
Idiomatic Kotlin is concise but clear: immutable data, explicit types at boundaries, the type system used to rule out invalid states and expressive standard functions used where they help readability.
Core habits:
valby default, read-only collections in signatures and data classes for data.- Null safety at boundaries: convert external input into non-null domain types early.
- Sealed hierarchies and
whenfor outcomes and states, with exhaustive handling. - Value classes for identifiers and validated primitives.
- Constructor injection and small interfaces, instead of singletons with hidden state.
- Structured concurrency with coroutines, never unscoped background work.
- Expressions over statements:
if,whenandtryreturn values. - Named arguments and defaults instead of many overloads or builder classes.
- Standard library first:
buildList,groupBy,requireandcheckbefore custom utilities.
What to avoid:
!!scattered through code.- Deeply nested scope functions and clever one-liners.
- Extension functions on very general types without a clear purpose.
lateinitfor values that could be constructor parameters.
Interview tip: showing how a sealed result type replaced a set of nullable fields and flags in real code is a convincing way to demonstrate idiomatic Kotlin.
Q26.What are coroutines, and how do they differ from threads?
A coroutine is a computation that can suspend, freeing its thread while it waits, and resume later, possibly on another thread. Many thousands of coroutines can share a small pool of threads, which makes asynchronous code efficient while it still reads like ordinary sequential code.
Coroutine Thread Lightweight object managed by the coroutine runtime Heavyweight resource managed by the operating system Suspends without blocking its thread Blocks while waiting, holding its stack and memory Hundreds of thousands are practical Thousands are usually the practical limit Cancellation and scoping are built into the model Stopping a thread safely needs manual coordination What this means for backend code:
- Waiting is cheap: a request handler can suspend while a database or API call is in progress, and the thread serves other requests meanwhile.
- Suspension needs suspending APIs: a blocking call inside a coroutine still blocks the thread, so blocking work must move to a dispatcher meant for it.
- Structure matters: coroutines run inside scopes that control their lifetime, cancellation and error handling.
The language provides
suspendfunctions and the low-level machinery; the official coroutines library adds builders such aslaunchandasync, dispatchers, flows and channels.Q27.How do suspend functions work under the hood?
The compiler transforms each
suspendfunction into a state machine. It adds a hidden continuation parameter, an object that knows how to resume the function, and splits the function at each suspension point. When a call suspends, the function returns a special marker to its caller, and the thread is free; later, something callsresumeon the continuation, and execution continues from the saved state.Key ideas:
- Continuation passing: every suspend call receives the continuation of its caller, linking the chain of suspended functions.
- No thread is held while suspended, only the small state objects.
- Resumption happens wherever the code that completes the operation decides, usually on a thread chosen by the coroutine's dispatcher.
suspendalone does not make code asynchronous: a suspend function that never calls another suspending operation runs straight through like normal code.
Practical consequences:
- Suspend functions can only be called from other suspend functions or coroutine builders.
- Stack traces across suspension points can look unusual, since the call stack is rebuilt on resumption.
- Low-level primitives, such as
suspendCoroutine, connect callback-based code to suspend functions.
Example
The standard library's low-level primitives show the mechanism: the coroutine suspends, the caller continues and a later
resumefinishes it.import kotlin.coroutines.* var saved: Continuation<String>? = null suspend fun waitForReply(): String = suspendCoroutine { continuation -> println("suspended, thread is free") saved = continuation } fun main() { suspend { println("start") val reply = waitForReply() println("resumed with $reply") reply.length }.startCoroutine(Continuation(EmptyCoroutineContext) { println("finished with ${it.getOrThrow()}") }) println("main continues while the coroutine waits") saved?.resume("pong") } // Output: // start // suspended, thread is free // main continues while the coroutine waits // resumed with pong // finished with 4Q28.What is structured concurrency in Kotlin?
Structured concurrency means every coroutine runs inside a scope, and a scope does not finish until all of its child coroutines have finished. Coroutines form a tree, so cancellation flows down to children and failures flow up to parents, and no coroutine can outlive the work that started it.
flowchart TD A["Request scope"] --> B["Load profile"] A --> C["Load orders"] A --> D["Load recommendations"] C --> E["Load order items"] F["Client disconnects"] -.->|"Cancels the whole tree"| AGuarantees it gives:
- No leaks: when a request finishes or is cancelled, every coroutine it started is finished or cancelled too.
- Errors are not lost: a failing child cancels its siblings and reports the error to the parent, which rethrows it.
- Clear lifetimes: the code that starts concurrent work also waits for it, visible in one block.
How to apply it:
- Use
coroutineScope { ... }inside suspend functions to start parallel work that must complete before the function returns. - Tie long-lived background work to an application-level scope that is cancelled on shutdown.
- Avoid unscoped global coroutines, which break all of these guarantees.
Q29.How do `launch` and `async` differ?
Both start a new coroutine inside a scope.
launchis for work whose result you do not need; it returns aJobfor waiting or cancelling.asyncis for work that produces a value; it returns aDeferred<T>, andawait()suspends until the value is ready.launchasyncReturns a JobReturns a Deferred<T>with a resultUse for side effects, such as sending a notification Use for computing values in parallel, such as loading two resources Failures propagate to the parent scope immediately Failures are stored and rethrown by await, and also cancel the parent scopeRunning work in parallel:
- Inside
coroutineScope, start each independent call withasync. - Call
awaiton each to collect the results. - Total time is roughly that of the slowest call, not the sum.
Common mistakes:
- Calling
awaitimmediately after eachasync, which makes the calls sequential again. - Using
asyncwithout ever awaiting, hiding results and making failures surprising. - Starting coroutines outside a scope, such as in a global scope, so they are not cancelled with the request.
- Inside
Q30.How do dispatchers decide where coroutines run?
A dispatcher decides which thread or thread pool runs a coroutine. Choosing the right one keeps CPU work, blocking input and output and event loops from interfering with each other.
Dispatchers.DefaultDispatchers.IOLimited parallelism views Shared pool sized to the number of cores Larger elastic pool for blocking calls A dispatcher that allows at most N coroutines to run at once For CPU-intensive work, such as parsing or computation For blocking libraries, such as file access or blocking database drivers For protecting a resource, such as a blocking client with a small pool Blocking here starves CPU work Wasteful for CPU work Created with limitedParallelism(n)Switching dispatchers:
withContext(Dispatchers.IO) { ... }runs a block on another dispatcher and returns its result, then resumes on the original one.Good practices:
- Non-blocking code needs no switching: suspending HTTP or database clients work on any dispatcher.
- Wrap blocking calls in
withContext(Dispatchers.IO)at the lowest level, inside the repository or client, so callers never have to remember. - Do not block
Defaultthreads, which would slow every coroutine sharing them. - Use limited views rather than creating new thread pools, so threads are shared efficiently.
Q31.How does coroutine cancellation work, and why is it cooperative?
Cancelling a coroutine sets its job to the cancelling state and cancels its children. The coroutine stops only when it reaches a suspension point that checks for cancellation, such as
delay, a channel operation or most suspending library calls, which then throw aCancellationException. Code that never suspends or checks keeps running.Making code cancellable:
- Long CPU loops should call
ensureActive()oryield()periodically. - Blocking calls do not respond to cancellation; use interruptible variants or non-blocking APIs.
- Clean up in
finally: the cancellation exception unwinds normally, sofinallyblocks run. - Suspending cleanup inside
finallyneedswithContext(NonCancellable), since the coroutine is already cancelled.
Never swallow cancellation:
- Catching
ExceptionorThrowable, or usingrunCatching, also catchesCancellationException; if you do not rethrow it, the coroutine keeps running after being cancelled, and structured concurrency breaks. - Catch specific exception types, or rethrow
CancellationExceptionexplicitly.
Why it is cooperative: stopping code at an arbitrary point could leave data structures, locks and connections in a broken state; cooperative cancellation stops only at safe points.
- Long CPU loops should call
Q32.How do you set timeouts on coroutine work?
Wrap the work in
withTimeout(duration) { ... }, which cancels the block when the time runs out and throws a timeout exception, or inwithTimeoutOrNull, which returnsnullinstead. Because timeouts use cancellation, the work inside must be cancellable to stop on time.Choosing between them:
withTimeoutwhen exceeding the limit is an error that should propagate, such as a request budget.withTimeoutOrNullwhen a fallback is acceptable, such as returning cached data if a recommendation call is slow.
Designing timeouts:
- Budget per request: an overall timeout for the handler, with smaller timeouts for each dependency inside it.
- Base values on measured latency, such as a little above the 99th percentile of each dependency.
- Combine with client timeouts: network clients should have their own connect and read timeouts as a safety net.
- Handle partial results: decide what to return when an optional part times out.
A subtle point: the timeout exception is a kind of cancellation exception. Catch it right around the
withTimeoutcall when you want a fallback, rather than letting it escape into code that treats it as ordinary cancellation of the outer scope.Q33.How do exceptions propagate in coroutines, and what does a supervisor change?
In a regular scope, a child coroutine that fails cancels its parent, and the parent cancels all its other children, then rethrows the failure. This "one fails, all fail" behavior suits work where every part is required. A supervisor scope changes the rule: a failing child does not affect its siblings or its parent.
flowchart TD A["coroutineScope"] --> B["Child 1 fails"] A --> C["Child 2 is cancelled"] B -.->|"Failure propagates up"| A D["supervisorScope"] --> E["Child 1 fails and reports its error"] D --> F["Child 2 keeps running"]When to use each:
coroutineScope: loading data that a response needs entirely, where one failure means the whole request fails.supervisorScopeor aSupervisorJob: independent tasks, such as sending several notifications, or a long-lived scope running many unrelated jobs.
Handling failures:
asyncfailures are rethrown byawait; wrapawaitintry/catchfor a fallback.launchfailures in a supervisor go to aCoroutineExceptionHandler, which should log them.- Never let failures disappear: every top-level scope needs a handler or explicit error handling.
Q34.How do you call several services concurrently with coroutines and combine the results?
Inside
coroutineScope, start each independent call withasync, then await all of them. The calls run concurrently, the total time is roughly the slowest call and, if any required call fails, the others are cancelled automatically.A typical pattern for an aggregation endpoint:
- Start required calls, such as profile and orders, with
async. - Start optional calls, such as recommendations, with
asyncwrapped in a timeout and a fallback. - Await the results and build the response.
Handling optional parts:
- Wrap the optional call in
withTimeoutOrNulland atry/catchfor specific exceptions, returning an empty section on failure, so it never fails the whole response. - Or run optional work in a
supervisorScope, so its failure stays isolated.
Practical details:
- Pass the request's context, so cancellation and tracing information flow to every call.
- Limit fan-out for large lists of IDs, using a semaphore or batching, so you do not send hundreds of calls at once.
- Do not share mutable state between the concurrent calls; combine results after awaiting.
Interview tip: interviewers often ask what happens when one of the parallel calls fails; explaining that a regular scope cancels the siblings, and showing how you isolate optional calls, is the answer they look for.
- Start required calls, such as profile and orders, with
Q35.How do you limit concurrency in coroutine code?
Starting a coroutine per item is cheap, but the resources behind them, such as database connections or a partner's rate limit, are not. Limit how many operations run at once with a semaphore, a limited-parallelism dispatcher or a fixed number of worker coroutines reading from a channel.
Options:
Semaphore(n)withwithPermit { ... }: at mostncoroutines run the protected block at once; others suspend without blocking threads.limitedParallelism(n)on a dispatcher: at mostncoroutines run on it at the same time, useful for blocking clients.- Worker pool:
ncoroutines consume work from a channel, which also gives backpressure through the channel's capacity. - Batching: group items and send one request per batch when the downstream API supports it.
Choosing the limit:
- Base it on the bottleneck, such as the connection pool size or the partner's documented limits.
- Make it configurable, and measure waiting time as a metric.
- Combine it with timeouts, so waiting for a permit cannot last forever.
Avoid: unbounded
map { async { ... } }over thousands of items, which overwhelms dependencies and can exhaust memory.Q36.What are flows, and how do cold flows differ from hot flows?
A
Flowis an asynchronous stream of values produced over time and consumed withcollect. Flows support operators such asmap,filter,bufferanddebounce, and they respect cancellation and structured concurrency.FlowSharedFlowStateFlowCold: the producer runs separately for each collector, starting when collection starts Hot: values are emitted whether or not anyone collects, to all current collectors Hot: always holds one current value and emits changes to collectors Best for queries, paged reads and processing pipelines Best for events broadcast to many subscribers Best for observable state, such as current configuration Stops when the collector stops Configurable replay and buffer New collectors immediately receive the latest value Backend uses:
- Streaming large query results or paginated API data without loading everything.
- Processing messages from a queue with operators for batching and rate limiting.
- Broadcasting internal events, such as configuration changes.
Key rules: flows are sequential by default; use
bufferorflatMapMergefor concurrency. Exceptions are handled withcatchandretryoperators, andflowOnchanges the dispatcher for upstream operators.Q37.How do channels work for communication between coroutines?
A channel is a coroutine-safe queue: one or more coroutines
sendvalues, and othersreceivethem. Unlike a flow, a channel is a hot communication primitive; each value is received by exactly one consumer, which makes channels a good fit for distributing work.flowchart LR A["Producer coroutine"] -->|"send"| B["Channel with capacity 100"] B -->|"receive"| C["Worker 1"] B -->|"receive"| D["Worker 2"] B -->|"receive"| E["Worker 3"]Capacity choices:
- Rendezvous, the default with no buffer:
sendsuspends until a receiver takes the value. - Buffered: a fixed capacity;
sendsuspends only when the buffer is full, giving backpressure. - Conflated: keeps only the latest value, dropping older ones, suited to state updates.
- Unlimited: never suspends senders, which risks running out of memory; avoid in services.
Good practices:
- Close the channel when production ends, so consumers iterating with
for (item in channel)finish. - Use a fixed number of consumers for a worker pool.
- Prefer flows for streams with operators and a single consumer; use channels for fan-out of work and coordination between coroutines.
- Rendezvous, the default with no buffer:
Q38.How do you protect shared mutable state in coroutine code?
Coroutines on multithreaded dispatchers can run in parallel, so shared mutable state needs the same care as with threads. The best option is to avoid sharing; otherwise, use a coroutine-friendly lock, atomic values or confine the state to a single coroutine.
MutexAtomic values Confinement A lock that suspends instead of blocking while waiting Lock-free updates of single values One coroutine owns the state; others send it messages Safe around code that suspends Fast for counters and flags No locks, and ordering is clear Use withLock { ... }Use atomic types from the platform or the language Use a channel to send commands to the owner Guidelines:
- Prefer immutable data and return new values, which removes the need for locks.
- Never use thread-blocking locks around suspending calls; a blocked thread cannot run other coroutines.
- Keep critical sections short, without network calls inside them.
- Remember that
Mutexis not reentrant: locking it again from inside the same block deadlocks. - Single-threaded confinement, with a dispatcher limited to one thread, is simple for low-contention state.
Q39.Why must you not block inside coroutines, and how do you call blocking APIs safely?
Coroutines share a small number of threads. A blocking call, such as a synchronous database driver,
Thread.sleepor reading a file, holds its thread for the whole wait, so other coroutines scheduled on that thread cannot run. Under load, a few blocking calls on the default dispatcher can stall the whole service.How to call blocking APIs:
- Wrap them in
withContext(Dispatchers.IO) { ... }, which runs them on a pool designed for blocking work. - Limit parallelism for a specific blocking resource with
Dispatchers.IO.limitedParallelism(n), matching its own capacity, such as a connection pool of 10. - Prefer non-blocking libraries with suspending APIs where they exist.
- Replace
Thread.sleepwithdelay, which suspends instead of blocking.
How to find blocking calls:
- Thread dumps showing default dispatcher threads stuck in input and output.
- Latency that rises sharply under load while processor use stays moderate.
- Debug tools that detect blocking calls on threads that should not block.
Never call
runBlockinginside a request handler or another coroutine; it blocks the current thread until the inner work finishes, defeating the purpose of coroutines and risking deadlocks.- Wrap them in
Q40.How do the `sequence` builder and `yield` create lazy generators in Kotlin?
The standard library's
sequence { ... }builder creates a lazy sequence from a block that callsyield(value)to produce each element. The block suspends at everyyieldand resumes only when the consumer asks for the next element, using the same suspension machinery as coroutines, but synchronously on the caller's thread.Why it is useful:
- Generating data on demand, such as pages of results, IDs or retry delays.
- Infinite sequences that stop when the consumer stops, such as
generateSequenceor awhile (true)loop withyield. - Readable stateful iteration: local variables in the block keep their values between elements, without writing an iterator class.
Related functions:
yieldAllproduces all elements of another sequence or collection, anditerator { ... }builds an iterator the same way.Limits:
- The block can call only
yieldandyieldAllas suspending functions; it cannot call other suspend functions, such as network calls. For asynchronous streams, use flows. - Each iteration of the sequence runs the block again from the start.
Example
Pages are produced only as the consumer asks for them, so the third page is never fetched.
fun pages(totalItems: Int, pageSize: Int): Sequence<List<Int>> = sequence { var start = 1 while (start <= totalItems) { println("fetching page from $start") val end = minOf(start + pageSize - 1, totalItems) yield((start..end).toList()) start = end + 1 } } fun main() { val firstTwo = pages(totalItems = 10, pageSize = 4).take(2).toList() println(firstTwo) } // Output: // fetching page from 1 // fetching page from 5 // [[1, 2, 3, 4], [5, 6, 7, 8]]