I spent a while chasing a question that sounds simple: if I write my logic once (say in Rust), how do other languages actually call it? It is easy to answer that with "stand up a REST service and let everyone make HTTP calls," but that is a different topic (that is microservices, and it does not matter whether the server is remote or sitting on the same machine). What I wanted was tighter than that. I wanted to share one compiled core across languages on the same node, with the boundary living as close to a function call as the platform allows. Once I framed it that way, the landscape collapsed into three boundaries, ordered from loosest coupling to tightest: an in-process-adjacent gRPC channel over protobuf, a WebAssembly module loaded into the host, and a plain foreign-function interface. The interesting part was discovering that one of the three is far more versatile than the other two.

Three boundaries for one core on the same machine

The first boundary is local gRPC over protobuf. You compile your core into a small executable that speaks gRPC, run it co-located with your application, and talk to it over a loopback connection or a Unix domain socket rather than the network (gRPC's resolver accepts unix: targets directly, see the gRPC naming doc). Because the contract is a protobuf schema, every language gets a generated, typed client for free. This is the loosest coupling: two processes, isolated memory, a serialized wire format between them. (There is also a true in-process gRPC channel in some implementations, but it is same-language and mostly used for testing, so it does not help cross-language sharing.)

The second boundary is a WebAssembly module loaded in-process. Instead of a separate process, you compile the core to WASM and load it into the host through a runtime like Wasmtime. The core runs inside your process, in a sandbox, and you call its exports directly. The WebAssembly Component Model turns this into a real cross-language story (typed interfaces that let a host in one language call a module written in another), and projects like Extism package that into a plugin system with host SDKs for many languages at once.

The third boundary is FFI: compile the core into a static or dynamic library, expose a C ABI, and link it straight into the host. This is the tightest coupling (one process, shared address space, a direct function call with no serialization). Signal's libsignal is the canonical example (a Rust core exposed to Swift, Java, and TypeScript over a hand-maintained C boundary), and Mozilla's UniFFI automates much of that binding generation for Swift, Kotlin, and Python.

Protobuf over FFI: a layer on top of the tightest boundary

The plain-FFI boundary carries one cost that its speed tends to hide: the C ABI is a wide, hand-written, unsafe surface, and it grows with every rich type you want to pass. Mozilla's application-services team ran straight into this and wrote up their fix in "Crossing the Rust FFI frontier with Protocol Buffers". Instead of exposing tree-shaped Rust types across the boundary as a growing pile of pointers and accessor functions, they serialize the whole structure to protobuf bytes on the Rust side, pass a single (pointer, length) across FFI, and deserialize it with a battle-tested protobuf library on the Kotlin or Swift side. The FFI interface collapses down to "pass some bytes," which is the one thing FFI does safely and well.

This is not really a fourth boundary; it is a layer on top of the third one. You keep everything that makes FFI attractive (in-process, shared address space, no subprocess, a direct call) and you borrow the part that makes gRPC pleasant (a protobuf schema that generates typed classes in every language and evolves without breaking the ABI). What you give up is the last slice of raw performance: you pay to serialize on the way in and deserialize on the way out, the same cost gRPC pays, minus the socket. In exchange, the hand-maintained unsafe surface shrinks from "one function per operation, per type" down to a couple of bytes-in, bytes-out entry points, which is exactly the maintenance burden that makes a libsignal-style boundary expensive to carry.

So protobuf-over-FFI sits neatly between plain FFI (fastest, widest hand-written surface) and local gRPC (slowest, cleanest isolation): it gives you protobuf's typed, evolvable contract at in-process latency. If you already reach for FFI to stay on the same node but dread hand-writing and versioning a wide C ABI across three languages, this is the layer that buys most of gRPC's ergonomics without ever spawning a process. It is worth being honest that this is not free or zero-copy: the bytes are still copied and parsed on both sides, so it is a maintenance-and-safety trade, not a performance one.

WebAssembly is the most versatile boundary

Of the three, WebAssembly is the one I keep coming back to, because it is the only boundary that survives everywhere. The gRPC approach needs you to spawn a subprocess. FFI needs you to link native code for each target architecture. WebAssembly needs neither: one portable artifact runs in-process inside a browser tab, inside a phone app, or inside a server, without a subprocess and without a per-architecture native build. It is sandboxed by default (so a bug in the core cannot reach the rest of the host), and with the component model it composes across languages the way FFI does, but without hand-writing a C boundary for each one. That is why 1Password keeps its logic in a Rust core and compiles it to WASM for the web instead of re-implementing it in JavaScript (Syntax podcast, episode 776, with Andrew Burkhart).

The most striking demonstration of that portability is how the Zig language bootstraps its own compiler. Bootstrapping a self-hosted compiler has a chicken-and-egg problem (you need a Zig compiler to build the Zig compiler), and the usual fix is to keep an old native binary around per platform. Zig instead commits a WebAssembly build of the compiler (zig1.wasm) to its source tree. On a fresh machine with nothing but a C compiler, the build converts that WASM to portable C (via a bundled wasm2c), compiles it, and uses the result to build a real native Zig compiler from source (the mechanism is described in the Zig 0.10 release notes, and the artifact lives in the Zig repository). WebAssembly is acting as a stage-one, architecture-neutral seed: a single committed blob that stands in for "a working compiler" on any target. If WASM is portable enough to bootstrap a compiler onto a platform it has never seen, it is portable enough to carry your shared core there too.

The platform often decides for you

You do not always get to pick the boundary (the platform picks it for you). On the desktop and on servers (Linux, Windows, macOS) all three are available, so the choice is yours. The mobile and web targets are where the constraints bite, and iOS and Android sit on opposite sides of the line.

iOS forbids spawning a subprocess at all. Its sandbox blocks the traditional UNIX mechanisms (fork(), exec(), posix_spawn(), system()), enforced by the kernel rather than by App Review, so there is no entitlement that re-enables them. An Apple Developer Technical Support engineer states it plainly on the Apple Developer Forums (see also the fork discussion), and the App Sandbox model confirms each app is confined to its own container. That rules out the gRPC-subprocess boundary on iOS and leaves you with WASM or FFI.

Android does not have this restriction. An Android app can spawn child processes through ProcessBuilder or Runtime.exec(), so the local-gRPC boundary is available there (with the caveat that, for security, modern Android blocks executing binaries from app-writable storage and expects executables to come from the app's bundled native libraries, per the platform's W^X hardening). The browser is the other constrained target: no subprocesses, no native linking, only WASM. So across the four targets that matter (desktop/server, iOS, Android, browser), WebAssembly is the single boundary that is available on all of them.

Performance, portability, and the cost of maintenance

Picking between the three, when the platform leaves it open, comes down to a trade among three things that pull against each other. On raw performance, FFI wins: a direct call in a shared address space with no serialization is as fast as it gets, which is why latency-critical cores (cryptography, real-time media, tight inner loops) reach for it. WebAssembly is close behind (near-native execution, with a small cost for the sandbox boundary and for copying data in and out of linear memory). Local gRPC is the slowest of the three, because every call serializes arguments to protobuf, crosses a socket, and deserializes on the other side. For the vast majority of applications that gRPC overhead is irrelevant next to the clarity it buys, but it is real, and it is the reason you would ever collapse the boundary inward.

Portability runs in the opposite direction. FFI is the least portable: you need a native build per architecture, and you inherit manual memory management across the boundary. gRPC is more portable (any language with a protobuf plugin gets a client) but still needs a subprocess, so it cannot go where iOS or the browser go. WebAssembly is the most portable of all, as the whole point of the previous section: one artifact, every target, no subprocess and no per-architecture native build.

The third axis, the cost of maintenance, is the one that is easiest to underestimate and often decides real projects. gRPC is cheapest to maintain across many languages, because the per-language surface is generated from the schema (add a language, generate a stub, write a thin wrapper). FFI is the most expensive, because the boundary is hand-written and unsafe in every language you support (libsignal maintains exactly this, and it is real, ongoing work, which is what UniFFI exists to reduce, and which the protobuf-over-FFI layer above trades a little serialization cost to shrink). WebAssembly again lands in the middle: the component model generates much of the cross-language glue, but the runtime is a dependency you carry and the toolchain is younger than protobuf's. The honest summary is that there is no free boundary. If you can spawn a process and latency is not critical, local gRPC gives you the lowest maintenance and the cleanest isolation. If you need the last microsecond, FFI earns its keep. And if you need one core to run in a browser, a phone, and a server without rewriting the boundary three times, WebAssembly is the versatile default that the other two cannot match.


This is the practical follow-through to my thinking on language choice in the LLM era: the point was never to crown one language for everything, but to keep the shared logic in one place and let each layer use the language that fits. Same-node code sharing is where that intention meets the operating system, and the boundary you can afford (in performance, in portability, and in maintenance) is usually the boundary the platform was going to allow anyway.