Overview

Why Undra is the default choice

A team choosing how to share logic across iOS, Android and the web asks one question before any other: what will this stop me doing? We catalogued 68 limitations of Kotlin Multiplatform, UniFFI, Crux, React Native, Flutter, Capacitor and writing the logic three times, each dated and sourced where a source exists, and mapped every one to what Undra's repository does about it. This post is the result: what Undra solved and how, what it did not, and why a team can start from it by default.

The matrix

The catalogue's matrix lists 36 things a team needs. On 1 October Undra was marked as having 10 of them, part of 9 and none of 16, with one not applicable. Since then every code piece of the v1.x program has merged, each feature after an adversarial review recorded in the repository. At the last merge the contract grid passed 101 of 101 cells over 35 scenarios, and the test suites ran 3,812 Rust, 2,043 TypeScript, 890 Kotlin, 914 Swift and 111 React Native tests. Counting again today: 28 solved, 5 partial, 2 open and 1 left out by decision. The count is ours, row by row in the ledger, and it is the part of this page we most want checked. The table groups the 36 rows into 12 classes. It leaves out Crux, which its own post covers, and reads "Flutter / React Native" as those two with the logic written in the UI's language.

ClassKotlin MultiplatformUniFFIFlutter / React NativeUndra
State reaches the UIrows 3 to 6 On iOS Swift copies a Kotlin collection whole; observing a Flow takes SKIE or Swift export, which is Alpha. Its documentation describes no observable state; reading Rust state is a call across the boundary. No language boundary. React Native runs logic on the JS thread; a frame it misses is dropped. Solved. Reads are local; a write crosses once; lists, and filtered or sorted views of them, cross as O(change); delivery is merged per frame. Reads post, ADR-031, 039.
Errors and crashesrows 1, 2 Only @Throws exceptions become Swift errors; others end the program. As we read its Swift templates, a Rust panic in a non-Result function stops the process. Flutter swaps a failed widget for a gray background in release. Platform channels answer with a code and a message. Solved. One closed UndraCallError set on every platform; panics are caught at the boundary and reach the app as a structured report; a trapped wasm core restarts from its snapshot. ERRORS.md, ADR-032, 046, 049.
What can crossrows 21 to 23 Swift sees an Objective-C header, or Swift export (Alpha), which erases generics. Objects and foreign traits cross; its documentation describes no generic types of your own. flutter_rust_bridge takes arbitrary types and closures; React Native native modules need a spec and code per platform. Solved: records, enums and errors become native sum types, no Objective-C; objects cross as parameters and returns; the core calls host callbacks; newtypes stay typed; generics cross as declared instantiations, functions as overloads and objects and stores as one class per alias. Objects, callbacks, types, generics, ADR-040 to 042.
Data layerrows 7 to 10 Libraries: Room, SQLDelight (compile-time migration checks), Store. Its documentation describes no cache, mutation, offline or persistence layer. Libraries: TanStack Query, drift (with migrations). Solved: cache, dedup, retry, optimistic rollback, an offline queue that survives a restart, migrating persisted state, infinite queries, lazy lists, polling. Not yet: optimistic placeholders after a restart. Queries, paging, ADR-037, 043.
Storage, sockets, servicesrows 11, 12, 14 Ktor, DataStore, Room, SQLDelight; many of Google's Jetpack libraries are KMP-ready. Its documentation describes no adapters; SQL and sockets are Rust crates you bring. React Native has WebSocket and expo-sqlite; Flutter has drift and web_socket_channel. Solved: default adapters for HTTP, storage, files and connectivity on iOS, Android, web and React Native; opt-in WebSocket, SSE and SQL ports. Not yet: the web database serves one tab per origin. Ports, database, real time.
Dev loop and toolingrows 15, 16 Compose Hot Reload needs a desktop JVM target; JetBrains' 2025 roadmap calls build speed "a common Kotlin/Native concern". Each change rebuilds the library and the app; its documentation describes no dev server or devtools. Both ship DevTools. Flutter hot reload skips native code; React Native Fast Refresh can fall back to a full reload. Solved: undra dev reconnects and keeps state across a rebuild, query handles included; devtools with time travel; Gradle, Xcode and Vite run undra build. ADR-051, 053, 054, 059.
Testing and previewsrows 18, 19 kotlinx-coroutines-test gives virtual time in multiplatform tests. Its documentation describes no fakes for platform I/O; you build the doubles. Flutter's widget previewer supports no dart:ffi or native plugins. Solved: PreviewCore runs your real core with fakes and a manual clock on Swift, Kotlin and TypeScript; a recorded session replays in tests. Testing, ADR-055.
Compatibility and floorsrows 20, 24, 30 Kotlin/Native targets iOS 15.0 and later. Several KMP frameworks in one app duplicate dependencies. As we read its Swift template, a library and bindings that disagree stop the process. React Native iOS 15.1, Flutter iOS 15. Flutter does not support several Flutter libraries in one app. Solved: schema-hash gate at load; iOS 15 and 16 via ObservableObject; several cores side by side. Caveat: the iOS 15 and 16 mode has not run on an iOS 15 or 16 runtime. Hash, iOS 15 and 16, cores.
Platforms and UI frameworksrows 25 to 29, 36 Kotlin/Wasm is Beta. Compose Multiplatform shares UI; desktop is Stable. Python and Ruby are first-party; web comes from a third-party generator. Flutter paints one UI on six platforms, not suited to text-rich static sites. React Native: web, Windows and macOS out of tree. Solved: web (wasm core; React, Vue, Svelte, Solid hooks) and React Native. Not yet: Flutter and Dart. Partly: desktop, and reuse on a server. By decision: no shared UI. TypeScript, React Native.
Adopting and buildingrows 31, 32 Several ways into Xcode; its IDE plugin applies a build-phase script by default. No end-to-end packaging: no Rust for Android, no .aar. React Native joins an iOS app through CocoaPods; Flutter add-to-app has limits. Solved: undra build runs inside Gradle, Xcode and Vite; SwiftPM on native iOS, no CocoaPods. Partly: undra adopt edits none of your project files. From KMP, from UniFFI, CLI.
Running in productionrows 13, 17, 33, 34 Background work is platform code (WorkManager is Android-only). The IDE plugin debugs across Swift and Kotlin. Not assessed. React Native Headless JS is Android-only. Both debug in their own language with DevTools. Solved: background runs in the window the OS grants; symbol files and undra symbolicate; a debugger path into Rust, documented for Xcode, Android Studio and Chrome and tested with LLDB on the iOS simulator. Caveat: iOS background tasks do not run in the simulator, so that handler is tested with fakes. Not yet: physical-device measurements. Production, ADR-046, device rows.
Ecosystem and track recordrow 35 JetBrains builds it; Google states support. Used extensively in Firefox; its README says it is a long way from 1.0. Backed by Google, and by Meta and Expo. Not yet, and a post cannot fix it. One team, no production users, nothing on a registry. Roadmap.

