The Real Cost of Kotlin Reflection
A startup trace showed 180ms in kotlin.reflect.jvm.internal during first frame—before any user interaction. The culprit was a DI-adjacent library walking KClass members at init to find @Serializable types. Replacing that scan with KSP-generated registries dropped cold start by 120ms and removed 1.4MB DEX. Reflection is convenient until you measure it on a mid-range phone.
Kotlin reflection (kotlin-reflect) exposes types, functions, and properties at runtime. On server JVMs the cost is often ignored. On Android and Native, metadata parsing, allocation, and ProGuard complexity make reflection a deliberate choice—not a default.
What reflection costs
- APK/DEX size — kotlin-reflect library footprint
- Cold start — classloading and cache warming on first
KClassuse - CPU — member iteration, generic type resolution
- R8 complexity — keep rules for reflected classes or face NoSuchMethodError in release
Micro-benchmarks on member lookup:
// Avoid in hot paths
fun inspect(obj: Any) {
obj::class.members.filter { it.name.startsWith("get") }
}
Each call walks metadata. Fine in a debugger tool; wrong in RecyclerView binding.
Reified generics: zero-cost at call site
inline fun <reified T> Fragment.findNavController(): NavController {
// T available at compile time in inline body
}
inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(): T? {
return if (Build.VERSION.SDK_INT >= 33) {
getParcelableExtra(key, T::class.java)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(key) as? T
}
}
reified inlines type info—no T::class field unless you explicitly reference it.
kotlinx.serialization instead of Gson reflection
Gson reflects fields at runtime. kotlinx.serialization generates codecs:
@Serializable
sealed interface PaymentResult {
@Serializable data class Success(val id: String) : PaymentResult
@Serializable data class Failure(val code: Int) : PaymentResult
}
val json = Json { classDiscriminator = "type" }
json.decodeFromString<PaymentResult>(payload)
Polymorphic without classpath scanning when registered explicitly.
KSP for registries
Generate type maps at compile time:
// Generated by KSP
object ScreenRegistry {
val routes: Map<String, () -> Screen> = mapOf(
"home" to { HomeScreen() },
"profile" to { ProfileScreen() }
)
}
Navigation and plugin systems use this pattern to drop Class.forName.
R8 and keep rules
If reflection is unavoidable:
-keep class com.example.model.** { *; }
-keepattributes RuntimeVisibleAnnotations
Missing rules cause production-only crashes. Document every reflective access.
Prefer @Keep on specific classes over broad wildcards.
JVM/server: when reflection is fine
Admin tools, test fixtures, framework internals (Spring, ktor routing with reified routes) run reflection at startup once. Profile before optimizing.
Avoid reflection in:
- Request-per-thread hot paths
- Compose recomposition
- Real-time audio/video pipelines
Migration checklist
- Audit
kotlin-reflectdependency—exclude transitive pulls - Replace Gson/Moshi reflective adapters with codegen (KSP)
- Replace manual
KClasswalks with generated catalogs - Use inline reified at API boundaries
- Measure release build startup with Macrobenchmark
Benchmark methodology
Compare cold start with Android Macrobenchmark, not microbenchmarks on desktop JVM. Reflection cost hits mobile cold start disproportionately.
Moshi codegen vs KSP
Moshi Kotlin codegen via KSP eliminates reflective adapter lookup at runtime—pair with ProGuard keep rules only for models, not entire reflect library.
What to measure after rollout
Track error rates, tail latency, and resource utilization for two weeks after changes land—most regressions appear under real traffic mixes, not in staging smoke tests. Keep a rollback path documented: feature flags, Helm revision, or Git revert with known good digest. Review on-call pages tied to the topic quarterly; delete alerts that never fire and add thresholds that would have caught your last incident.
Run a short blameless postmortem if production surprised you, even for minor issues. The goal is updating this runbook section with one concrete lesson per quarter so the next engineer inherits context, not just configuration snippets.
Documentation your team should maintain
Maintain a one-page runbook link from your main service README: prerequisites, owner rotation, last drill date, and known sharp edges. Link to vendor docs in the Resources section below but capture org-specific decisions (CIDR ranges, cluster names, approval gates) in internal docs that stay current. New hires should deploy a safe canary within a week using only that runbook—if they cannot, the doc is incomplete.
Pre-production checklist
Before promoting to production, walk through this list with someone who was not the primary author—fresh eyes catch assumptions.
- Staging parity: The staging environment exercises the same code paths as production, including failure modes you expect to handle (timeouts, retries, partial outages).
- Observability: Dashboards and alerts exist for the metrics and log patterns discussed above; on-call knows where to look first.
- Rollback: You can revert to the previous known-good state in one documented step without improvising.
- Access control: Only the principals that need access have it; audit logs are enabled where the topic touches secrets or infrastructure APIs.
- Load test: You have evidence—not intuition—about behavior at expected peak plus headroom.
If any item is "we will do that later," treat it as a release blocker for tier-1 services.
Common questions from reviewers
Reviewers and auditors often ask whether this approach scales with team growth and whether it fails safely. Answer explicitly in your design doc: what happens when dependencies are down, when credentials expire, and when traffic doubles overnight. Prefer defaults that deny or degrade gracefully over defaults that fail open. Document known limits (throughput ceilings, supported versions, regions) in the same place operators look during incidents—avoid scattering critical constraints across Slack threads.
Resources
- Kotlin reflection documentation — API scope and warnings
- R8 shrinking kotlin metadata — keep rules and reflection
- kotlinx.serialization polymorphic guide — compile-time discriminators
- KSP for code generation — replacing runtime discovery
Frequently asked questions
Why is kotlin-reflect large on Android?
The kotlin-reflect JAR embeds metadata parsers and caches for the entire Kotlin type system. It adds megabytes to APK size and initialization cost. R8 can shrink unused portions but reflection-heavy libraries still pull significant DEX weight.
When is reified type parameter acceptable instead of KClass?
Inline functions with reified T avoid runtime KClass lookups for generic type parameters at call sites. Use reified for kotlinx.serialization serializers(), Retrofit-style parsers, and internal DSLs—not for storing types in collections across non-inline boundaries.
What replaces runtime reflection for JSON polymorphism?
kotlinx.serialization with sealed class serializers generated at compile time, or explicit JsonContentPolymorphicSerializer. KSP processors can generate type maps for custom polymorphic registries without scanning classpath at runtime.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →