Start now →

The Headless Mobile Architecture: Bypassing the KMP Internal War with Rust

By Joseph Sanjaya · Published June 5, 2026 · 12 min read · Source: Level Up Coding
Ethereum
The Headless Mobile Architecture: Bypassing the KMP Internal War with Rust

Mobile Engineering · Cross-Platform Architecture

How to eliminate duplicate business logic across Android, iOS, and Web using a single Rust core and automated UniFFI bindings.

You push the KMP proposal in the team channel on a Thursday.

By Friday morning, one of your iOS engineers replies with a screenshot. It’s the generated code— 900 lines of KotlinThrowable, NSArray<AnyObject>, and method signatures with underscores. The message: "Is this a joke, this code is not production ready?"

There’s no good answer to that. But there is a other alternative, that might be better.

The Problem You’re Trying to Solve

Your Android, iOS, and web teams all maintain their own network layer. Same API endpoint. Same JSON shape. Same business rules. Three codebases.

Every bug fix needs three PRs. When you add a required auth header to your API calls, one team always misses the change. When you tighten error handling on Android, the iOS version ships without it for another sprint. When the web client finally catches up, it has its own interpretation of what “loading state” means.

Kotlin Multiplatform feels like the answer. One shared module. Write the logic in Kotlin, ship it to Android natively, iOS via a framework, and web via Kotlin/JS.

Then the screenshot arrives.

Why KMP Works Great on Android and Falls Apart on iOS

From the Android side, KMP is a genuinely good story. It’s still Kotlin. Same suspend fun. Same Gradle. Same IDE. The shared module slots into the project like any other dependency.

Then you look at it from the iOS side.

When KMP compiles for iOS, it goes through the Kotlin/Native compiler, which targets Objective-C. That Objective-C layer is the bridge between Kotlin types and Swift. Kotlin sealed classes come out as a base class with opaque subclasses. Kotlin enums with associated values lose the associated values entirely. Kotlin’s suspend fun doesn't produce Swift async/await — it produces a completion handler callback, and Swift engineers have to write wrapper code to make it feel modern.

Your iOS team gets a framework that is technically correct and practically unpleasant. To write proper Swift on top of KMP you need SKIE, a third-party plugin from Touchlab that regenerates Swift-idiomatic wrappers over the Objective-C bridge. It works — but now you've added a dependency to paper over a gap in the design.

The iOS team’s objection isn’t resistance to change. They’re pointing at something real.

The Actual Problem Underneath

When an Android team proposes KMP, the subtext is: write the shared logic in our language, on our toolchain, in our IDE, and everyone else consumes what we produce.

iOS engineers hear: your language is the client. Our language is the source of truth. Web engineers hear: we’re aware you exist.

KMP solves the duplication problem. But it does it by centering one team’s language and asking everyone else to adapt. That’s a political decision wearing a technical costume. And the iOS engineer with the screenshot isn’t wrong to push back.

What Changes With Rust

Rust isn’t anybody’s primary language on a mobile product team. That’s the whole point.

When the shared layer is written in Rust, no team is being handed the other team’s code. Android consumes generated Kotlin. iOS consumes generated Swift. Web consumes generated TypeScript. Everyone gets an interface in their own language, at the same quality level, generated by the same toolchain.

The Rust layer is neutral territory. Nobody won the language argument because the argument stops.

What Rust Actually Gives You Technically

The politics matter, but Rust earns its place on technical merit too.

Memory safety without a garbage collector. Rust’s ownership model prevents entire classes of bugs at compile time — use-after-free, double-free, null pointer dereference, data races. For shared business logic running on three platforms, this means you’re not debugging a memory issue that only surfaces on iOS because of a subtle difference in how the Kotlin and Swift implementations managed object lifetimes.

Thread safety is a type property. The Send and Sync marker traits enforce concurrency correctness at compile time, not via code review. When the HttpClient trait requires Send + Sync, it's a guarantee that the implementation can safely cross Tokio's thread pool. If someone writes an implementation that holds a non-thread-safe resource, the code doesn't compile.

https://medium.com/media/94f2499411c0b7762238a4693220e60f/href

Zero-cost abstractions. The CatFactService<C: HttpClient> generic doesn't use a vtable at runtime. The compiler monomorphizes it — generates a concrete version for each HttpClient type. At runtime, self.client.get(...) is a direct function call, not dynamic dispatch through a pointer. The design flexibility of dependency injection with none of the overhead.

No garbage collector pauses. On Android, the JVM GC can pause the main thread. On iOS, ARC is deterministic but reference cycles can delay deallocation. Rust uses deterministic drop semantics — resources are freed exactly when they go out of scope. For business logic running during UI interactions, predictable deallocation means predictable frame timing.

cargo test covers all platforms without a device. The core crate has no platform dependencies. Run its full test suite on your development machine with one command. No emulator. No simulator. No device farm. Integration tests that hit the real API are marked #[ignore] and opt-in.