What we solved, and how

Five classes carry most of the weight. Each gets one measured number and the file it came from.

The boundary

A boundary costs twice: once per crossing, and once for what a crossing carries. Undra's rule is that reads never cross and a write crosses once per transaction, returning one change-set for each store it touched. ADR-031 adds the other half: the platform applies what has arrived once per frame, merged, with a bounded backlog.

The number: a burst of 1,667 one-row updates to a 10,000-row list, which is 100,000 a second at 60 Hz, reaches the iOS app as one drain that costs the main thread 787 µs at the median. Applying each update on its own is estimated at 3.4 to 3.94 ms, 20.4 to 23.7 percent of a frame, from the measured cost of one (benchmark results; iPhone 17 Pro simulator on an Apple M5 Pro, so not a device).

ADR-044 turned the C ABI into one function table per core, so two cores can share a process without sharing anything. What the table's indirection costs a call is not settled: the implementer measured +0.6 ns on quiet rounds whose load the record does not give, and the reviewer, on a loaded machine, measured a median of −5.1 ns inside a spread of ±40 ns, which shows no regression above the noise and cannot confirm a figure that small. A re-measure on a quiet machine is owed.

What remains is the crossing itself: through the generated binding a call is about 300 ns on the iOS simulator and 0.47 to 0.68 µs in Chromium, against 44 ns for the core alone. ADR-056 brought the Chromium call down from 3.2 to 3.5 µs by taking allocations and lowered private fields off the call path, and the same changes cut the React Native mirror's work at 100,000 keyed updates a second from 21.5 to 22.0 ms of a 16.7 ms frame to 5.0 to 5.4 ms, on the iPhone simulator under Hermes. The call is still over its targets, which is open work, below.

