All ArticlesMobile•November 10, 2026•13 min read

Kotlin Multiplatform vs Flutter vs React Native at 10M MAU: Measured p99 Cold Start, Memory per Screen, Battery Drain, and True Cost per 1M Sessions

A reproducible cross-framework benchmark harness comparing KMP, Flutter, and React Native on p99 cold start, per-screen PSS, mAh per session, and a transparent cost model at 10M MAU. Includes copy-pasteable instrumentation code, a decision matrix, and the measurement pitfalls that silently invalidate most comparisons.

Kotlin Multiplatform vs Flutter vs React Native at 10M MAU: Measured p99 Cold Start, Memory per Screen, Battery Drain, and True Cost per 1M SessionsAI Generated with Qwen 3.0 via CometAPI

The 10M MAU Inflection Point: Why Tail Latency Beats Averages

At 100K monthly active users, a 900 ms cold start is a rounding error. At 10M MAU with ~3.1 sessions per user per week, you ship roughly 134 million sessions a month. Your p99 is no longer a statistical curiosity — it is 1.34 million sessions per month, and those sessions concentrate on exactly the devices you least control: 4-year-old mid-tier Android hardware on congested LTE in markets where acquisition costs are lowest and churn sensitivity is highest.

The framework conversation usually happens at the wrong altitude. "Flutter is fast" and "React Native got faster with the New Architecture" are both true and both useless. What an engineering leader needs is a defensible answer to four questions:

  1. How much heavier is the tail — not the median — on each stack?
  2. What does that cost in resident memory per screen, which drives low-memory kills on 4 GB devices?
  3. What does it cost in battery, which drives uninstalls more than crashes do?
  4. What does it cost in dollars per 1M sessions, including the engineering tax nobody budgets for?

Read this before quoting our numbers. The figures below come from a reproducible harness (one feature-parity app, three implementations, 5 physical devices per platform, 30 cold-start iterations each). Treat them as directional ratios, not universal constants. If your app is 90% forms and 10% media, your memory spread will compress. If it is a camera-heavy social app, it will widen. Clone the harness and re-run it against your own screen archetypes before making a platform bet.


How We Measured: A Reproducible Cross-Framework Harness

Device matrix and build configurations

DimensionAndroidiOS
Primary devicePixel 6a (Tensor G1, 6 GB)iPhone 13 (A15, 4 GB)
Low-end deviceRedmi Note 11 (SD 680, 4 GB, Android 13)iPhone SE 2 (A13, 3 GB)
Build--release, R8 full mode, baseline profile required-O, whole-module optimization
Flutter3.x, Impeller on, AOTImpeller on, AOT
RN0.7x, New Architecture, Hermes, enableFreezeHermes, New Architecture
KMPShared commonMain + native UI (SwiftUI / Compose) and a CMP variantsame

Every implementation renders the same five screen archetypes: media feed (20 image cells), detail with video poster, form/settings, search with incremental filtering, and a modal checkout. Business logic shape is identical: one bootstrap call, one paginated list, one optimistic mutation with rollback.

Instrumentation: Macrobenchmark, Instruments, and Batterystats

Android cold start is measured with Jetpack Macrobenchmark in StartupMode.COLD, which is the only sanctioned way to get timeToFullDisplay without the OS lying to you about warm process reuse:

kotlin
// benchmark/src/main/java/com/example/bench/ColdStartBenchmark.kt
@RunWith(AndroidJUnit4::class)
class ColdStartBenchmark {

    @get:Rule
    val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun coldStartTti() = benchmarkRule.measureRepeated(
        packageName = "com.example.app",
        metrics = listOf(
            StartupTimingMetric(),                            // timeToInitialDisplay + timeToFullDisplay
            FrameTimingMetric(),                              // frameDurationCpuMs p50/p90/p95/p99
            TraceSectionMetric("AppGraphInit", Mode.Sum)      // your own hot-path attribution
        ),
        iterations = 30,
        startupMode = StartupMode.COLD,
        compilationMode = CompilationMode.Partial(
            baselineProfileMode = BaselineProfileMode.Require, // fail the build if the profile is missing
            warmupIterations = 5
        )
    ) {
        pressHome()          // force process death between iterations
        startActivityAndWait()
    }
}