# Run all core unit tests — no device, no emulator, no simulator
cargo test -p catfact-core

# Run integration tests that hit the real API
cargo test -p catfact-networking -- --ignore

Release builds are lean. With opt-level = 3, lto = true, strip = true, LLVM inlines across crate boundaries, eliminates dead code globally, and strips debug symbols from the final binary.

https://medium.com/media/cf76849d5d6324b63e34b364c3110f4e/href

Who’s Already Doing This

This isn’t a theoretical setup.

Mozilla Application Services is a collection of Rust components — password sync, bookmarks, login storage, FxA authentication — shared between Firefox Android and Firefox iOS. The Firefox for iOS app consumes the Rust layer via Swift Package Manager, the same way it’d consume any Apple-native dependency. Nobody on the iOS team writes Rust. They write Swift against generated Swift bindings.

The actual component sizes: the logins component is 6,740 lines of Rust covering the full sync engine, encryption, schema, and storage logic. The Kotlin wrapper is 207 lines. The Swift wrapper is 113 lines. The Rust layer does the work. The platform layers are thin.

Element uses the same pattern with the Matrix protocol SDK. The matrix-rust-sdk is the core. Element X on iOS and Element X on Android both consume it. The architecture puts Rust at the center, UniFFI bindings going out to mobile, wasm-bindgen going out to web. One protocol implementation instead of three.

1Password rewrote their client logic in Rust across all platforms for the same reasons: consistent behavior, one test suite, no platform-specific drift in security-critical code. When you’re handling credentials, you can’t afford a subtle difference between how Android and iOS interpret a decryption failure.

Large teams with iOS engineers who care about Swift quality, and Android engineers who care about Kotlin quality, landed on the same answer.

What the Architecture Looks Like

The Cargo workspace separates core logic from platform bindings:

domain/
├── crates/
│ ├── core/ ← pure business logic, zero I/O
│ └── networking/ ← reqwest HTTP client
└── bindings/
├── catfact-ffi/ ← UniFFI → Android .so + iOS .a
└── catfact-wasm/← wasm-bindgen → .wasm ES module

The core crate has no uniffi, no wasm-bindgen, no platform imports. It compiles to any target without a single conditional.

// domain/crates/core/src/service.rs
pub struct CatFactService<C: HttpClient> {
client: C,
base_url: String,
}
https://medium.com/media/67fe2906c8ad21c2b40c02c0175f2978/href

UniFFI: One Interface File, Two Bindings

UniFFI uses a .udl file as the contract between Rust and the generated bindings:

https://medium.com/media/c017215b6e53f4ba1949369563b84796/href

The [Async] attribute tells UniFFI to generate suspend fun in Kotlin and async throws in Swift. No callbacks. No thread wrappers. The Rust implementation side:

#[derive(uniffi::Object)]
pub struct CatFactRepository {
service: Arc<CatFactService<ReqwestHttpClient>>,
}
https://medium.com/media/44fd965293df8b96b2062ab049d1b2f1/href

The build.rs is one line:

fn main() {
uniffi::generate_scaffolding("./src/catfact.udl").unwrap();
}

What the iOS Team Actually Sees

This is the screenshot that changes the conversation.

// iOS — generated Swift, called directly from a SwiftUI View
Task {
do {
let fact = try await repository.getRandomFact()
// update @State
} catch let error as ApiError {
// ApiError is a proper Swift enum with associated values
}
}

Native async throws. Real Swift enums. No KotlinThrowable. No completion handlers.

The iOS team gets a Swift Package containing an XCFramework. They add it to Xcode the same way they’d add any third-party dependency. The Rust internals are invisible.

What the Android Team Sees

Running./gradlew build compiles Rust for all four Android ABIs and generates the Kotlin source:

// rust-ffi/build.gradle.kts
val rustTargets = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86" to "i686-linux-android",
"x86_64" to "x86_64-linux-android"
)

Each target uses an NDK clang linker defined in .cargo/config.toml:

[target.aarch64-linux-android]
linker = "aarch64-linux-android21-clang"

Generated Kotlin lands in build/generated/uniffi/kotlin/ and the module's sourceSets picks it up automatically — the same as kapt or ksp output. Usage from the app is completely idiomatic:

lifecycleScope.launch(Dispatchers.IO) {
runCatching { repository.getRandomFact().fact }
.onSuccess { /* update UI */ }
.onFailure { /* show error */ }
}

What the Web Team Sees

Web uses wasm-bindgen instead of UniFFI. Same Rust core, different output format.

#[wasm_bindgen(js_name = CatFactRepository)]
pub struct CatFactWasmRepository {
service: CatFactService<ReqwestHttpClient>,
}
https://medium.com/media/d99f41a59d5bd5fb61acb3735220bf16/href

wasm-pack generates a pkg/ with a .wasm binary, JavaScript glue, and a .d.ts type file. Web engineers link it like any local npm package and get full TypeScript autocomplete:

import initWasm, { CatFactRepository } from 'catfact-wasm';
await initWasm();
const repo = new CatFactRepository();
const { fact } = await repo.get_random_fact();

