Comparison
Undra vs Kotlin Multiplatform: sharing logic without leaving your platform
Undra and Kotlin Multiplatform (KMP) both let you write your domain logic once and keep the screens in SwiftUI, Jetpack Compose and React. They bet on different languages, boundaries and toolchains. We build Undra, so read this with that in mind: every statement about KMP is dated to 30 September 2026 and linked to its source, and one section covers when KMP is the better choice.
The short version
KMP compiles Kotlin to each target: the JVM and Android, native code for iOS, JavaScript or WebAssembly for the web. Your shared code is Kotlin, it runs in a Kotlin runtime on every platform, and platform code calls into it. Undra compiles Rust to a C ABI library (iOS, and Android through JNI) and to a wasm module (web), and generates Swift, Kotlin and TypeScript from a schema extracted from the Rust. Platform code does not call into your logic to read state; it holds a local mirror.
Three differences follow:
- Language. Kotlin or Rust. For an Android-first team Kotlin is already the daily language. Rust gives you a borrow checker and no garbage collector, and asks more of the people learning it.
- Boundary. In KMP, platform code reads shared state by calling into the Kotlin runtime. In Undra, reads are local and only writes cross, once per transaction.
- Scope. KMP is broader: share only logic, or logic and UI with Compose Multiplatform. Undra never shares UI, and ships the state and data layer that KMP leaves to libraries.
What Swift sees
On iOS, Kotlin/Native compiles a shared module into a framework that Swift reaches through an Objective-C header (Kotlin docs). JetBrains documents the consequences: generics map to Objective-C lightweight generics with limits, boxed primitives surface as NSNumber subclasses such as KotlinInt, and suspend functions appear as completion-handler functions, or as async functions in Swift, which the page calls experimental with limitations. Flow has no Swift counterpart in that mapping, which is why community tooling such as SKIE and KMP-NativeCoroutines exists.
JetBrains' answer is Swift export, which generates Swift directly and exports suspend functions as async and flows as AsyncSequence, which the Objective-C path does not. Its documentation calls it Alpha, on a page last modified 28 August 2026, lists limitations including generic type parameters being erased to their upper bounds, and says breaking changes are expected (Swift export). In June 2026 Touchlab, the maker of SKIE, still called SKIE the choice for shipping KMP on iOS while describing Swift export's Objective-C-free architecture as "actively arriving" (Touchlab). So as of September 2026 the mainstream path for a SwiftUI team is the Objective-C framework, usually with a community plugin. That is a moving target; treat it as a snapshot.
Undra emits Swift source. This is the real generated store for the playground's to-do list, abbreviated:
Generated by undra bindgen (abbreviated): examples/playground/generated/swift
@MainActor @Observable
public final class Todos: UndraStore, @unchecked Sendable {
public private(set) var todos: [Todo] = []
public private(set) var filter: Filter = .all
/// Computed by the core; read-only.
public private(set) var visible: [Todo] = []
/// Computed by the core; read-only.
public private(set) var remaining: UInt32 = 0
/// Adds an item at the end of the list.
/// - Throws: ``TodoError``, `CancellationError` if the task is cancelled, or ``UndraCallError``.
public func add(title: String) async throws -> Todo { /* … */ }
/// Chooses which items `visible` holds.
/// - Note: A failure is logged and passed to `LoadOptions.onError`; the method does not throw.
public func setFilter(_ filter: Filter) { /* … */ }
}
There is no Objective-C in the path. Records are Sendable structs, errors are enums thrown as themselves (catch TodoError.emptyTitle), and a SwiftUI view reads todos.visible like any other observable property. The price is a baseline of Swift 6 and, by default, iOS 17, which @Observable needs; an app that supports iOS 15 or 16 lowers its deployment target and gets ObservableObject stores (supporting iOS 15 and 16).
On Android the comparison inverts, in KMP's favour. Shared Kotlin is just Kotlin on Android's runtime, with no cross-language boundary. Undra's Android path crosses JNI into a Rust shared library and surfaces StateFlows, suspend functions and sealed error classes that Compose reads with collectAsState(). That is idiomatic, but still a boundary.
The read path
In KMP the shared state lives in the Kotlin runtime. On Android a read is a plain Kotlin read. On iOS, SwiftUI code reads Kotlin objects through the bridged API: an in-process call into the Kotlin/Native runtime, which is compiled into the framework your app links, with values bridged to Objective-C types rather than serialised. For a Flow, Swift needs something that collects it and feeds values into state SwiftUI observes; SKIE and KMP-NativeCoroutines expose a Flow as a Swift AsyncSequence for that. Whether that costs anything you can measure is a question for your app; we have not measured it and do not claim it is slow.
Undra's rule is that reads never cross the boundary (R5 in the project's constitution). Each platform runtime holds a mirror: store handle, then signal id, then the decoded value. The generated store exposes it in the platform's idiom, so a read is a property read on a Swift class, a StateFlow.value or a Signal.peek(). A write is a call in. The core runs it as one transaction and answers with one binary change-set per store touched, holding only the observed signals that changed, which the runtime applies on the main thread.
A contract test checks the rule: scenario S11 reads four signals 1,000 times and asserts the core's crossing counter did not move (scenarios).
The trade is explicit. Undra pays a decode and a copy on every change, plus memory for the mirror. In exchange reads are ordinary native reads of native value types, and the same generated API works whether the core is in-process, in a web worker, or behind a WebSocket during undra dev: reads that crossed could not work over an asynchronous transport.
For scale: 49.8 ns for a synchronous call through the C ABI and 6.31 µs for one insert into an observed 10,000-row list, measured on an Apple M5 Pro. Both are the core's half only, excluding the Swift, JNI or JavaScript side, and neither is a device number (benchmark results). That file lists a comparison against KMP and UniFFI baselines as not yet done, so this page makes no speed comparison.
The web story
KMP has two web targets. Kotlin/JS compiles Kotlin to JavaScript (docs). Kotlin/Wasm targets WebAssembly with garbage collection in the browser, and its documentation still calls it Beta (docs). If your organisation writes Kotlin on the web, that is coherent. If your web team writes TypeScript and React, the questions are what the seam looks like and what the bundle carries.
Undra's web output is a wasm module built from your Rust core plus @undra/runtime, a TypeScript package with no runtime dependencies. Each store field becomes a Signal<T> with get, peek and subscribe; @undra/runtime/react exports useUndra and useSignal (built on useSyncExternalStore), and Vue, Svelte and Solid adapters exist too. The core can run on the main thread, in a worker, or remotely over a WebSocket. The repository measures 113.1 KB gzipped for the wasm module of Undra's runtime plus a hello-world core, against a budget of 120 KB that CI enforces; it grows with your code.
Playground code, abbreviated: examples/playground/web/src/views/TodosView.tsx
const visible = useSignal(todos.visible);
const remaining = useSignal(todos.remaining);
// ...
<input type="checkbox" checked={todo.done} onChange={() => void todos.toggle(todo.id)} />
The web has its own costs. The shipped wasm profile aborts on a Rust panic, so a panic traps the module and the page recovers by reloading the core and restoring a snapshot (scenario S17 tests that). Every generated method returns a Promise, even ones that are synchronous in Swift and Kotlin. And 64-bit integers are bigint unless you opt into number.
Tooling weight
KMP is one integrated toolchain: Kotlin and Gradle, with JetBrains' IDE support behind them. JetBrains documents several ways into Xcode: a build-phase script (the default with its IDE plugin), a local Swift package, CocoaPods, or a prebuilt XCFramework published through Swift Package Manager or CocoaPods (integration overview). A team already running Gradle installs nothing new.
Undra is a second toolchain for most teams. The undra CLI scaffolds a project (undra init), generates bindings (undra bindgen), builds the core (undra build produces an XCFramework, 16 KB-aligned .so files per Android ABI and an optimised wasm module) and diagnoses your machine (undra doctor). You need Rust with the iOS, Android and wasm targets, Xcode, and the Android SDK and NDK. Rust builds are not quick, which is what undra dev is for: it runs the core on your machine behind a WebSocket and rebuilds on save, and the running iOS app, Android app or web page reconnects by itself and keeps its state, without rebuilding the app (ADR-051, ADR-053; corrected on 2 October 2026: when this post was first published the Android runtime had no remote transport).
The weight is roughly symmetrical. Either can be consumed as a prebuilt binary, so an iOS engineer who never edits the shared code takes an XCFramework built by Gradle or by Cargo. The second toolchain lands on whoever owns the shared code: usually the Android team with KMP, the Rust authors with Undra.
Ecosystem and backing
This is KMP's largest advantage. Kotlin and KMP are developed by JetBrains, and Google's documentation states that KMP is officially supported for sharing business logic between Android and iOS and is stable and production-ready (Android developers). Google has made Jetpack libraries including ViewModel, lifecycle, DataStore, Room, Paging and SQLite KMP-ready. Around them sit Ktor for HTTP, SQLDelight for type-safe Kotlin generated from SQL, Koin for dependency injection, and the wider Kotlin ecosystem.
Undra 1.0 shipped on 30 September 2026, the date of this post. It is one team's project, dual-licensed MIT or Apache-2.0, with a reference app (the playground) in the repository and no third-party packages. Its evidence is its tests (more than 4,000 across five languages, 18 contract scenarios on three platforms) and four adversarial reviews of the core crates. That is evidence of care, not of years in production.
What Undra offers instead of a library ecosystem is a built-in data layer with the semantics of a server-state cache: staleness windows, request deduplication, retry with jitter, optimistic mutations with precise rollback, an offline queue and persistence. With KMP you would typically assemble that from HTTP and database libraries, often with a caching library on top, and your own glue.
Team fit
If your mobile engineers are Android-first Kotlin developers and iOS is a second consumer of the same logic, KMP meets you where you are: the shared code is in a language the team writes daily.
Undra fits better when teams already maintain a Rust core, want memory safety without a garbage collector, or have iOS, Android and web engineers in separate groups who would rather consume a generated package than learn another platform's runtime. The cost is Rust. Undra's macros emit errors that say what, why, how to fix it and where to read more, and they keep lifetimes and threads off the common path, but the borrow checker is still there, and in our experience most mobile teams have few Rust engineers. Undra's adoption wall is hiring, not technology.
Side by side
| Aspect | Kotlin Multiplatform | Undra |
|---|---|---|
| Shared language | Kotlin | Rust |
| Runtime on iOS | Kotlin/Native, linked into the app | Rust static library in an XCFramework, C ABI |
| Swift surface (Sep 2026) | Objective-C header; Swift export in Alpha | Generated Swift, @Observable stores, typed throws |
| Android | Kotlin on Android's runtime, no boundary | Rust .so over JNI, StateFlow and suspend bindings |
| Web | Kotlin/JS; Kotlin/Wasm in Beta | Rust to wasm plus a TypeScript package with hooks |
| Read and write path | Calls into the Kotlin runtime | Reads local; one call in and one change-set per store out |
| State model | Bring your own (Flow, ViewModel) | Signals, computed values, keyed lists, built in |
| Data layer | Ktor, SQLDelight or Room, and your glue | Built-in queries, mutations, offline queue, persistence via a Kv port |
| Shared UI | Optional, Compose Multiplatform | Never |
| Dev loop | Gradle | undra dev: core on your machine, apps connect over WebSocket |
| Backing | JetBrains; Google support for shared logic | One team; v1.0 shipped 30 Sep 2026 |
When Kotlin Multiplatform is the better choice
Choose KMP when any of these describes you.
- You are an Android-first Kotlin organisation. The shared code is in your language, it runs on Android with no boundary, and your hiring, reviews and tooling already fit.
- You want to share UI, now or later. Compose Multiplatform gives you a path from sharing logic to sharing screens, with iOS listed as Stable and interop with SwiftUI and UIKit where you want native parts. Undra will not share UI.
- You need mature libraries today. A shared relational database, a large HTTP and serialisation ecosystem, Jetpack libraries with Google behind them. Undra has a key-value port, a query layer and an opt-in SQL port over SQLite (Db, added on 2 October 2026), not that ecosystem.
- You want JetBrains and Google behind a long-lived app. KMP has been Stable since Kotlin 1.9.20 in November 2023 (release notes), was in production before that, and has two large companies behind it. That is a lower-risk bet than a v1.0 from one team, and a legitimate reason to choose it.
- You have to hire for it. Kotlin engineers are plentiful on mobile teams and Rust engineers are not, and Undra's core has to be written in Rust.
- You ship to desktop, or share code with a Kotlin backend. KMP and Compose target desktop, and Kotlin on the server can share models with clients. Undra has no desktop targets yet.
- Your shared logic is small. A few models and validation functions do not justify a second toolchain.
Where Undra fits
Choose Undra if what you keep rewriting three times is stateful and not visual: the cache, the retry policy, the offline queue, the session refresh, the list that must stay consistent across screens. Choose it if your UI engineers are happy in SwiftUI, Compose and React, and if Rust's guarantees are an asset for your domain and not a tax.
Next: the core concepts, why reads never cross the boundary, and why Undra keeps your UI native.