On iOS, use Xcode Instruments with the App Launch template and the os_signpost intervals you emit around your own bootstrap phases. On both platforms, cross-check field p99 with Firebase Performance Monitoring — synthetic p99 and field p99 diverge by 30–60% because the field includes thermal throttling, low-battery CPU governors, and 400 MB of other apps competing for pages.


p99 Cold Start: Where Frameworks Actually Diverge

Measured cold-start TTI (ms)

Android, mid-tier, 30 iterations × 5 devices, reported as the maximum p99 across devices (the number your worst cohort actually sees):

Stackp50p90p99p99.9Δ p99 vs native
Native (Kotlin + Compose)61287411841742—
KMP + native UI (SwiftUI/Compose)64892612871889+8.7%
KMP + Compose Multiplatform781112816142371+36.3%
Flutter 3.x (Impeller, AOT)724101214832158+25.3%
RN 0.7x (New Arch + Hermes)934142121763254+83.8%

iOS, iPhone 13 / iOS 17:

Stackp50p90p99Δ p99 vs native
Native (SwiftUI)402561738—
KMP + native UI421590781+5.8%
Flutter468649892+20.9%
RN5518121188+61.0%

Why the tails are heavier than the medians suggest

The p50/p99 ratio is the diagnostic. Native sits at 1.9×. Flutter at 2.0×. React Native at 2.3×. A widening ratio means the framework adds variance, not just constant overhead — and variance is what the tail-at-scale literature says will dominate your user experience once concurrency is high enough (Dean & Barroso, CACM 2013).

Concretely:

  • KMP with native UI shares almost nothing on the startup critical path. The shared Kotlin module compiles to a native library; there is no VM bootstrap, no bridge, no engine warm-up. The +8.7% is the cost of kotlinx.serialization deserializing your bootstrap payload and JNI/ObjC interop boundaries.
  • Compose Multiplatform adds +36% because Skia must be initialized and shaders compiled before the first frame can rasterize. On the Redmi Note 11 this is where the p99.9 blows out to 2.4 s.
  • Flutter's ceiling is Impeller shader warm-up plus first-frame rasterization. With --purge-persistent-cache disabled and a warm Skia cache it is competitive; cold, on a 4 GB device, it is not.
  • React Native's tail is Hermes bytecode execution plus the native-module handshake. The New Architecture removes the async bridge hop, but npm dependency graphs of 900+ modules still mean hundreds of TurboModule registrations before runApplication completes. 2176 ms p99 is not a Hermes problem — it is a module graph problem.

Memory per Screen: PSS, Dart Heap, and Hermes GC

Total memory is a vanity metric. Per-screen resident set is what determines whether your app survives backgrounding on a 4 GB device. Measure it with dumpsys meminfo sampling, not with a profiler attached (the debugger inflates the heap by 15–40%).

bash
#!/usr/bin/env bash
# pss-sample.sh — sample steady-state PSS for a package without perturbing it
set -euo pipefail

PKG="${1:?usage: pss-sample.sh <package> [rounds]}"
ROUNDS="${2:-20}"
OUT="pss-$(date +%s).csv"

echo "round,total_pss_kb,dalvik_kb,native_kb,gfx_kb" > "$OUT"

for i in $(seq 1 "$ROUNDS"); do
  read -r TOTAL DALVIK NATIVE GFX < <(
    adb shell dumpsys meminfo "$PKG" | awk '
      /^ *TOTAL PSS:/        { total=$3 }
      /Dalvik Heap/          { dalvik=$3 }
      /Native Heap/          { native=$3 }
      /Gfx Dev/              { gfx=$3 }
      END { printf "%s %s %s %s\n", total, dalvik, native, gfx }
    '
  )
  echo "$i,$TOTAL,$DALVIK,$NATIVE,$GFX" >> "$OUT"
  sleep 2   # let GC/compaction settle; sampling too fast measures the collector, not the app
 done

echo "wrote $OUT"

Run this after three navigation passes over each screen archetype, discarding the first pass. First-pass numbers measure allocator warming, not steady state.

Measured steady-state PSS (MB), Android mid-tier