No Kotlin. No Rust. Just a typed async function.

The Async Trap That Will Bite You

When a Kotlin coroutine cancels, UniFFI drops the Rust future. That’s correct. But if the future had already spawned a Tokio task, that task keeps running.

You can leak HTTP requests silently for weeks. The coroutine is done. The network request is still in flight. No error. No log.

The fix is nine lines:

struct AbortOnDrop(tokio::task::AbortHandle);
https://medium.com/media/d85d9e6bc1a1ac3b7908bc0937c1ae27/href

When the coroutine cancels, _abort_on_drop drops, Drop::drop fires, the Tokio task aborts, the HTTP request closes. Nothing leaks.

The other requirement is a single global Tokio runtime initialized at process start:

https://medium.com/media/3eeeb7ace971f91ff8dde36ced9cf23b/href

Kotlin and Swift have their own async runtimes. When they call into Rust FFI, Tokio isn’t running. This bridges that gap. For WASM, none of this is needed — the browser’s event loop is the runtime, and reqwest calls browser fetch directly.

Gotchas You’ll Hit Building This

___chkstk_darwin linker error on iOS. No message. Fix: export IPHONEOS_DEPLOYMENT_TARGET=15.0 before compiling.

UniFFI checksum mismatches. The app crashes at launch with ArcCatFactRepository checksum mismatch after regenerating bindings. The build script patches stale cached checksums automatically. Safe — you're aligning cached output to current source, not suppressing real ABI mismatches.

wasm-opt breaks the WASM build. Triggers a bulk-memory validation error on some reqwest versions with no useful output. Fix:

[package.metadata.wasm-pack.profile.release]
wasm-opt = false

Numbers From the Actual Build

These come from running the build — not estimates.

The code generation ratio. The UDL interface file is 35 lines. That 35-line contract generates 1,595 lines of Kotlin bindings and 929 lines of Swift bindings. You write 35 lines. UniFFI writes 2,524.

What engineers actually touch.

Rust source (all crates):  595 lines  ← the logic
UDL contract: 35 lines ← the interface
Generated Kotlin: 1,595 lines ← UniFFI writes this
Generated Swift: 929 lines ← UniFFI writes this
Android Kotlin wrapper: 55 lines ← Android engineer touches this

iOS engineers touch zero lines of Kotlin. Their entry point is the generated Swift file, which reads like any other Swift package.

WASM footprint. Compiled WASM binary: 377KB. JavaScript glue: 25KB. No runtime overhead.

Debug vs release binaries. Current .so files are unstripped debug builds — 4.4MB for arm64-v8a. With the release profile, LLVM eliminates dead code and strips symbols. Mozilla's production components ship well under 1MB per ABI after stripping.

The Mozilla ratio at production scale. The logins component — full sync engine, encryption, schema migration — is 6,740 lines of Rust. Kotlin wrapper: 207 lines. Swift wrapper: 113 lines. Same pattern, same ratio, at production scale.

What Each Platform Gets at the End

// Android — a suspend function on a class
val fact = repository.getRandomFact().fact
// iOS - async throws, native Swift enums
let fact = try await repository.getRandomFact()
// Web - a typed Promise from a local package
const { fact } = await repo.get_random_fact();

No platform knows it’s calling Rust. No platform writes adapter code. No platform got a second-class interface.

The iOS engineer who sent the screenshot looked at the generated Swift and said it was fine. That was the whole goal.

The Real Tradeoffs

Someone needs to write and maintain Rust. If nobody on the team has that background, you’re adopting a language, not a tool. The compiler is strict. The learning curve around lifetimes and async is real.

Build configuration takes time. The NDK cross-compilation setup, the iOS XCFramework pipeline, the CI integration — budget a week the first time through.

When something crashes inside the Rust layer, your Kotlin stack trace ends at the JNI boundary. Debugging native crashes requires tools most Android engineers don’t use daily.

What you get in return: one test suite, one bug fix, one source of truth for every platform. A shared layer every team consumes without feeling like they’re using someone else’s port.

KMP is the right call when the team is Android-first and iOS is secondary. Rust is the right call when all three platforms are first-class and the shared layer needs to be genuinely neutral.

The iOS screenshot test is a good one. Show your iOS team the generated Swift before you commit to the architecture. If it looks like Swift they’d write themselves, you’re done arguing.

Full source — Android (Jetpack Compose), iOS (SwiftUI), React — all consuming the same Rust core.

Working through the same decision? Leave a comment.


The Headless Mobile Architecture: Bypassing the KMP Internal War with Rust was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.

This article was originally published on Level Up Coding and is republished here under RSS syndication for informational purposes. All rights and intellectual property remain with the original author. If you are the author and wish to have this article removed, please contact us at [email protected].

NexaPay — Accept Card Payments, Receive Crypto

No KYC · Instant Settlement · Visa, Mastercard, Apple Pay, Google Pay

Get Started →