Mobile Computing · Cross-Platform Development

Marshalling Across the Language Boundary

Every call between shared code and platform code crosses a runtime boundary with a serialisation step, a scheduler hop, and a lifetime problem. The cost is dominated by crossings, not by bytes.

A shared core in one language and platform adapters in another meet at a foreign function interface. On Android that interface is usually JNI between the JVM or ART and C, C++, or Rust; on iOS it is the Objective-C runtime and, since Swift, a generated bridging layer; in a JavaScript-based toolkit it is the host object interface between the JavaScript engine and native modules. All of them present the same shape: a call from one side to the other that involves converting arguments, transferring control to a different execution model, and then converting results back.

The interesting engineering fact about this boundary is that its cost is dominated by the number of crossings rather than by the amount of data that crosses. A call that passes one integer and a call that passes a 64-kilobyte buffer can cost the same order of magnitude if the buffer is passed by reference, and both can cost far more than the arithmetic the callee performs. Optimisation therefore means batching, not compacting.

What a single crossing actually does

Take a JNI call from Kotlin into a C++ function. The sequence is roughly: the caller resolves the method identifier, which the runtime caches after first use; arguments are converted, with strings becoming modified UTF-8 and objects becoming local references in a per-thread handle table; control enters the native method through a generated stub that sets up the native frame; the native code runs; results are converted back, with local references released or leaked; control returns through the stub; and any exception thrown on the native side is deferred until the return to the managed frame.

Several of these steps have costs that are easy to underestimate.

A local reference is an entry in a table the runtime allocates per native frame, with a default capacity that is deliberately small: the JNI specification only guarantees room for 16, and implementations commonly allow more, so code that relies on the larger number works on one runtime and exhausts the table on another. A native loop that creates a Java object per iteration without releasing references exhausts that table and aborts the process, which is why JNI provides an explicit delete function and why the reference count is a real resource rather than an abstraction. The table is not a stack of pointers to the heap; it is a handle table, and every dereference goes through it.

Strings are the second trap. JNI has two string encodings: modified UTF-8, which is not standard UTF-8 because it encodes U+0000 as two bytes and supplementary characters as surrogate pairs, and UTF-16. A round trip through the modified form for a string containing an emoji therefore does more work and produces a different byte sequence than the caller might expect, and the conversion allocates a copy on both sides. For a large string passed across a boundary repeatedly, the conversion is the dominant cost.

Third, there is a thread attachment rule. Native code that calls back into the managed runtime must be attached to it, and attaching a thread that was created natively costs far more than an attached call. The usual pattern is to attach once per thread and hold the attachment for the thread’s lifetime, which is correct and is also a source of leaks when the thread is pooled and never detached.

The asynchronous bridge is a queue, not a call

JavaScript-based toolkits add a second layer. The JavaScript engine and the native side do not share a call stack, so a call is not a call: it is a message placed on a queue, drained on the other side’s run loop, with the result delivered by callback or promise. React Native’s bridge is the canonical example, and the rearchitectures of that bridge in recent years have all been attempts to reduce the number of messages and to remove the serialisation of payloads that the queue requires.

Modelling the bridge as a queue makes its performance properties obvious. Latency is at least one run-loop turn, often two, and grows with queue depth. A turn that lands after the frame deadline costs 16.6 ms at 60 Hz, which is the entire frame. Throughput is bounded by serialisation and deserialisation on both ends. Ordering is preserved per queue, which is convenient, but a batch of independent property updates still costs one message each unless the API lets the caller aggregate them.

The practical consequence is the same as on the JNI side, only sharper: the design that performs well is the one that sends few messages with coarse payloads. Setting a hundred properties on one view in one message is much cheaper than a hundred messages, and it is also the design that a declarative description naturally produces, because a description is already a batch. The frameworks that expose a batched update path do so for this reason.

Ownership and lifetime at the seam

The hardest correctness problem at a language boundary is not cost but ownership. Each runtime has its own notion of when an object dies, and the two collectors do not know about each other.

3 arrangements exist. The first is a copy at the boundary: the caller’s object is converted into a value the callee owns, and the callee’s copy is freed by an explicit rule. This is safe and it is what happens to primitives and to strings in most bindings. The second is a borrowed reference valid for the duration of the call, which is cheap and requires the callee not to retain it. The third is a shared ownership protocol, usually a reference count or an explicit handle, where the callee holds a strong reference and must release it.

The bug that appears in every codebase that uses the third arrangement is a cycle that neither collector can see: a native object holds a reference to a managed object, the managed object holds a reference to the native handle, and each side considers its own reference the only one. Neither frees. The usual mitigations are weak references on one side, which requires the runtime to support them across the boundary, or an explicit lifetime tied to a scope the application controls, such as the lifetime of a screen.