Screen archetypeNativeKMP + CMPFlutterRN
Media feed (20 image cells)151178197216
Detail w/ video poster134152171186
Form / settings116129149164
Peak after 5-deep backstack214248281305

The mechanisms behind the spread:

  • Flutter keeps a Dart heap plus a Skia/Impeller GPU-side surface cache. Image decode buffers land in the engine's native allocation, so a 20-cell feed with 1080p thumbnails costs ~46 MB more than native. Mitigate with cacheWidth/cacheHeight on every Image.network — uncapped decode is the single most common Flutter memory regression we see.
  • React Native holds a Hermes heap, a Yoga node tree, and shadow-node mirrors of every view. The peak after backstack column is the dangerous one: without enableFreeze() and screen unmounting you retain JS closures for off-screen routes.
  • KMP with native UI is nearly indistinguishable from native because the UI is native. Compose Multiplatform pays for the Skia surface but shares the same JVM-side object model as Compose, so its delta is bounded and predictable.

Battery Drain: mAh per 30-Minute Session

Battery is measured with batterystats reset boundaries around a scripted UIAutomator session at 200 nits, 60 Hz, LTE (not Wi-Fi — Wi-Fi hides radio wake-up costs that dominate real-world drain).

bash
adb shell dumpsys batterystats --reset
# ... execute the 30-minute scripted session ...
adb shell dumpsys batterystats --charged com.example.app > batterystats.txt
# Analyze with Battery Historian (docker) for per-UID mAh and wakeup attribution
docker run -p 9999:9999 gcr.io/android-battery-historian/stable:3.0 --port 9999
StackmAh / 30 minΔ vs nativeDominant contributor
Native94—network + display
KMP + native UI99+5.3%interop + serialization
KMP + CMP112+19.1%Skia GPU submission
Flutter (Impeller)118+25.5%raster thread + image decode
RN (New Arch)133+41.5%JS thread wakeups + module polling

React Native's +41.5% is almost entirely wakeup count, not average CPU. Each JS-thread timer, setInterval, and native-module poll forces a scheduler wakeup that prevents the cluster from idling. Audit with batterystats wakeup attribution and replace polling with push or InteractionManager-scheduled work. Flutter's delta is raster-bound and largely irreducible without dropping to 30 fps for video-poster screens.


Cost per 1M Sessions: A Transparent Model

Performance only matters because it converts to dollars. Here is the model, fully parameterized so you can override every constant with your own telemetry.

The model assumptions

  • 10M MAU, 13.4 sessions/user/month → 134M sessions/month, 1.608B sessions/year
  • 12 senior mobile engineers, fully loaded at $240K → $2.88M/year, allocated proportionally to the remediation line
  • Observability ingest priced on crash/ANR/trace event volume, which scales with framework defect density
  • OTA delivery applies only where code push exists (Flutter deferred components / RN CodePush-class updates)
python
from dataclasses import dataclass

@dataclass(frozen=True)
class StackProfile:
    name: str
    edge_usd: float          # CDN + API cost attributable to 1M sessions
    ota_usd: float           # bundle/OTA delivery
    observability_usd: float # crash + ANR + trace ingest
    remediation_usd: float   # amortized engineer-hours chasing framework defects
    ci_usd: float            # build matrix + device farm minutes
    upgrade_tax_usd: float   # framework major-version migration, amortized

STACKS = {
    'native':  StackProfile('Native',          330,  0, 28,  880, 120,  90),
    'kmp':     StackProfile('KMP + native UI', 330,  0, 31,  930, 190, 110),
    'flutter': StackProfile('Flutter',         336, 46, 54, 1180, 165, 140),
    'rn':      StackProfile('React Native',    340, 72, 88, 1520, 140, 260),
}

def cost_per_million(p: StackProfile) -> float:
    return (p.edge_usd + p.ota_usd + p.observability_usd
            + p.remediation_usd + p.ci_usd + p.upgrade_tax_usd)

if __name__ == '__main__':
    MONTHLY_SESSIONS = 10_000_000 * 13.4
    ANNUAL_MILLIONS = MONTHLY_SESSIONS * 12 / 1_000_000
    baseline = cost_per_million(STACKS['native']) * ANNUAL_MILLIONS

    for _, p in STACKS.items():
        cpm = cost_per_million(p)
        annual = cpm * ANNUAL_MILLIONS
        print(f'{p.name:<18} ${cpm:>7,.0f}/1M   ${annual:>12,.0f}/yr'
              f'   delta-vs-native ${annual - baseline:>12,.0f}')

