Learn when Rust is worth introducing into a Clojure mobile stack, which boundaries should stay in Kotlin or Swift, and how to keep FFI and native interop maintainable.
FFI (Foreign Function Interface): A boundary that lets one language call code compiled from another language, usually through a shared library or generated bindings.
Rust belongs in a Clojure mobile stack only when there is a real low-level reason for it: cryptography, media processing, binary parsing, compression, offline sync engines, or performance-critical algorithms that should not live in JavaScript or high-level JVM code. If the problem is ordinary business logic, moving it into Rust usually adds more integration cost than product value.
The key design question is not “Can Clojure call Rust?” It can. The more important question is “Where should the language boundary live so the app stays understandable?” On mobile, the best answer is usually: keep Rust behind a small host-language wrapper, and let the shared Clojure or ClojureScript layer call that wrapper instead of talking directly to a native boundary everywhere.
The cleanest mobile shape usually looks like this:
This layered boundary is often better than calling raw native functions directly from application code because it gives each layer a focused job:
That is easier to test, easier to replace, and easier to debug when one platform behaves differently than another.
Older examples often jump straight to raw FFI from Clojure. That can be useful in controlled environments, but it is rarely the best default for mobile product code.
On mobile, raw FFI boundaries create pressure around:
This is why raw FFI is usually the wrong product-level abstraction. The more the application sees ABI details, pointer rules, or low-level boundary errors, the more the product code starts carrying infrastructure cost it should not own.
For Android-hosted Clojure, a safer pattern is often:
For iOS or mixed-platform systems, generated bindings such as UniFFI can reduce some manual glue work, though they still need disciplined API design.
Suppose the team needs a high-performance payload decoder for an offline-first app. The Rust side owns the binary parsing and validation rules:
1pub fn decode_payload(bytes: &[u8]) -> Result<String, DecodeError> {
2 // parse, validate, and transform compact binary payloads
3}
On Android, a thin host-language wrapper presents a stable app-facing method:
1object PayloadDecoder {
2 external fun decodePayload(bytes: ByteArray): String
3}
Then the Clojure layer consumes that wrapper through ordinary interop:
1(ns mobile.decode
2 (:import [com.example.mobile PayloadDecoder]))
3
4(defn decode-payload [bytes]
5 (PayloadDecoder/decodePayload bytes))
This is usually healthier than exposing raw native details all the way up to the app layer. The Clojure code sees a domain operation, not the mechanics of a shared library call.
Rust is a strong candidate when the logic has one or more of these traits:
Rust is a weak candidate when the work is mostly:
If product managers change it every week, that code usually belongs in the high-level application layer.
The interop boundary should not leak Rust-only error semantics into the entire app. That means choosing one of a few clear strategies:
What you should avoid is sprinkling boundary-specific exception handling everywhere in the product code. That creates a system where no one can tell whether an error came from domain logic, network state, or a low-level native failure.
Strong interop work usually needs three kinds of tests:
If only the end-to-end app reveals boundary failures, the interop surface is probably too implicit.
Cross-language calls are not free. A Rust optimization can lose much of its value if the app crosses the boundary thousands of times per screen.
The fix is to make the boundary coarser:
In practice, good Rust integration often looks less like “many tiny calls to native code” and more like “a small number of carefully chosen native operations.”
On mobile, the hardest part is often not the Rust code itself. It is packaging and release discipline:
This is why mobile teams should resist adding Rust casually. The technical gain must justify the build and release complexity.
Once a native library enters the release path, ABI changes, build reproducibility, and symbol/debug workflows become part of the team’s normal operating burden. That is why Rust should be introduced for durable reasons, not curiosity.
If those questions do not have good answers, Rust is probably being introduced too early.