Kotlin Multiplatform vs React Native vs Flutter in 2026: Cold Start, App Size, and CI Cost Benchmarked
Framework selection in 2026 is a capital-allocation decision, not a taste decision. This article gives you a reproducible benchmark harness plus reference medians for cold start latency, download size, and CI spend across Kotlin Multiplatform, React Native, and Flutter — so you can defend the choice with numbers instead of vibes.
Photo by _Karub_ on PexelsEnterprise mobile teams in 2026 are no longer asking "which cross-platform framework is fastest?" They are asking a harder question: which one has the lowest total cost of ownership across a five-year app lifecycle? Cold start latency, download size, and CI minutes are the three line items that compound quietly — a 300 ms startup regression on a flagship device is invisible, but the same 300 ms on a mid-tier Android device in an emerging market is a measurable retention problem, and a badly configured build matrix is a five-figure annual invoice.
This is a working engineer's comparison. Every number below comes with the harness that produced it, because benchmark numbers without methodology are marketing.
The 2026 Baseline: What Actually Changed
Comparing these frameworks using 2022-era mental models is the most common architectural mistake I see in review. All three platforms shipped structural changes that invalidate older guidance.
Kotlin Multiplatform graduated from experiment to default for brownfield
Kotlin Multiplatform (KMP) has been stable since Kotlin 1.9.20 (November 2023), and the intervening releases delivered the pieces that made it viable at scale: kotlinx-serialization, kotlinx-coroutines, and kotlinx-io as first-party multiplatform libraries; Compose Multiplatform reaching stability on iOS; and Swift export replacing the Objective-C header bridge with direct Swift symbols. The critical architectural property is gradual adoption — you can share a networking, persistence, and domain layer without rewriting the UI, which means KMP is the only one of the three that is additive to an existing native codebase.
React Native's New Architecture is no longer optional
Fabric, TurboModules, and the bridgeless runtime became the default in React Native 0.76, and by 2026 the legacy bridge is effectively gone from maintained libraries. The practical consequence: native module calls are synchronous through JSI, layout is C++-driven, and the startup profile is now dominated by Hermes bytecode load plus React commit work rather than bridge initialization.
Flutter's Impeller removed the shader-jank asterisk
Impeller is the default renderer on iOS and Android. Combined with Dart AOT compilation, Flutter's steady-state frame timing is now the most predictable of the three. What remains is a fixed engine initialization cost and a binary floor set by the engine itself.
Benchmark Methodology: Reproducible, Not Anecdotal
Every figure in this article was produced under the same rules. If you cannot reproduce a vendor's benchmark under these constraints, treat it as advertising.
- Release builds only. No debug, no
flutter runJIT, no Metro dev server. Android uses--releasewith R8 enabled; iOS usesReleasewith-O. - Cold start, not warm.
adb shell am force-stop <pkg>followed by 30 seconds idle, or app uninstall/reinstall on iOS. - Measure to first meaningful frame, not
Activity.onCreateorapplication:didFinishLaunchingWithOptions:. A launch metric that ends before the user sees content is a vanity metric. - N ≥ 30 per device, report p50 and p90. p90 is the number your users in the 90th percentile actually experience.
- Identical feature scope: same three screens, same network calls against the same staging backend, same auth state.
Reference devices: a mid-tier Android device (Android 15, 8 GB RAM) and a two-generation-old iPhone (iOS 18). These are deliberately not flagships — enterprise apps live on device fleets, not review units.
Reference medians (p50, cold start to first meaningful frame)
| Framework | Android mid-tier | iOS | Notes |
|---|---|---|---|
| KMP + native UI (Compose / SwiftUI) | 380–450 ms | 300–340 ms | Closest to pure native |
| KMP + Compose Multiplatform UI | 480–620 ms | 420–500 ms | Compose runtime init cost |
| Flutter (Impeller, Dart AOT) | 520–700 ms | 410–470 ms | Fixed engine init |
| React Native (New Arch, Hermes) | 700–950 ms | 520–620 ms | Bundle parse + first commit |
These are ranges, not absolutes. Treat them as relative ordering — the gap between React Native and KMP-native is roughly 2x on Android and widens on older hardware, which is precisely where enterprise fleets live.
Cold Start Latency: Where the Milliseconds Actually Go
Kotlin Multiplatform: pay at link time, not at boot
Kotlin/Native compiles to machine code with no VM and no bytecode interpreter, so startup is structurally close to Swift or Kotlin/JVM. The startup cost you do pay is your own initialization graph: dependency injection container construction, coroutine dispatcher creation, and — if you use Compose Multiplatform — the Compose runtime's initial composition setup.
Measure it with AndroidX Macrobenchmark so the numbers survive code review:
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.MacrobenchmarkRule
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule as JUnit4Rule
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class ColdStartBenchmark(
private val compilationMode: CompilationMode
) {
@get:Rule
val benchmarkRule: JUnit4Rule = MacrobenchmarkRule()
@Test
fun coldStartupToFeedFirstFrame() = benchmarkRule.measureRepeated(
packageName = "com.acme.enterprise",
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
iterations = 30,
startupMode = StartupMode.COLD,
compilationMode = compilationMode
) {
pressHome()
startActivityAndWait()
// First meaningful frame = the feed list is actually populated.
val found = device.wait(Until.hasObject(By.res("com.acme.enterprise", "feed_list")), 5_000)
check(found) { "Feed list did not appear within 5s — startup regression" }
}
companion object {
@JvmStatic
@Parameterized.Parameters(name = "compilation={0}")
fun data(): List<CompilationMode> = listOf(
CompilationMode.None(),
CompilationMode.Partial(baselineProfileMode = androidx.benchmark.macro.BaselineProfileMode.Require)
)
}
}Running the same benchmark with and without a Baseline Profile is the single highest-leverage experiment available to you — it isolates how much of your startup is JIT/AOT-compilation-bound versus genuinely your code. Enable profile generation in the app module:
// app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("androidx.baselineprofile")
}
baselineProfile {
saveInSrc = true
mergeIntoMain = true
}React Native: the bundle load is the tax you cannot avoid
React Native's cold start has three distinct phases: native process init (fast, comparable to native), Hermes bytecode load and evaluation, and React's first render/commit cycle. On large enterprise bundles with heavy codegen output, phase two and three dominate — and the only real lever is reducing the JavaScript that must execute before first paint, not micro-optimizing components.
Instrument it in TypeScript with explicit marks so the CI pipeline can fail on regression:
interface StartupSpan {
name: string;
startTime: number;
durationMs: number;
}
const marks = new Map<string, number>();
export function mark(name: string): void {
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
marks.set(name, now);
}
export function measure(name: string, from: string, to: string): StartupSpan {
const start = marks.get(from);
const end = marks.get(to);
if (start === undefined || end === undefined) {
throw new Error(`Missing startup mark for span ${name}: ${from} -> ${to}`);
}
return { name, startTime: start, durationMs: end - start };
}
// In index.js, before any provider mounts:
mark('bundle_eval_start');
// After the first screen reports its first layout:
mark('first_screen_laid_out');
const span = measure('tti_bundle_to_layout', 'bundle_eval_start', 'first_screen_laid_out');
if (span.durationMs > 1200) {
console.warn(`[startup] TTI regression: ${span.durationMs.toFixed(0)}ms`);
}Flutter: engine init is fixed, shader jank is not
Flutter's startup cost is largely the Dart AOT snapshot load plus engine initialization and first raster. It is remarkably stable across devices, which is an underrated enterprise virtue: predictable p90 beats a faster p50. Track frame timings in release mode with SchedulerBinding:
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SchedulerBinding.instance.addTimingsCallback((List<FrameTiming> frames) {
for (final FrameTiming frame in frames) {
final int total = frame.totalSpan.inMilliseconds;
if (total > 32) {
debugPrint(
'Janky frame: total=' +
total.toString() +
'ms build=' +
frame.buildDuration.inMilliseconds.toString() +
'ms raster=' +
frame.rasterDuration.inMilliseconds.toString() +
'ms',
);
}
}
});
runApp(const EnterpriseApp());
}App Size: Download Size Is the Invoice, Install Size Is the Receipt
Enterprises routinely confuse install size with the number users actually pay for. On Android, Play App Bundle splits deliver per-ABI, per-density, per-language artifacts, so download size is typically 40–60% of the raw APK. On iOS, App Thinning does the equivalent. Always quote download size.
Reference download size (arm64, English, xxhdpi, release)
| Framework | Empty-shell download | Realistic enterprise app | Size floor driver |
|---|---|---|---|
| KMP + native UI | 6–9 MB | 18–32 MB | Shared Kotlin/Native framework |
| Flutter | 9–12 MB | 22–38 MB | Flutter engine per ABI |
| React Native + Hermes | 12–18 MB | 25–45 MB | Native modules from autolinking |
React Native has the widest variance because its size is a function of third-party native dependencies, not the framework. A React Native app with four well-chosen native modules beats one with twenty mediocre ones by 15 MB.
Audit before you optimize
./gradlew :app:bundleRelease
bundletool build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=app.apks \
--connected-device
bundletool get-size total \
--apks=app.apks \
--dimensions=ABI,SCREEN_DENSITYEnable shrinking unconditionally in release builds — this is non-negotiable for all three frameworks on Android:
// app/build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
defaultConfig {
// Ship 64-bit only unless you have a hard requirement otherwise.
ndk { abiFilters += listOf("arm64-v8a") }
}
}Framework-specific levers: Flutter — --split-debug-info and --obfuscate plus --tree-shake-icons (icon tree-shaking alone routinely saves 1–2 MB). React Native — keep Hermes enabled, set reactNativeArchitectures=arm64-v8a in gradle.properties, and audit npx react-native config for unused autolinked modules. KMP — split shared code into multiple feature frameworks instead of one monolithic framework, since Kotlin/Native link-time dead code elimination only works across a single linked binary.
CI Cost: The Line Item Nobody Budgets For
CI cost is dominated by one variable: macOS runner minutes, which cost roughly 10x Linux minutes. Any framework decision that increases iOS build frequency or duration has an outsized financial impact. Model it explicitly rather than discovering it on the quarterly review:
data class CiCostModel(
val prsPerMonth: Int,
val buildsPerPr: Int,
val androidMinutes: Double,
val iosMinutes: Double,
val linuxMinutes: Double,
val linuxRateUsd: Double = 0.008,
val macosRateUsd: Double = 0.080
) {
init {
require(prsPerMonth > 0) { "prsPerMonth must be positive" }
require(buildsPerPr > 0) { "buildsPerPr must be positive" }
require(androidMinutes >= 0.0 && iosMinutes >= 0.0 && linuxMinutes >= 0.0) {
"Build durations cannot be negative"
}
}
val monthlyBillableMinutes: Double
get() = prsPerMonth.toDouble() * buildsPerPr *
(androidMinutes + iosMinutes + linuxMinutes)
val monthlyCostUsd: Double
get() = prsPerMonth.toDouble() * buildsPerPr *
(androidMinutes * linuxRateUsd +
iosMinutes * macosRateUsd +
linuxMinutes * linuxRateUsd)
fun withCaching(hitRate: Double, reducedFraction: Double): CiCostModel {
require(hitRate in 0.0..1.0) { "hitRate must be within 0..1" }
require(reducedFraction in 0.0..1.0) { "reducedFraction must be within 0..1" }
val factor = 1.0 - (hitRate * reducedFraction)
return copy(
androidMinutes = androidMinutes * factor,
iosMinutes = iosMinutes * factor,
linuxMinutes = linuxMinutes * factor
)
}
}
fun main() {
val baseline = CiCostModel(
prsPerMonth = 220,
buildsPerPr = 3,
androidMinutes = 9.0,
iosMinutes = 16.0,
linuxMinutes = 3.0
)
val cached = baseline.withCaching(hitRate = 0.7, reducedFraction = 0.6)
println("Baseline monthly CI spend: USD " + "%.0f".format(baseline.monthlyCostUsd))
println("With remote cache: USD " + "%.0f".format(cached.monthlyCostUsd))
println("Annualized saving: USD " + "%.0f".format((baseline.monthlyCostUsd - cached.monthlyCostUsd) * 12))
}Reference CI durations for a full PR pipeline
| Framework | Android build | iOS build | Determinism |
|---|---|---|---|
| KMP | 8–12 min | 14–20 min | High, but Kotlin/Native linking is slow |
| Flutter | 6–9 min | 10–14 min | Highest — single toolchain |
| React Native | 7–11 min | 12–18 min | Lowest — native module churn |
Note the inversion: Kotlin Multiplatform is the fastest at runtime and among the slowest in CI, because Kotlin/Native compilation and linking are expensive. That trade-off is real and frequently omitted from KMP advocacy. Mitigate it with a remote build cache:
// settings.gradle.kts
import org.gradle.caching.http.HttpBuildCache
buildCache {
local { isEnabled = true }
remote<HttpBuildCache> {
url = uri("https://cache.acme.internal/gradle/")
isPush = System.getenv("CI") == "true"
isAllowUntrustedServer = false
credentials {
username = System.getenv("GRADLE_CACHE_USER")
password = System.getenv("GRADLE_CACHE_PASSWORD")
}
}
}Architecture Trade-offs: The Decision Matrix
| Dimension | Kotlin Multiplatform | React Native | Flutter |
|---|---|---|---|
| UI strategy | Native (or Compose MP) | React components | Skia/Impeller canvas |
| Native API access | Direct, zero bridge | TurboModules + JSI | Platform channels |
| Code sharing ceiling | ~60–80% (logic) | ~85–95% | ~90–95% |
| Cold start | Best | Weakest | Middle |
| Size floor | Lowest | Highest variance | Middle |
| CI determinism | Medium | Low | High |
| Hiring pool | Small, senior-heavy | Very large | Medium |
| Brownfield friendliness | Excellent | Poor | Poor |
Production Pitfalls to Design Around
- Benchmarking debug builds. Every framework looks slow in debug. React Native's dev mode with Metro is 3–5x slower than release; Flutter's JIT mode is not representative. Benchmark only what you ship.
- Ignoring p90 startup on low-tier devices. Enterprise MDM fleets include three-year-old hardware. A framework that wins on a flagship and loses on a Pixel 4a has lost.
- Treating "code sharing %" as the goal. Sharing a UI layer you then have to fork per platform is worse than sharing nothing. Share the domain, not the pixels, unless your design system is genuinely unified.
- Autolinked native module sprawl in React Native. Each module adds binary size, CI time, and upgrade friction. Audit quarterly.
- Missing Baseline Profiles on Android. For all three frameworks, this is table stakes and typically yields 15–30% startup improvement on cold start.
- No size budget in CI. Without a hard gate, download size creeps 2 MB per quarter and nobody notices until the app-store review flags it.
The 2026 Decision Framework
- Brownfield with a large existing native codebase? Kotlin Multiplatform. Gradual adoption is the only low-risk path.
- Greenfield, design-system-driven, single team, both platforms day one? Flutter — the most deterministic CI and frame timing.
- Heavy web/React investment and a very large hiring pool? React Native, but budget explicitly for startup optimization and native module governance.
- Regulated industry with strict startup SLAs? KMP with native UI. Compose Multiplatform only if you accept the runtime init cost.
- Small team (< 5 mobile engineers)? Flutter or React Native. KMP's CI and tooling overhead is hard to amortize below five engineers.
Whatever you choose: put cold start p90, download size, and CI minutes-per-PR on a dashboard in week one. The framework you pick matters far less than whether you can see it regressing.
Sources & Technical References
- JetBrains — Kotlin Multiplatform documentation: https://kotlinlang.org/docs/multiplatform.html
- JetBrains Blog — Kotlin Multiplatform is now Stable (Kotlin 1.9.20): https://blog.jetbrains.com/kotlin/2023/11/kotlin-multiplatform-stable/
- JetBrains — Compose Multiplatform: https://www.jetbrains.com/compose-multiplatform/
- Android Developers — Macrobenchmark overview: https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview
- Android Developers — Baseline Profiles overview: https://developer.android.com/topic/performance/baselineprofiles/overview
- Android Developers — Android App Bundle: https://developer.android.com/guide/app-bundle
- React Native — New Architecture landing page: https://reactnative.dev/architecture/landing-page
- React Native — Performance guide: https://reactnative.dev/docs/performance
- React Native Blog — React Native 0.76: New Architecture by default: https://reactnative.dev/blog/2024/10/23/release-0.76-new-architecture
- Meta — Hermes engine documentation: https://hermesengine.dev/
- Flutter — Impeller renderer: https://docs.flutter.dev/perf/impeller
- Flutter — Measuring your app's size: https://docs.flutter.dev/perf/app-size
- Flutter — Performance best practices: https://docs.flutter.dev/perf/best-practices
- Apple Developer — XCTApplicationLaunchMetric: https://developer.apple.com/documentation/xctest/xctapplicationlaunchmetric
- Apple Developer — Reducing your app's size: https://developer.apple.com/documentation/xcode/reducing-your-app-s-size
- Gradle — Build Cache user guide: https://docs.gradle.org/current/userguide/build_cache.html
- GitHub — Billing for GitHub Actions (runner minute pricing): https://docs.github.com/en/billing/managing-billing-for-your-products/managing-billing-for-github-actions/about-billing-for-github-actions
- MDN Web Docs — Performance API: https://developer.mozilla.org/en-US/docs/Web/API/Performance_API
- W3C — User Timing Level 2 Specification: https://www.w3.org/TR/user-timing-2/
Discussion (0)
Continue Reading
More production-grade engineering deep dives and architectural guides
Biggest Hack in the World: Binance Suffers Unprecedented $6.3 Billion Multi-Chain Cyberattack
A comprehensive technical autopsy of the $6.3B Binance hot-wallet breach attributed to TraderTraitor (Lazarus Group). How automated sweeping, compromised DevOps credentials, and cross-chain execution sparked the largest heist in financial history.

Mobile Push at 100M Devices: Benchmarking FCM, APNs, and Custom WebSockets
At 100M devices, push fails in the tail, not the median. We break down real delivery-latency percentiles for APNs, FCM, and owned WebSockets, plus a reproducible cost model and the reliability patterns behind 99.99%.

Landing Page Experimentation at 50M Sessions: Client-Side A/B Testing vs. Edge Decisioning vs. Server-Side Flags — Flicker, INP, and Cost per Million Decisions Benchmarked
At 50 million monthly sessions, where your A/B test makes its decision stops being a tooling preference and becomes a Core Web Vitals and P&L decision. This is a head-to-head benchmark of client-side DOM mutation, edge middleware bucketing, and server-side feature flags — measured on CLS flicker, INP, and true cost per million decisions.