State over the boundary

A filtered or sorted view of a big list used to cost the whole list. A Computed<Vec<T>> was recomputed at every commit and sent whole, so one edited row in a 10,000-row list shipped 352.6 KB and took 176.5 µs. A DerivedList (ADR-039) is kept from the source's recorded operations on two order-statistic trees and ships the change: 158 bytes for the same edit, in 333 ns measured on the signals crate directly and 392 ns through the runtime, which is the figure on our landing page; at 100,000 rows, 375 ns and 158 bytes (finding 5; best of three medians).

A contract scenario replays 60,000 seeded operations through the Swift, Kotlin and TypeScript runtimes and checks each view's hash after every change-set. What a platform still pays is its own list copy, once per drain: for a one-operation patch to a 100,000-row source, 47 µs in TypeScript and 7 µs in Kotlin. iOS 15 and 16 mode copies the list on every patch, 48 µs at 10,000 rows.

The error channel

A call that fails does one of three things on every platform: it throws or rejects with the method's own error type, with the platform's cancellation, or with UndraCallError, a closed set of five cases (cancelled by the core, panicked, refused, unavailable, malformed). A command, which has no caller to throw to, reports through onError. No reply status, cancellation or lost connection reaches fatalError, an uncaught exception or an unhandled rejection (docs/ERRORS.md, ADR-032).

A native panic is caught at the boundary and arrives as panicked, and the app's onPanic receives a structured report with frames its crash reporter can symbolicate (ADR-046); a computed that panics poisons only itself (ADR-019); a stream's failure is a typed item on the wire (ADR-036); a write from a thread the runtime does not own is refused in every build (ADR-035). A wasm core cannot unwind, so it traps; with crashRecovery() the runtime restarts the same module and restores the last snapshot (ADR-049, docs). The number: 3.12 to 3.22 ms at the median from the trap to a running core, with a 100 KB state, over five runs in headless Chromium (benchmark results). Recovery is opt-in, and a call in flight at the trap fails with Unavailable.

The dev loop

Edit Rust, save, and the app on a simulator, an emulator or a page reconnects by itself, with exponential backoff from 250 ms to 5 s, and observes its stores again (ADR-051). The core's state is snapshotted in memory before the rebuild and restored into the new core before it listens, so the app returns to the screen it was on, and the dev bar says "Reloaded, state kept" or why not (ADR-053). A screen that shows a query is carried too: its query handle comes back on the same handle and fetches with the code that was just rebuilt, with no code in the app for the handle (a value the app set by a call outside its stores, such as a server’s address, is told to the new core again; ADR-059).

The number: the playground's 204 KiB snapshot restores in 1.6 ms inside undra dev, which runs a debug build of the core (189 µs in a release build), and 74 ms pass from suspending the old core to the new one listening (the piece's record). A page served by undra dev shows every store's live value and a timeline of every change-set, and a scrubber restores the core to an earlier step while the app follows (ADR-054, docs/DEV_LOOP.md). It sits behind a per-run token.

Shipping an update, and shipping the app

Persisted data records the structure it was written with, so a build that changed a type still reads what the last one stored: by name for free, through a #[undra::migrate] hook when a name changed, and never by discarding it silently (ADR-037, Shipping an update). A queued write the new build cannot carry over becomes a dead letter the app can show. Storage ports fail with typed errors (full, locked, corrupt, unavailable, io) on every platform instead of raw throws (ADR-049).

The number: the hello-world web core is 113.1 KB gzipped against a 120 KB budget, and the JavaScript a hello-world page loads up front is 15.8 KB gzipped against a 16 KB budget, for the production build of the runtime as an app installs it, with Vite's own preload helper reported beside it and gated on its own. CI fails a change that exceeds either (ADR-052, size record). Opt-in pieces stay out of the hello world: the remote transport and the default ports load on first use, and the browser database adds about 299 KB gzipped, only to an app that imports it.