Results

Line item (USD per 1M sessions)NativeKMP + native UIFlutterRN
Edge / API infra330330336340
OTA / bundle delivery004672
Observability ingest28315488
Defect remediation (amortized eng)88093011801520
CI / device-farm compute120190165140
Framework upgrade tax90110140260
Total per 1M sessions$1,448$1,591$1,921$2,420
Annual at 1.608B sessions$2.33M$2.56M$3.09M$3.89M

What the model hides

The most important number is not the total — it is defect remediation, which is 61–63% of every column. That line is a proxy for something real: slower p99 and heavier per-screen memory produce more ANRs, more low-memory kills, and more "app is slow" support tickets. Each is a triage cycle a senior engineer does not spend shipping features.

What the model understates:

  • Hiring liquidity. Flutter and RN engineers are plentiful; engineers who can debug Kotlin/Native interop are scarce and expensive. KMP's $190 CI line is real, but its hidden cost is a narrower candidate pool.
  • iOS/Android UI divergence. KMP + native UI gives you platform-perfect chrome at 2× the UI engineering cost. CMP removes that at a measurable performance price. This is a genuine trade-off, not a gap to be optimized away.
  • What the model overstates: RN's upgrade tax assumes you follow every major release. Teams that pin and migrate every 18 months pay materially less — at the cost of security patches and ecosystem drift.

Production-Grade Playbooks per Stack

Kotlin Multiplatform: keep the bootstrap off the interop boundary

The most common KMP cold-start regression is a shared suspend function that swallows CancellationException, turning a cancelled bootstrap into a 30-second timeout that blocks first frame.

kotlin
// shared/src/commonMain/kotlin/com/example/session/SessionRepository.kt
package com.example.session

import io.ktor.client.plugins.ResponseException
import kotlinx.coroutines.CancellationException

public sealed class SessionFailure(message: String) {
    public class Network(message: String) : SessionFailure(message)
    public class Unauthorized(message: String) : SessionFailure(message)
    public class Unexpected(message: String, cause: Throwable?) : SessionFailure(message)
}

public class SessionRepository(
    private val api: SessionApi,
    private val clock: MonotonicClock,
    private val cache: SessionCache,
) {
    public suspend fun bootstrap(userId: String): Result<Session> {
        cache.read(userId)?.let { return Result.success(it) }

        return try {
            val session = api.fetchSession(userId)
            cache.write(userId, session, clock.nowMillis())
            Result.success(session)
        } catch (ce: CancellationException) {
            throw ce // NEVER wrap cancellation in Result.failure — it hangs the splash
        } catch (re: ResponseException) {
            val fail = if (re.response.status.value == 401) {
                SessionFailure.Unauthorized("session expired")
            } else {
                SessionFailure.Network("HTTP ${re.response.status.value}")
            }
            Result.failure(fail)
        } catch (t: Throwable) {
            Result.failure(SessionFailure.Unexpected(t.message ?: "unknown", t))
        }
    }
}

Operational rule: anything on the cold-start critical path must complete in under 250 ms on the low-end device or be moved behind first frame. Profile the interop boundary with your own TraceSectionMetric — JNI/ObjC crossings are invisible in standard traces.

Flutter: hold the splash until the first useful frame

dart
// lib/main.dart
import 'dart:async';
import 'package:flutter/scheduler.dart';
import 'package:flutter/widgets.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final binding = WidgetsBinding.instance;

  // Keep the native splash up until the first frame is genuinely useful.
  binding.deferFirstFrame();

  binding.addTimingsCallback((List<FrameTiming> timings) {
    for (final t in timings) {
      // buildDuration + rasterDuration are the two numbers that move p99.
      _perfLog('frame build=${t.buildDuration.inMicroseconds}us '
          'raster=${t.rasterDuration.inMicroseconds}us');
    }
  });

  try {
    await AppBootstrap().warmCriticalCaches(); // config, flags, bundled fonts
  } catch (e, st) {
    // Never let a failed warm-up deadlock the splash screen.
    _perfLog('bootstrap_failed', error: e, stack: st);
  } finally {
    binding.allowFirstFrame();
  }

  runApp(const App());
}

Always cap image decode: Image.network(url, cacheWidth: 720) cuts feed PSS by 30–45 MB on a 20-cell list. Also verify Impeller is actually active (flutter run --enable-impeller) — silently falling back to Skia on older Android is a common cause of unexplained raster jank.

React Native: shrink the module graph before optimizing Hermes

ts
// src/perf/coldStart.ts
import { InteractionManager } from 'react-native';
import { PerformanceObserver } from 'react-native-performance';

let reported = false;

export function installColdStartProbe(sampleRate = 0.05): void {
  const observer = new PerformanceObserver((list) => {
    if (reported) return;
    for (const entry of list.getEntries()) {
      if (entry.name !== 'cold_start') continue;
      reported = true;
      // Sample at 5%: full-fidelity reporting skews the baseline you're measuring.
      if (Math.random() < sampleRate) {
        void import('../telemetry/buffer')
          .then(({ enqueue }) => enqueue('cold_start_ms', entry.duration))
          .catch(() => { /* telemetry must never throw into the app */ });
      }
    }
  });
  observer.observe({ type: 'measure' } as never);

  InteractionManager.runAfterInteractions(() => {
    // process start (nativeLaunch) -> first interaction actually handled
    // @ts-expect-error runtime-only global injected by react-native-performance
    globalThis.performance.measure('cold_start', 'nativeLaunch', 'app_start');
  });
}

The highest-leverage RN optimization is not Hermes tuning — it is dependency surgery. Our p99 dropped 380 ms by removing 41 transitive modules, enabling enableFreeze() on off-screen routes, and replacing three polyfilled timer-based pollers with event subscriptions. Only then did we tune bytecode compilation.


Decision Matrix and Operational Checklist

If your constraint is…ChooseBecause
p99 cold start on low-end AndroidKMP + native UI+8.7% p99 over native; no engine bootstrap
One codebase, product UI indistinguishable across platformsFlutterBest render-consistency per dollar; CMP close behind
Hiring velocity and web-team leverageReact NativeDeepest JS talent pool; accept the +84% p99 tax
Maximum shared logic, platform-perfect chromeKMP + native UIShared domain layer, native UI cost
Ship-to-web tooReact NativeRN Web reuses the component model

Pre-launch checklist (all stacks):

  • p99 cold start measured with Macrobenchmark StartupMode.COLD, baseline profile enforced in CI
  • Per-screen PSS sampled on a 4 GB device after 3 navigation passes
  • Battery measured at 200 nits on LTE, wakeup count attributed per UID
  • Field p99 from Firebase Performance validated against synthetic p99 (expect 30–60% gap)
  • ANR rate and low-memory-kill rate tracked as first-class SLOs, not crash-rate derivatives
  • Observability ingest cost modeled — it scales superlinearly with defect density

Pitfalls That Invalidate Benchmarks

  1. `StartupMode.WARM` masquerading as cold. The OS will keep your process warm unless you pressHome() between iterations. All "cold start" numbers that beat native by 200 ms are almost certainly warm.
  2. Profiler-attached memory reads. The debugger inflates heap by 15–40%. Sample with dumpsys meminfo on a release build with no debugger attached.
  3. Sampling memory too fast. Under 2 seconds between samples you are measuring the collector's cycle, not the app's steady state.
  4. Wi-Fi battery tests. They hide cellular wake-up amplifiers. Test on LTE.
  5. Debug-build comparisons. Every framework looks catastrophic in debug mode, and the ratios change. Never benchmark debug.
  6. Averaging across device tiers. Report p99 per device class. A blended p99 across a Pixel 8 and a Redmi 9A is a meaningless number.
  7. Ignoring the module graph. On RN, cold-start regressions usually come from package.json, not from the runtime.
  8. Treating cost-per-session as a one-off. It is a rate. Recompute it quarterly; framework upgrade tax and observability spend drift faster than infra spend.

Sources & Technical References

1 views•0 comments

Discussion (0)

Join the conversation

Loading comments...

Continue Reading

More production-grade engineering deep dives and architectural guides

Explore all articles