Architecture
Reads never cross the boundary: how Undra's state model works
Every cross-language framework has to decide what crosses the boundary, how often, and what it costs. Undra's answer fits in a sentence: reads never cross the boundary, and writes cross once per transaction. This post walks through the mechanism with real generated code from the playground app, the numbers we measured with links to the raw results, and an honest section on what the design costs you.
The rule
There are roughly three ways to keep native UI in step with state that lives in another language. The UI can ask for each value when it needs it, which costs a crossing per read per render. The core can send the whole state after every change, which costs a crossing proportional to the state. Or the UI can hold a copy, and the core can send only what changed. Undra does the third, and states it as the fifth rule of its constitution: reads never cross the boundary; writes cross once per transaction; no generated getter calls into the core. The UniFFI and Crux comparison looks at how other tools place themselves among these options.
There is a second reason for the rule besides speed. If reads crossed, a core in a web worker or behind a WebSocket during undra dev would need every read to be asynchronous. Because reads are local, the same generated API works in-process, in a worker and over a socket.
The mirror model
You declare state in Rust as a store of signals. Fields of type Signal<T>, Computed<T> and DerivedList<T> are the signals, numbered in declaration order; the rest is private:
Playground code, comments added: examples/playground/core/src/todos.rs
#[undra::store(restore = "Self::assemble")]
pub struct Todos {
next: AtomicU64, // private state: not a signal
#[undra(key = "id")]
todos: Signal<Vec<Todo>>, // signal 0
filter: Signal<Filter>, // signal 1
#[undra(key = "id")]
visible: DerivedList<Todo>, // signal 2, derived in the core, sent as patches
remaining: Computed<u32>, // signal 3, derived in the core
}
The macros record this in a schema, and undra bindgen turns the schema into Swift, Kotlin and TypeScript. Each platform runtime holds a mirror, a map from store handle to signal id to decoded value, and the generated store exposes its slice of it in the platform's own idiom. This is the generated Swift, 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
public private(set) var visible: [Todo] = []
public private(set) var remaining: UInt32 = 0
public override func apply(signal: UInt32, op: ChangeOp, reader: inout UndraReader) {
do {
switch signal {
case 0:
switch op {
case .fullValue:
let value = try [Todo].undraDecode(&reader)
try reader.finish()
self.todos = value
case .keyedPatch:
let ops: [PatchOp<Todo>] = try decodePatch(&reader)
try reader.finish()
try applyPatch(ops, to: &self.todos)
case .lazyListInvalidated:
break
}
// ... cases 1 to 3 decode `filter`, `visible` and `remaining` ...
default:
break
}
} catch is PatchError {
// The mirror diverged from the core: re-observe to receive a full value.
self.core.observe(self.handle, signal: signal, on: false)
self.core.observe(self.handle, signal: signal, on: true)
} catch { /* ... */ }
}
}
Kotlin exposes the same signals as StateFlows and TypeScript as Signals:
Generated by undra bindgen (abbreviated): examples/playground/generated/kotlin
class Todos private constructor(core: UndraCore, handle: Long) : UndraStore(core, handle) {
private val _todos: MutableStateFlow<List<Todo>> = signal(emptyList())
val todos: StateFlow<List<Todo>> = _todos.asStateFlow()
private val _visible: MutableStateFlow<List<Todo>> = signal(emptyList())
/** Derived by the core from another list; read-only. Changes arrive as keyed patches. */
val visible: StateFlow<List<Todo>> = _visible.asStateFlow()
// ...
}
Generated by undra bindgen (abbreviated): examples/playground/generated/ts
export class Todos extends UndraStore {
readonly todos: Signal<Todo[]> = new Signal<Todo[]>([]);
/** Derived by the core from another list; read-only. Changes arrive as keyed patches. */
readonly visible: Signal<Todo[]> = new Signal<Todo[]>([]);
// ...
}
A screen reads these like any other state: a SwiftUI view reads todos.visible, a Compose screen calls store.visible.collectAsState(), and a React component calls useSignal(todos.visible), which is built on useSyncExternalStore. A contract test, scenario S11, reads four signals 1,000 times and asserts that the core's crossing counter did not change (scenarios).
A write crosses once
A write is a call. The runtime encodes the arguments, takes a call id, and crosses the boundary once. The core runs the method, and everything it writes inside one transaction is committed together. The counter store in the playground shows the idea:
Playground code, reformatted: examples/playground/core/src/counter.rs
pub fn add(&self, amount: i32) {
self.ctx.txn(|| {
self.count.update(|count| *count = count.saturating_add(amount));
self.changes.update(|changes| *changes = changes.saturating_add(1));
}); // one change-set carries count, changes and the computed parity
}
On commit the core recomputes the dirty computed values in dependency order, then builds one change-set for each store the transaction touched, containing only signals that the platform has observed and that changed. The layout is small:
ChangeSet payload (docs/SPEC.md section 3.5)
txn_id u64
count u32
entries count x { handle u64, signal_id u32, op u8, len u32, value bytes }
op 0 = full value, 1 = keyed patch, 2 = lazy list invalidated
Scenario S09 pins the behaviour: counter.add(5) delivers three entries, for count, changes and parity, and the transaction counter grows by exactly one. Change-sets of one store reach the platform one at a time, in commit order, with strictly increasing transaction ids (ADR-020). Observing a signal sends its current value at once, so a store's initial state is just a change-set.
The platform runtime receives the payload on whatever thread produced it, possibly while a core lock is held, so it never calls back into the core from that callback. It takes the payload out and applies everything that has arrived in one hop to the main thread: the Swift runtime on the MainActor, Kotlin on Dispatchers.Main.immediate, TypeScript in one flush per macrotask. Applying means running the store's generated apply, which sets the @Observable property, the StateFlow value or the Signal. The UI framework does the rest.
Keyed lists: recorded operations, not diffs
A list marked #[undra(key = "id")] sends patches made of insert, remove, update, move and clear operations instead of the whole list. The first version of this did not meet its own promise, and the story is in the repository because the benchmark caught it.
Originally the core found the patch by comparing the list as it is with a copy of the list as the host last saw it. That comparison hashes every key of both lists and compares surviving rows, so its cost was linear in the list and independent of the change. One insert into an observed 10,000-row list took 536.16 µs, and 85 percent of that was the diff (Finding 1). The store code had known exactly what it did, and that information was thrown away at the Vec and rediscovered at commit.
The fix, decided in ADR-027, records the operation instead. Signal<Vec<T>> has push, insert, remove, update_at, move_item and clear. Each mutates the list and appends the wire operation it performed to a log, and a commit sends the log as the patch. The wire did not change.
Playground code, abridged: examples/playground/core/src/biglist.rs
pub fn insert_at(&self, index: u32, label: String) -> Result<u32, ListError> {
// ...check the index...
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
// The recorded list ops keep the change-set O(change) (ADR-027).
self.items.insert(index as usize, Item { id, label, version: 0 });
Ok(id)
}
| One change, 10,000 observed rows | Diffed (before ADR-027) | Recorded operation |
|---|---|---|
| Insert | 536.16 µs | 6.31 µs |
| Update | 532.32 µs | 272.3 ns |
| Move | 702.95 µs | 9.74 µs |
These are host-measured, core-side medians on an Apple M5 Pro. The remaining 6.31 µs is mostly the memmove of the vector's tail, paid twice because the core keeps a baseline copy. Scenario S10 checks the wire: an insert into the 10,000-row list arrives as a single Insert operation in under 100 bytes. On the platform, the Swift runtime, for one, validates every index of a patch against the list as it will be at that point before it changes anything, so a bad patch cannot leave the list half changed. If a patch does not fit, the generated store treats the signal as out of sync and re-observes it, which delivers a full value.
The fast path is opt-in. A list written with set, update or replace is still found by diffing against what the host has, which is always correct and O(list): 528.7 µs for the same one-row edit in the benchmark gate's harness. The to-do store in the playground uses update, so it pays the diff. For a to-do list that is irrelevant. For the 10,000-row list it is the difference between microseconds and half a millisecond.
The schema hash gate
Everything above assumes that the core and the generated code agree on the layout of every type. Undra makes that a checked property, not a convention. The macros emit a description of every public type and function, which is the schema. It is serialised canonically (lists sorted by name, no whitespace, doc comments excluded) and hashed with 64-bit FNV-1a (SPEC section 2.3). Two cores with the same public surface have the same hash. Each generated package carries the hash it was generated from, and you pass it when loading the core:
Real playground code: examples/playground/ios/PlaygroundApp/UndraBootstrap.swift
try UndraCore.load(.inproc(adapters: adapters, expectedSchemaHash: UndraIds.schemaHash))
Playground code, abridged: examples/playground/android/.../UndraApp.kt
UndraCore.load(LoadOptions(expectedSchemaHash = UndraIds.SCHEMA_HASH, adapters = adapters))
Playground code, abridged: examples/playground/web/src/undra.ts
await UndraCore.load({ mode: "wasm-main", wasm, expectedSchemaHash: UndraIds.schemaHash, adapters });
If the hashes differ, loading fails before the core is initialised with a typed error carrying both hashes (UndraSchemaMismatchError in Swift and TypeScript, UndraSchemaMismatchException in Kotlin), and scenario S16 tests that on all three platforms. The remote and worker transports carry the hash in every message header. At build time undra bindgen --check exits non-zero when generated files are stale, so a stale binding fails in CI.
This is a hard gate and a blunt one. Any change to the public surface changes the hash, and v1 has no evolution across a mismatch. Breaking the wire is a major version and needs a decision record first. Other tools have comparable load-time checks, and we do not claim this one is unique.
Typed errors and panic containment
An error in a Result<T, E> is a value of a #[undra::error] enum, and it arrives as the platform's natural typed error:
Generated by undra bindgen (abbreviated)
public enum TodoError: UndraError, Error, Sendable, Hashable {
/// The title is empty once spaces are trimmed.
case emptyTitle
}
// store.add(title:) is declared `async throws` and throws a TodoError as itself
Generated by undra bindgen (abbreviated)
sealed class TodoError(message: String) : UndraException(message) {
data object EmptyTitle : TodoError("the title cannot be empty")
}
TypeScript gets TodoError.EmptyTitle as an Error subclass you can test with instanceof. Replies carry a status byte: ok, typed error, panic, cancelled, stream opened or bad request, and decoders return typed errors for malformed input and never panic.
A panic in the core is caught at the dispatch boundary on native targets and answered with a status that carries the panic message and a backtrace. The process is not aborted, the core keeps serving calls, and the log port receives a record at error level or above. Scenario S17 verifies this over JNI and the C ABI. What the host does with that reply is its own policy, and every platform turns it into a value the app can catch. Generated code turns a panic into UndraCallError.Panicked on every platform (Swift spells it .panicked(message:backtrace:)): a call fails with exactly one of three things, its own typed error (TodoError), the platform's cancellation (CancellationError, CancellationException or the AbortSignal's reason) when the caller cancelled, or UndraCallError for a panic, a cancellation by the core, a refused call, an unreachable core or a reply it cannot read. A synchronous method that returns nothing, such as todos.toggle(id:), is a command and never throws or rejects, so a SwiftUI button, a Compose onClick or a React handler can call it; if it fails, the runtime logs the failure, hands it to onError and returns. Nothing in the generated code stops the process or leaves an unhandled rejection (ADR-032), and S17 calls the generated explode on all three platforms.
On the web the shipped wasm profile is built with panic=abort, so a panic traps the module. Containment there means the page survives: the failing call rejects, the log adapter receives a fatal record first, and the page can load a fresh core and restore a snapshot of the stores, which S17 also tests. Snapshot and restore keep handles valid and cancel the calls that were running on the stores they replaced (ADR-023).
Determinism
The core reads no clock and no random source and starts no thread outside the runtime. Time comes from the Clock port, delays from the Timer port, randomness from Rng, the network from Http. That is rule R12. The to-do store shows the discipline: its item identities come from a counter, not from a random source, and after a restore the counter continues above the largest identity in the snapshot.
The payoff is testability. In Rust, TestRuntime installs deterministic fakes for every port, including a FakeClock that fires timers when you advance it, and runs the executor on the test thread. On each platform, the contract tests supply a manual clock and an in-memory HTTP server. Scenario S12 advances the clock by 31 seconds to prove that a cached query becomes stale and refetches, without waiting 31 seconds. This is determinism of inputs. It does not make native thread scheduling deterministic, and we do not claim replayability beyond what the tests exercise.
What we measured
Every number here comes from bench/RESULTS.md or the README, measured on an Apple M5 Pro with release builds, on a machine that was sharing load with other builds.
| Operation | Median | Source |
|---|---|---|
| Synchronous call through the C ABI, core side | 49.8 ns | C ABI table |
| Same call without the allocation the ABI owes | 31.5 ns | Dispatch table |
| 1 KB record, codec round trip | 228.0 ns | Section 14 rows |
| Change-set for 100 dirty signals, core side | 2.30 µs | Signals and stores |
| Decoding that change-set, borrowed | 253.9 ns | Signals and stores |
| Decoding and replaying a one-operation patch on 10,000 rows (Rust wire crate) | 2.57 µs | Keyed patch |
| Cold start, restoring 100 KB of state | 70.87 µs | Snapshot |
| Wasm runtime plus hello-world core, gzipped | 113.1 KB | Size record |
| Android core, hello world, arm64-v8a, release | 898.2 KB | Size record |
Read these for what they are. They are core-side, on a host faster than the phones the design targets, so within budget here is necessary and not sufficient. The per-device targets (an iPhone with an A15, a 2022 mid-range Android phone, Chromium) are measured in a later device phase, and the platform half of a crossing, meaning the Swift, JNI or JavaScript side and applying a change-set to a mirror, is not in these numbers. The 46 operations covered by the CI gate have budgets about five times what they measure, so the gate catches an operation that becomes several times slower, not a few percent (the gate). We have not published a stress benchmark for sustained very high write rates, and we have not compared Undra against other tools, so this page makes no such comparison.
What this costs
Every design has a price, and the honest version of this post lists it.
- Memory for copies. An observed keyed list exists in the core, in a baseline copy the core keeps to build patches against, and in the platform mirror. A computed value is sent whole and held in the mirror too. If you observe a large keyed list, you pay for three copies of it.
- A computed list crosses whole. A keyed
Signal<Vec<T>>and aDerivedList<T>get patches. AComputed<Vec<T>>crosses as a full value whenever it changes, so declare a filtered or sorted view of a large list as aDerivedList(ADR-039, added on 2 October 2026), which ships one edited row of a 10,000-row list in 158 bytes where the computed shipped 352.6 KB. - A crossing per write. Writes are calls: they encode arguments, cross, and run under the core's single lock. A long synchronous method blocks other callers, so long work belongs in an async method. Every TypeScript method returns a
Promise. - The O(change) path is opt-in. The recorded list operations are what make patches cheap. A raw
updateclosure is correct and O(list). - The discipline of a schema. Every public type exists in the schema or it does not cross. Names become ids, so renaming a method changes the wire. Any public change changes the hash, bindings must be regenerated, and v1 has no version skew between a core and its UI. You get a build failure instead of a corrupt decode, and you pay for it in ceremony.
- The host contract. Callbacks can run on any thread, possibly with a core lock held. A host must not call back into the core from them; re-entry is refused with an error in every build instead of deadlocking, but the runtimes have to honour the rule.
- Rust. The core is Rust, with the learning curve that implies, and the wasm build aborts on panic.
- Numbers that are not yet device numbers. See above.
The specification is the binding description of everything in this post, and the decision records say why each choice was made. To try it, start with the getting started guide, or read how Undra compares with Kotlin Multiplatform and why it keeps your UI native.