Five Kotlin Optimizations That Stop Garbage Before It Ships
Anyone can write code that a computer can understand. Good programmers write code that humans can understand.
— Martin Fowler
Good morning, fellow Kotlin developers. Apologies first — a bout of rhinitis plus a cold has been bothering me for a while, so the article update is a bit late.
Today I'll share a few Kotlin code optimization tricks I've been using regularly over the past few years, for everyone's benefit.
Use inline to reduce the extra overhead brought by Lambdas
Lambdas accepted by higher-order functions may need to exist as Function objects.
Lambdas that capture external variables, in particular, can generate extra object allocations when called repeatedly.
If logic like this sits inside a RecyclerView binding process or a per-frame callback, it could execute tens of thousands of times and add avoidable work for the garbage collector.
Kotlin's inline modifier is specifically designed to solve this problem. It tells the compiler to try to expand the function body together with the passed-in Lambda at the call site, thereby reducing the extra overhead caused by function calls and Lambda objects.
For example:
fun forEachVisibleChannel(action: (Channel) -> Unit) {
for (c in visibleChannels) action(c) // action may need to be passed as a Function object
}
Optimized:
inline fun forEachVisibleChannel(action: (Channel) -> Unit) {
for (c in visibleChannels) action(c) // action can be inlined at the call site
}
However, what truly shows you are awesome is not adding inline to a function, but knowing when you should not add it.
Inlining a very large function will copy the entire function body to every call site, causing generated bytecode to bloat.
If a function has neither an inlinable Lambda parameter nor a reified type parameter, Kotlin may even tell you via NOTHING_TO_INLINE that the inlining here does not bring enough benefit.
Therefore, inline is more suitable for small higher-order functions in hot code paths, rather than being slapped onto every function you see.
In practice, there is a pretty good trick: you can look at a large amount of Kotlin's official source code and reach a conclusion — if your function requires a Lambda expression, then you can ask yourself whether this place can be optimized by adding inline. (Be sure to pay attention to bytecode bloat.)
Bare Int and String → value class
When two Int parameters sit next to each other, a potential problem has already appeared.
Suppose a call site accidentally swaps channelId and frequency. The compiler will give no hint whatsoever, and you may only discover the problem when users see nothing but static after switching channels.
@JvmInline value class can establish distinct types for underlying values without introducing extra wrapper objects in common cases. In the generated code, the compiler can usually still use the Int it wraps.
For example:
fun tune(channelId: Int, frequencyHz: Int) { /* ... */ }
tune(frequencyHz, channelId) // compiles, but parameters are swapped, and there is no hint at all
Optimized:
@JvmInline value class ChannelId(val raw: Int)
@JvmInline value class FrequencyHz(val raw: Int)
fun tune(id: ChannelId, freq: FrequencyHz) { /* ... */ }
tune(FrequencyHz(101_700_000), ChannelId(7)) // fails to compile
Of course, value class is not free of runtime cost in every situation. When it is used as a generic parameter, converted to a nullable type, or in other cases requiring boxing, it may still be wrapped into a real object.
So, you can use value classes at API boundaries to improve type safety, but do not assume that object allocations disappear in all scenarios.
I previously developed a units library internally at my company to solve the problem of mixing up time units and length units, and I used exactly this method. For example:
@JvmInline value class Meter(val meter: Int) // meter
@JvmInline value class Kolimeter(val km: Int) // kilometer
Choose Sequence deliberately
For a list containing 650 channels, the following code first creates a complete intermediate list for map, then a second list for filter, and finally takes only one element via first:
channels.map { transform(it) }.filter { predicate(it) }.first { matches(it) }
Sequence uses lazy evaluation, letting each element flow through the entire operation chain in turn. Once first finds an element that satisfies the condition, subsequent processing stops immediately.
For example:
val target = channels
.map { it.toDisplayModel() } // creates a list of 650 elements
.filter { it.isHd } // creates another list
.first { it.number == wanted } // ultimately only needs one element
Optimized:
val target = channels.asSequence()
.map { it.toDisplayModel() }
.filter { it.isHd }
.first { it.number == wanted } // stops immediately upon finding, and does not create the above intermediate lists
However, another pitfall easily appears here: calling asSequence() the moment you see consecutive collection operations.
Sequence operations generally cannot inline the entire processing logic the way collection operations do, and each layer requires extra Sequence wrapping and iteration overhead.
Whether a specific Lambda produces a new object also depends on whether it captures external state and how the compiler handles it; you cannot simply assume that every step necessarily allocates a new object.
If the list has only 3 elements and you perform a single map, eagerly evaluated collection operations are usually faster because their operators can be inlined and do not need to introduce Sequence's extra mechanisms.
A simple rule of thumb to remember: when facing larger collections, longer operation chains, or operations like first and take that can terminate early, asSequence() is more likely to show its value; for small collections and very short operation chains, using collection operations directly is often more appropriate.
Of course, I wrote an article before that you can also check out — Is Sequence definitely faster than List?.
Use sealed interfaces to describe state
I still see page states like this today: an object that simultaneously holds isLoading, isPlaying, and a nullable errorMessage.
The problem with this approach is that, looking at the code alone, these mutually independent fields can combine into many states that are simply illegal.
For example, a page should not be both in a loading state and simultaneously indicate playback failure, yet the type system does not prevent you from constructing such an object.
You might even miss some page states precisely because you wrote some combinations incorrectly!
Sealed type hierarchies let the compiler check whether when covers all branches. When you add a new state later, if you forget to handle it, the problem will be exposed at compile time rather than discovered only in production.
For example:
data class PlayerState(
val isLoading: Boolean,
val isPlaying: Boolean,
val error: String? // which combinations are legal states?
)
Optimized:
sealed interface PlayerState {
data object Loading : PlayerState
data class Playing(val positionMs: Long) : PlayerState
data class Failed(val cause: Throwable) : PlayerState
}
fun render(state: PlayerState) = when (state) { // no else branch needed
PlayerState.Loading -> showSpinner()
is PlayerState.Playing -> showFrame(state.positionMs)
is PlayerState.Failed -> showError(state.cause)
}
Regarding the use of when (state), never casually add else, because not writing else is precisely the point here.
When you add a fourth state, the compiler will point out all when expressions that need to be updated synchronously. This is far more reliable than encountering an unhandled state after going live.
This matter is also documented!
Cancel coroutine tasks promptly
If you launch a GlobalScope.launch every time you switch tasks, rapid user actions will accumulate multiple tasks.
After N task switches, a previously launched task may finish even later and then overwrite the current result with a wrong one.
Moreover, this task's lifecycle may outlive the page that launched it.
Coroutine cancellation is cooperative: a task responds to cancellation only when the code reaches a suspension point or actively checks the cancellation status.
Therefore, business coroutines should normally be placed in scopes with clear lifecycles. Before starting a new tuning task, you should also cancel the previous task that has already become invalid.
For example:
fun zapTo(id: ChannelId) {
GlobalScope.launch { player.tune(id) } // lifecycle out of control, and old tasks are not cancelled
}
Optimized:
private var tuneJob: Job? = null
fun zapTo(id: ChannelId) {
tuneJob?.cancel() // cancel the expired channel-switching task
tuneJob = viewModelScope.launch { player.tune(id) }
}
For CPU-intensive loops that never suspend, the cancellation signal will not automatically stop the code. You need to call ensureActive() inside the loop, or check isActive, so that the task truly stops after being cancelled.
Three more issues I frequently flag in Code Review
Using !! everywhere → use requireNotNull for parameter validation, checkNotNull for state validation, and provide clear messages
CPU tasks using Dispatchers.IO → parsing, decoding, and computation should prefer Dispatchers.Default
Using var in state data classes → prefer val, create modified state via copy()
This does not mean requireNotNull can mechanically replace all !!: it indicates that the call parameter does not meet requirements and throws IllegalArgumentException; checkNotNull is more suitable for validating object state. More importantly, first determine why this nullable value is null, then choose a handling approach that matches the semantics.
A few thoughts
The optimizations above do not require introducing new libraries, much less rewriting the entire project.
Very often, when we write code, we need to read our own code like a compiler from the very beginning: account for every object allocation, every intermediate list, and those tasks that have not been cancelled in time.
While writing, ask yourself one more question: Will this Lambda be called repeatedly? Will this loop keep allocating temporary objects? After this coroutine is used, can it be cancelled promptly?
This is also a kind of cost awareness — first see clearly what really happens behind each line of code; if you rework it after going live, you may end up receiving customer complaints.