The second bug is a use-after-free that looks like a crash in unrelated code: the native side caches a pointer to a managed object, the managed object is collected, and the pointer becomes a dangling handle. Runtimes that move objects during collection make this worse, because the pointer is invalidated even without collection. The fix is to hold a handle rather than a pointer and to go through the runtime’s indirection on every access, which is exactly what JNI’s local and global references are.

Conversions that silently change semantics

Beyond cost and lifetime, a boundary converts representations, and conversions are lossy in ways that are documented but easy to forget.

Integers are the clearest case. A JavaScript number is a double; a 64-bit integer larger than 2^53 cannot be represented exactly, so a timestamp in nanoseconds or a database identifier generated as a random 64-bit value loses low-order bits when it crosses into JavaScript. The convention is to pass such values as strings or as pairs of 32-bit halves, and the failure mode is a silent off-by-something in an identifier that only shows up when two records collide.

Booleans, nullability, and absence are the second case. Languages differ on whether a value can be absent, whether an absent value is distinct from a zero value, and whether an empty collection is distinct from a missing one. A binding that maps a nullable field onto a non-nullable one must choose a sentinel, and the sentinel eventually collides with a legitimate value.

Collections are the third. An array passed across a boundary is usually copied, sometimes lazily, and a lazily copied array whose backing store is mutated by the caller after the call is a data race with no diagnostic. The safe rule is that anything crossing the boundary is immutable from the moment of the call, which is cheap to state and requires discipline to enforce.

Dates deserve a mention because they combine all three. A date crossing a boundary can be a wall-clock string, an epoch millisecond count, or an object with a timezone attached, and the three are not interchangeable. The portable representation is an RFC 3339 timestamp with an explicit offset, and the boundary should convert to it at the edge rather than pass a platform date object through.

Designing the boundary for few crossings

The design rules that follow from the mechanics are short.

Batch by screen or by frame, not by property. A single call that carries a complete description costs one crossing and one serialisation, while a call per property costs one of each per property and the serialisation overhead is paid on small payloads where it is proportionally worst. One batched crossing per frame is 60 per second at 60 Hz; one per property on a screen with 200 properties is 12,000 per second.

Keep hot loops on one side. A native function that calls back into managed code per element of a collection should instead take the whole collection and return a result, because each callback is a crossing and callbacks also re-enter the runtime’s scheduler: 1,000 elements with 1 callback each is 1,000 re-entries, against 1 crossing for the whole array.

Avoid converting strings repeatedly. Cache the converted form on the side that uses it, or pass a handle to an already-converted string, rather than converting on every call. The same applies to identifiers used as map keys.

Make the crossing visible in the API. A function named updateAll that internally performs two hundred crossings is a trap; a function whose signature takes an array is honest about the cost. The port design described in The Portable Core Boundary helps here, because a narrow port interface with coarse operations is naturally low-crossing.

Prefer a shared memory region for large payloads. A megabyte buffer copied across a boundary costs a copy on each side; a buffer allocated from a region both runtimes can map costs the mapping and nothing per access. This is the reason graphics and media APIs at these boundaries take handles to buffers rather than arrays.

Entry points should be batch-shaped rather than per-item. The canonical form is a call that takes an array and a count, so the caller pays one crossing for the whole collection instead of one per element:

typedef struct { int32_t id; double x; double y; } NodeUpdate;

/* One crossing for the whole batch. Inside, the conversion loop runs on the
 * native side, where it costs no boundary transitions at all. */
void apply_updates(NodeUpdate *updates, size_t count);

The same rule applies in the other direction: a native callback invoked per element forces the managed runtime to re-enter its scheduler once per element, which is strictly worse than returning a result array once.

Where the boundary stops being worth it

A boundary that is crossed a few times per frame is free. A boundary crossed 10,000 times per second is a design error, and the fix is not a faster marshaller but a different split.

The signal to watch is the ratio of crossing cost to work performed. When a function’s body is a few arithmetic operations and it is called from the other side per item, the boundary is the program. Moving that loop to the other side, or restructuring the interface so the loop lives where the data lives, recovers more than any micro-optimisation of the conversion path. The same reasoning applies at the UI layer, where the choice of who owns the scene graph, discussed in Retained Native Widgets or an Owned Render Tree, determines how many crossings a frame requires before any code is written.

References

  1. Java Native Interface Specification — Oracle
  2. Dart C Interoperability — Dart project
  3. WebAssembly Core Specification — W3C