Supercharging Build Times: Migrating to Kotlin KSP and Optimizing Gradle for the K2 Compiler
Modern Android builds are fast - until they aren’t. If your project still leans on KAPT, unaligned Compose/Kotlin versions, or default Gradle settings, you’re likely leaving minutes on the table every build. In this post, we’ll migrate a typical Android feature from KAPT to KSP and tune Gradle for Kotlin’s K2 compiler, with production-oriented settings you can drop into your repo today.
Version prerequisites
- Android Studio Koala (2024.1.1) or newer
- Gradle 8.6+ (8.7 recommended)
- Android Gradle Plugin (AGP) 8.5+
- Kotlin 2.0.21+ (K2 is default)
- JDK 17
- KSP matching your Kotlin version (for example, 2.0.21-1.0.24)
- Compose Compiler plugin aligned with Kotlin (use the Kotlin Compose Gradle plugin)
- Libraries used in examples:
- Room 2.6.1+
- Moshi 1.15.1+ (codegen)
- kotlinx-serialization 1.7.0+
Note: Always check the Compose–Kotlin compatibility matrix. The Kotlin Compose Gradle plugin handles this for you.
Why KSP + K2
- KAPT generates Java stubs and runs javac, which breaks incremental compilation and increases IO. KSP works natively with Kotlin symbols, is more incremental, and typically reduces both clean and incremental build times.
- K2 (Kotlin 2.x) brings a faster, more memory-efficient frontend with improved incremental behavior. Combined with KSP and Gradle’s configuration/build caching, you get the biggest wins with minimal code changes.
Target feature: Offline notes (Room + Moshi)
We’ll migrate a small “Offline Notes” data layer from KAPT to KSP:
- Room for local persistence
- Moshi codegen for JSON (e.g., syncing or export/import)
- ViewModel + Flow for UI data
This represents a very common real-world setup.
Step 1: Global Gradle tuning for K2
Configure Gradle once, reap benefits in every module.
gradle.properties (project root):
# Performance
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
# JVM
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
# Kotlin/KSP (defaults are good; keep explicit for CI discoverability)
kotlin.incremental=true
kotlin.incremental.useClasspathSnapshot=true
ksp.incremental=true
# Build scan is optional but invaluable when tuning
# org.gradle.enterprise.url=https://your-gradle-enterprise
settings.gradle.kts:
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
Why it matters:
- Configuration Cache drastically reduces Gradle’s configuration time between runs.
- Build Cache reuses task outputs across runs and branches (even more so with a remote cache in CI).
- Parallel builds accelerate multi-module projects.
Step 2: Adopt the Kotlin Compose plugin (avoid version roulette)
Replace legacy composeOptions with the Kotlin Compose Gradle plugin, which pairs Compose Compiler to your Kotlin version automatically.
In the module plugins block:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose") // Handles compose compiler versioning
id("com.google.devtools.ksp")
id("org.jetbrains.kotlin.plugin.serialization")
}
Android/JVM toolchain:
android {
compileSdk = 34
defaultConfig {
minSdk = 24
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
jvmToolchain(17)
compilerOptions {
// K2 is default in Kotlin 2.x; keep settings explicit for clarity
languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0)
apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0)
progressiveMode.set(true)
}
}
Step 3: Migrate from KAPT to KSP per library
We’ll switch Room and Moshi to KSP. Keep KAPT only for libraries that don’t support KSP yet.
Before (KAPT):
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
}
dependencies {
implementation("androidx.room:room-runtime:2.5.2")
kapt("androidx.room:room-compiler:2.5.2")
implementation("com.squareup.moshi:moshi:1.14.0")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.14.0")
}
After (KSP):
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.devtools.ksp")
}
dependencies {
// Room
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// Moshi
implementation("com.squareup.moshi:moshi:1.15.1")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
}
// Room schema generation (useful for migration testing)
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.generateKotlin", "true")
arg("room.incremental", "true")
}
// For tests that need annotation processing
dependencies {
kspTest("androidx.room:room-compiler:2.6.1")
kspAndroidTest("androidx.room:room-compiler:2.6.1")
}
Notes:
- Remove the
kotlin-kaptplugin entirely in modules where nothing needs KAPT. - Do not keep both kapt(...) and ksp(...) for the same processor - this causes duplicate class errors.
- KSP generated sources live under build/generated/ksp/... and are picked up automatically by Android Studio.
What about Dagger/Hilt?
- As of Kotlin 2.0, most production apps still use KAPT for Dagger/Hilt. Keep KAPT for those modules only.
- Consider complementing with Anvil (Square) to cut KAPT scope for pure Dagger, but evaluate carefully; it’s a build-time optimization tradeoff.
Step 4: Feature code (Room + Moshi + Flow)
Entity + Moshi:
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
@Entity(tableName = "notes")
data class Note(
@PrimaryKey val id: String,
val title: String,
val content: String,
val updatedAtEpoch: Long
)
DAO:
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface NoteDao {
@Query("SELECT * FROM notes ORDER BY updatedAtEpoch DESC")
fun observeNotes(): Flow<List<Note>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(notes: List<Note>)
@Query("DELETE FROM notes WHERE id = :id")
suspend fun deleteById(id: String)
}
Database:
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [Note::class], version = 1, exportSchema = true)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
}
Repository:
import kotlinx.coroutines.flow.Flow
class NotesRepository(
private val dao: NoteDao
) {
fun notes(): Flow<List<Note>> = dao.observeNotes()
suspend fun upsertNotes(notes: List<Note>) = dao.upsert(notes)
suspend fun delete(id: String) = dao.deleteById(id)
}
DI (example with Hilt still on KAPT in a single module, or manual wiring if avoiding KAPT in this module):
import android.content.Context
import androidx.room.Room
object ServiceLocator {
@Volatile private var db: AppDatabase? = null
fun db(context: Context): AppDatabase =
db ?: synchronized(this) {
db ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"notes.db"
).fallbackToDestructiveMigration() // For demo; use real migrations in prod
.build()
.also { db = it }
}
}
ViewModel:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
class NotesViewModel(repo: NotesRepository) : ViewModel() {
val notes = repo.notes()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
Step 5: Verify build performance and cacheability
Run a clean build with a build scan:
./gradlew :app:assembleDebug --scan
- Check “Task execution” for KSP tasks (kspKotlin, kspTestKotlin, etc.) and ensure they are incremental after the first run.
- Re-run the same task: most Kotlin and KSP tasks should be up-to-date or from cache.
- Look for warnings that disable configuration cache. Fixable offenders are typically custom tasks or old plugins.
Enable Kotlin build reports (optional, helpful for CI trend tracking) in gradle.properties:
kotlin.build.report.enable=true
kotlin.build.report.output=build_scan,build_log
Step 6: CI pointers
- Use a remote build cache for ephemeral agents.
- Warm the Gradle daemon. On GitHub Actions, prefer the official Gradle Build Action which caches wrapper, jars, and configuration-cache.
- Pin Kotlin/KSP versions; a mismatch is a frequent CI flake source.
Common pitfalls and fixes
- Compose/Kotlin mismatch:
- Symptom: “This version of the Compose Compiler requires Kotlin x.y.z”
- Fix: Apply
id("org.jetbrains.kotlin.plugin.compose")and remove manual composeOptions. If you must pin a version, use the compatibility matrix and match Kotlin.
- KSP/Kotlin mismatch:
- Symptom: “KSP version 2.0.21-x is incompatible with Kotlin 1.9.y”
- Fix: KSP coordinate must match the Kotlin line, e.g.,
2.0.21-1.0.24for Kotlin 2.0.21.
- Duplicate classes after migration:
- Symptom: “Duplicate class ... generated by kapt and ksp”
- Fix: Remove kapt artifacts for processors you’ve moved to KSP. Ensure only
ksp(...)remains.
- Room schema paths:
- Symptom: Room warns about missing schema location or Migrations tests fail.
- Fix: Provide
ksp { arg("room.schemaLocation", "$projectDir/schemas") }and commit schemas. Use them in migration tests.
- Mixed KAPT/KSP modules:
- Symptom: Full-module recompiles or slower incrementals.
- Guidance: It’s safe to mix, but prefer isolating KAPT-heavy libraries (e.g., Hilt) into fewer modules and keep leaf feature modules KSP-only.
- Configuration Cache disabled:
- Symptom: Build scan shows “Configuration cache is not available.”
- Fix: Update old plugins (code coverage, linting, protobuf, etc.). For custom tasks, use the Worker API and avoid accessing project state at execution time.
- Moshi codegen still not used:
- Symptom: Reflection via
KotlinJsonAdapterFactoryin release; larger APK and slower parsing. - Fix: Ensure
@JsonClass(generateAdapter = true)and removekotlin-reflectif not otherwise needed. Verify generated adapters under build/generated/ksp/.
Optional - but effective - extras
- Prefer sealed interfaces + data classes with kotlinx-serialization where possible; the Kotlin plugin is K2-optimized and avoids extra processors.
- Keep annotation processing local to leaf modules. Core APIs should minimize annotation-driven codegen to improve cache hits across the graph.
- Use version catalogs (libs.versions.toml) and centralize plugin versions to reduce drift and breakages.
A quick migration checklist
- Upgrade toolchain: Gradle 8.6+, AGP 8.5+, Kotlin 2.0.21+, JDK 17
- Apply
org.jetbrains.kotlin.plugin.composeand remove manual composeOptions - Replace
kotlin-kaptwithcom.google.devtools.kspwhere supported - Switch Room/Moshi (and others with KSP support) to
ksp(...) - Add KSP args for Room (schemas, incremental)
- Keep KAPT only for libraries without KSP (e.g., Hilt/Dagger as of writing)
- Enable configuration and build cache
- Validate with build scans; fix blockers for configuration cache
- Monitor Kotlin build reports in CI to catch regressions
Key takeaways and next steps
- Moving from KAPT to KSP where possible yields immediate, measurable build-time wins - especially alongside Kotlin K2 and Gradle’s configuration cache.
- The Kotlin Compose Gradle plugin removes a major source of version mismatch and build flakiness.
- Keep KAPT contained to the smallest surface area; migrate processors to KSP as they become available.
- Measure everything. Build scans and Kotlin build reports will guide your next bottleneck fixes.
Next steps:
- Roll out KSP to one feature module first (e.g., your data layer) and capture baseline vs. improved numbers in CI.
- Tackle configuration-cache blockers and plugin upgrades.
- Plan a follow-up to evaluate DI options (staying with KAPT-based Hilt vs. alternatives) for further build-time gains.
If you methodically apply these changes, you’ll see faster clean builds, dramatically better incremental builds, and happier developers.
Monitor Build & App Performance in Production
Speeding up build times is only half the battle. Appxiom helps Android, iOS, and Flutter teams track app performance, catch ANRs, and trace runtime bugs directly to business impact.
Start Free Trial (No credit card required)