What it costs to adopt

You do not move a domain on a Monday. The two migration guides describe three levels. L1, side by side: a few pure functions (from UniFFI, plain-data functions in a new crate beside yours) move first, and the rest of your stack stays. L2, one feature: one screen's state and requests become a store, a query and a mutation. L3, the whole domain: platform services become ports, and persistence and the offline queue move in (from Kotlin Multiplatform, from UniFFI).

The costs are real. The core is Rust, and for an Android-first team Rust is no longer the daily language. It is a second toolchain: Rust with the iOS, Android and wasm targets, Xcode, and the Android SDK and NDK; undra doctor checks each of them and prints the command that fixes what is missing. Generated Swift defaults to iOS 17, and an app that supports 15 and 16 takes the ObservableObject shape and its extra list copy. An observed keyed list exists three times: in the core, in a baseline copy the core keeps to build patches against, and in each platform's mirror. And undra adopt writes a core and the steps but edits none of your project files.

What is still open

Plainly, in the order a team would meet it: the matrix's open and partial rows, then what the matrix does not show.

Not built yet

  • Generics are declared, not open-ended. Generic records, enums, functions, methods, objects and stores cross as the instantiations you list; a type parameter with no list, native generic types, and generic queries, ports and callbacks do not (generics, the limits, ADR-058).
  • Flutter and Dart. Not started; the plan was to decide after React Native, which has shipped.
  • Desktop and Windows. The Kotlin runtime is plain JVM, but no desktop target ships, and the CLI does not support Windows.
  • A server. The TypeScript runtime needs Node 20 or later; there is no guide to sharing a core with a server.
  • Publishing. Nothing is on crates.io, Maven Central, npm or Homebrew yet; the install channels go live with the first tagged release.

Built, with a known limit

  • A queued write's optimistic placeholder and its invalidates targets do not survive a restart: the write replays, and the screen refetches only the mutation's own key.
  • The web database serves one tab per origin, and a migration that contains its own COMMIT is open.
  • After an undra dev reload a query without persist shows loading once, because a snapshot carries no query data, and an object that is neither a store nor a query handle is not carried over.
  • The binding call path is over its targets: about 300 ns on the iOS simulator against 60 ns, and 0.32 to 0.44 µs for the runtime's synchronous call in Chromium against 80 ns.
  • iOS background tasks do not run in the simulator, so the iOS background handler is tested with fakes and has not run under the OS scheduler here.
  • Every number here is from a host, a simulator, an emulator or Chromium. The iOS 15 and 16 mode is proven by compilation and a runtime probe on iOS 26.5, not on an older runtime.

Where another tool is ahead, and stays ahead

  • Time and backing. KMP has JetBrains and Google's stated support; UniFFI ships in Firefox. Undra is one team's work with no production users.
  • Android. Shared Kotlin is plain Kotlin there, with no boundary. Undra crosses JNI.
  • Language. For an Android-first team Kotlin is the daily language, and in our judgement Rust is the adoption wall.
  • Other hosts. UniFFI serves Python and Ruby. Undra serves three UIs, and React Native on top.
  • Over-the-air logic. Expo's EAS Update ships JavaScript without a new binary. A native Undra core ships with the app; a web core updates with the site.
  • One UI. Undra does not share UI, by decision. For one codebase on every screen, look at Flutter, React Native or Compose Multiplatform.
  • Writing it three times. It needs no new language or toolchain and keeps the best native tooling, with no boundary to debug across.

The default

Default is a claim about which question comes first, not about every team. If your logic is stateful (a cache, a queue, a session, lists that must agree across screens), your UI is native and your team can carry Rust, the open rows above are few enough to check against your own requirements in an afternoon. If one of the open rows is yours, use the tool that has it. If none is, start with L1: move one function, call it from Swift, Kotlin and TypeScript, and see what it costs you before you decide anything else.

Next: why reads never cross the boundary, Undra vs Kotlin Multiplatform, Undra vs UniFFI and Crux, and why Undra keeps your UI native.