Why is kapt's stub-generation step a performance concern, and what does it cost at the build level?
answer
- Stub gen = extra near-full compile pass
- stub-gen + process + compile (3 passes)
- Stubs require full type resolution
- Hurts incrementality; KSP ~2x faster
- kapt.incremental.apt / use.worker.api
basics
~10 sBefore processing, kapt makes kotlinc produce extra Java placeholder files for your whole codebase. Generating those files runs an additional, expensive compilation pass, which makes builds noticeably slower.
solid answer
~50 skapt cannot hand Kotlin to a Java processor directly, so it first runs a **stub-generation** pass: the Kotlin compiler analyzes your sources and emits Java stub files (signatures with placeholder bodies) for the code the processors need to see. That stub pass is essentially an extra round of front-end compilation — full type resolution to produce accurate Java signatures — on top of the normal Kotlin compile and the processing itself. The result is multiple passes over the code, more I/O, and reduced incrementality, so kapt builds are commonly far slower than KSP, which inspects Kotlin symbols directly with no stubs. Practical knobs: keep `kapt.use.worker.api` on, enable incremental annotation processing (`kapt.incremental.apt=true`, default true), and `kapt.include.compile.classpath=false` to avoid scanning the compile classpath for processors. The real fix is migrating to a KSP processor where one exists.
code
kotlin · 11 lines// gradle.properties — kapt tuning knobs
// kapt.use.worker.api=true
// kapt.incremental.apt=true
// kapt.include.compile.classpath=false
// build.gradle.kts — pass arguments to a processor
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}go deeper
Knows kapt builds are slower because it generates extra Java files first.
Explains the stub-gen pass as a near-full compilation and names the three passes plus the incrementality hit.
Connects the overhead to lost incrementality, cites the tuning flags, and frames KSP migration as the real fix.
Reasons about build-graph impact across many modules and weighs migration cost vs. processor KSP availability for a large codebase.
## The cost comes from an extra compilation pass kapt's overhead is structural, not a tuning bug. Because a Java annotation **processor** (implementing `javax.annotation.processing.Processor`) only understands Java `Element`s, kapt must synthesize them. It does so via **stub generation**: 1. **Stub generation pass** — kapt invokes the Kotlin compiler front-end to fully **resolve types** for your sources, then writes **Java stub `.java` files**: real signatures (classes, methods, fields, annotations) with **placeholder bodies** (e.g. `throw new RuntimeException("Stub!")`). Producing *accurate* Java signatures requires near-complete semantic analysis, so this is close to a second compile. 2. **Annotation processing pass** — `javac`'s processing environment runs the registered processors over the stubs; they generate code. 3. **Kotlin compilation pass** — your real Kotlin plus generated sources are compiled to bytecode. So a kapt module does **stub-gen + process + compile**, where a plain Kotlin module does just one compile. That is why kapt is the classic build-time bottleneck. ## Why it hurts incrementality too A change can invalidate stubs broadly, forcing re-stubbing and re-processing. KSP, by contrast, reads Kotlin **symbols** directly (no stubs) and supports finer incremental processing, which is the main reason KSP builds are typically ~2x faster for the same processor logic. ## Tuning options (mitigations, not cures) ```kotlin // gradle.properties kapt.use.worker.api=true // run kapt in Gradle worker (default true) kapt.incremental.apt=true // incremental annotation processing (default true) kapt.include.compile.classpath=false // don't scan compile classpath for processors ``` - `kapt.use.worker.api` runs kapt inside the Gradle Worker API for better parallelism/isolation. - `kapt.incremental.apt` enables incremental APT; it falls back to non-incremental if a processor isn't incremental-friendly. - `kapt.include.compile.classpath=false` stops kapt from searching the **compile** classpath for processors, which both speeds builds and avoids accidentally running processors you only meant to use at compile time. - Per-processor flags go via `kapt { arguments { arg("key", "value") } }`. ## The structural fix None of these remove the stub pass. The real remedy is **migrating to KSP** for processors that ship a KSP variant (Room, Moshi, many Dagger/Hilt paths). kapt remains only as the fallback for processors with no KSP support. ## Keywords `kotlin-kapt`, stub generation, `kapt.incremental.apt`, `kapt.use.worker.api`, `kapt.include.compile.classpath`, KSP symbols (no stubs).
- Why is generating stubs almost as expensive as compiling?Producing correct Java signatures requires the Kotlin front-end to fully resolve types and symbols, so the stub pass does most of the semantic analysis a real compile does.
- Does enabling kapt.incremental.apt always make processing incremental?No. If a processor isn't written to be incremental-aware (no incremental annotation processing support), kapt falls back to processing everything, losing the benefit.
kapt is like photocopying every page of a book before letting a reader who only reads photocopies skim it for highlights — the copying itself is the slow part.
saying these in an interview costs you the question
- Claiming kapt is slow only because of disk I/O, ignoring the resolution/stub-gen compile pass
- Saying tuning flags eliminate the stub pass (they only mitigate)
- Believing kapt is incremental-free or fully incremental — reality is partial and processor-dependent
- Asserting KSP is faster only because of caching rather than because it skips stubs