Temporal ships client SDKs in seven languages, and those SDKs are not thin HTTP wrappers. Because Temporal provides durable execution, a large amount of correctness-critical logic must run inside the user's process rather than on the server. Spencer Judge's central argument is that writing that logic once in Rust and exposing it through per-language bridges is a viable and worthwhile architecture, provided you understand precisely where the cost moves rather than disappears.
Judge leads the SDK team at Temporal and has spent over a decade building tools, libraries, and products for other developers. He gave this 39-minute, 46-second talk at QCon San Francisco 2025; InfoQ published the recording, transcript, and slides on June 25, 2026. These notes report what he presented and flag supplementary explanation where it is added.
What You Will Learn
- Why some client libraries cannot be made thin, and when that justifies a shared native core.
- What the three-layer core/bridge/language architecture looks like and which layer absorbs the complexity.
- Which per-language helper libraries exist for Rust FFI, and what you must hand-write when none does.
- How to manage type conversion volume with IDLs, macros, and deliberately simple bridge-facing types.
- How to bridge Rust futures to language-native async primitives, including runtimes with a global interpreter lock.
- The manual memory-management discipline required when helper libraries are unavailable.
- Why Java and Go resisted this architecture, and how WebAssembly might change that.
The Problem: Client Logic That Cannot Be Thin
Judge opens with a state machine diagram for a Temporal local activity. His point is not the semantics of the diagram but its size: roughly 800 lines of code behind one state machine. The full set of state machines is about 7,000 lines, sitting inside a repository roughly ten times larger at about 70,000 lines.
This logic cannot move to the server. Temporal is a durable execution platform: users write ordinary code against Temporal's APIs, and the platform makes that execution resilient to process crashes, restarts, and infrastructure failure. Delivering those durability guarantees requires substantial client-side state tracking. Judge notes that making an RPC for every small piece of that logic would not scale, so the SDKs are necessarily "super fat."
Temporal also refuses the alternative escape hatch. Many products in this space define their own graph-based DSL, but Judge argues users hit the limits of such DSLs and do not want to learn them. Meeting users in the languages they already use is a product requirement, which is what creates the multiplication problem: nobody wants to write 7,000 lines of state machine seven times.
The Five Requirements That Shaped the Decision
Judge lists the constraints the business had to satisfy, roughly in priority order.
| Requirement | Why it mattered |
|---|---|
| Reliability | Priority zero. Violating durability guarantees destroys customer trust immediately. |
| Consistency | Divergent behaviour between language SDKs causes serious problems for a platform promising identical semantics. |
| Maintainability | The project started with four or five engineers; the team is now ten people supporting seven languages. |
| Idiomatic APIs | Users should get APIs that feel natural in their language, not a mechanical pass-through of a Rust interface. |
| Performance | Not their tightest constraint, but library code runs in the user's infrastructure and shows up in their cloud bill. |
The maintainability figure is the one worth internalising: ten engineers across seven languages. Judge calls his own team's throughput "mind-blowing," and the architecture is what makes that ratio possible.
Why Rust
Judge is explicit that he is not selling Rust generally; he argues it was the right tool for this job. His stated reasons are the conventional ones — safe, fast, expressive, portable — with portability carrying particular weight because Temporal users run on Linux, macOS, and Windows, across ARM and x64.
Two points are more specific to FFI work. First, Rust is not C, which matters because nobody wants to write and maintain 7,000 lines of state machine in C. Second, Rust is C compatible, which matters more. Judge's position is that there is effectively no other way to call across a language boundary in shared memory today; the C ABI is the universal, long-established standard. You could route everything over IPC instead, but that is a different and less appealing architecture.
He adds a detail that surprises people new to this area: Rust itself has no
stable binary interface. You cannot export a Rust function and call it
directly from another language. Everything crossing the boundary must be
expressed through C-compatible declarations. (Supplementary context: in
practice this means extern "C" functions and #[repr(C)] data layouts;
Judge does not walk through the syntax.)
The rejected alternatives were C — dismissed for obvious safety reasons — and Zig, which Judge calls a cool modern C replacement. Rust won on memory safety, expressiveness, and the maturity of its community relative to Zig.
The Three-Layer Architecture
The deployed shape has the Temporal service on one side and the user's worker process on the other. Judge is careful to generalise: the fact that it is a Temporal worker is incidental. If you adopt this pattern, the equivalent is whatever artifact your users deploy.
Inside the user's process there are three layers the SDK team authors, plus the user's own code on top.
flowchart TD
U["User code (workflows, activities)"]
L["Language SDK layer
idiomatic public API"]
B["Bridge crate (Rust)
one per language, in the language repo"]
C["Rust Core
state machines, shared logic"]
S["Temporal service (gRPC)"]
U --> L
L --> B
B -->|C FFI| C
C <--> SThe Rust Core holds the shared logic. Each bridge is itself a Rust crate, but it lives in the language-specific repository alongside that language's code. Every bridge ultimately reaches the core through C FFI, though depending on the language that fact may be heavily obscured by a helper library. Swift and C# are the exception Judge flags: they share a single bridge.
The language SDK layer is what users program against. Ideally they never learn the lower layers exist, though Judge concedes that in practice they sometimes will. The talk focuses almost entirely on the bridge layer, because that is where the difficulty concentrates.
His design principle for bridges is that they should be thin. The bridge is pure mechanics — it holds no core logic and no user-facing design. Every line in it is a distraction from the two layers that actually carry value. But he is equally clear that thin does not mean trivial: type conversion, async bridging, and memory management all have to be solved there.
Practical Advice: Use the Helper Libraries
Judge's first concrete recommendation is to check whether a maintained Rust-to-host-language helper library exists before hand-rolling C bindings. These libraries paper over the raw C calls and typically handle memory management and async conversion for you.
The ecosystem as Temporal experienced it:
- Python — PyO3. Very capable; Judge repeatedly cites it as the best case.
- Node/TypeScript — Neon. Also very nice, but Node-specific. If your JavaScript is not running in Node, it cannot help you.
- Ruby — Magnus. "Pretty decent," but does considerably less than PyO3 or Neon.
- Swift — none at the time. Apple later published a Swift interop layer built on top of Temporal's core, which Judge highlights as a notable external contribution.
- .NET — something existed, but not mature enough for Temporal's needs.
For Swift and .NET, the team hand-wrote C-exported functions and their representations. Judge frames this as a genuine tier difference in effort, not a minor inconvenience.
Concern One: Type Conversion
Judge says "much blood is spilt" on type conversion, and the volume of code is the reason. The underlying problem is that a single logical type may need several distinct representations:
- An ergonomic core type that is pleasant to write logic against inside the Rust core.
- An ergonomic user-facing type in the host language, because users deserve something nice to work with.
- An IDL-generated type, which sits between them.
- Occasionally a hand-written bridge type for cases the others do not fit.
The friction, in his framing, is that IDLs are excellent at moving bytes between computers but do not generate types that are pleasant to work with by hand. So you get generated types and ergonomic types and the conversion code linking them.
Why Temporal Uses Protobuf
Judge is candid that Protobuf was not a free design choice. Temporal's service is reached over gRPC, so a large body of message definitions already existed in Protobuf and reusing them was the only practical option. He states plainly that he might choose differently given a clean slate, and returns to that later.
Two Mitigations
The first is macros. Rust has a strong macro system, and much conversion code is mechanical field-to-field mapping. Rather than writing thousands of lines of individual field assignments, you write a handful of macros that generate the mapping for everything.
The second is avoiding complicated types at the bridge interface. It is tempting in Rust to build an expressive enum with generics and elaborate trait bounds. That type may be genuinely good inside the core, but Judge warns that "very non-obvious things might happen" when you try to pass it across the C boundary. The remedy is a deliberately simpler representation for the crossing, with conversions on either side. (Supplementary context: generic Rust types have no single fixed layout to expose through a C ABI, which is why the simplification is necessary rather than merely stylistic.)
The Serialization Benchmark
Judge poses this as a pop quiz. Protobuf is a serializing IDL — it must encode data into a wire format, which costs CPU time even when both sides are in the same process. So: which is faster for getting a Rust object into JavaScript under Node?
- Construct the JavaScript object directly using Node's built-in C APIs.
- Serialize the Rust object to JSON, hand the string to Node, and parse it back into an object.
The intuitive answer is option 1. But Judge relays a Slack exchange in which the author of the helper library told a user that Node's object-creation APIs are surprisingly slow, and that round-tripping through JSON might actually win. Judge's reasoning for why that is not absurd: JSON serialization in V8 is "probably one of the most optimized pieces of code on the planet," because it is the hot path for a double-digit percentage of the world's software.
He then benchmarked it himself. In Temporal's specific case, direct object creation was faster — but by a small margin. He ran two tests, a simple object and one with many more fields, and the advantage shrank as field count grew. He speculates that for very large objects the JSON approach may win, while explicitly saying he does not have the details to explain why.
Two lessons follow, and the second is the one he emphasises. The first is the familiar instruction to measure. The second is more consequential for architecture: if you are tempted to reject a serializing IDL on principle because both sides share a process, recognise that the principled objection may carry almost no practical weight. The engineering time saved by not hand-writing type conversions can easily exceed the cost of the redundant serialization.
Concern Two: Bridging Async Concepts
Judge notes an escape hatch first: if your Rust core does pure computation with no I/O, this section may not apply. But if the core makes network calls or touches disk, it will use Rust futures, and you will need to connect them to the host language's own async model.
The mismatch is conceptual as much as technical. Rust has futures; JavaScript has promises; Python has futures; Ruby has fibers. These are broadly the same idea and do not map cleanly onto one another. Worse, you may need to wait in both directions — a Rust future awaited from the language side, and a language-side promise or task awaited from Rust.
Where PyO3 or Neon are available, they handle much of this. Judge's PyO3 example is essentially one step: the Rust future becomes a Python future and you are done. Where they are not available, he offers two techniques.
Technique One: Callbacks
The straightforward approach. You define a Rust function that accepts a callback passed in from the language side — in practice a raw function pointer, since it crosses the language boundary. Inside, you await the Rust future, invoke the callback with the result, and you are finished. Judge suggests this can be non-obvious in context simply because it is buried under everything else happening in the bridge.
Technique Two: An Event Loop, When Callbacks Are Illegal
Callbacks do not always work, and the reason is thread affinity. Several runtimes serialize execution: V8's async event loop runs on one thread, and CPython has a global interpreter lock.
Judge's worked example is Ruby, whose equivalent lock is the GVL (Global VM Lock). Ruby's constraint is stricter than mere serialization: any invocation that will touch Ruby code must occur on a thread Ruby itself created. Rust async code typically runs inside a Tokio-spawned task, which may execute on an arbitrary worker thread the Tokio executor created. Ruby does not own that thread, so calling a Ruby callback from it is not permitted.
The workaround inverts the flow. Every callback passed in from Ruby is wrapped so that, instead of being invoked directly, it pushes a fulfillment event onto a queue. The bridge exposes a "run this loop" function that Ruby is expected to call during program setup. That call occupies a single Ruby-owned thread which spins, pulling callback fulfillment requests off the queue and executing them inside Ruby land.
sequenceDiagram
participant R as Ruby thread
participant Q as Fulfillment queue
participant T as Tokio worker thread
R->>T: call bridge fn, pass callback
R->>Q: enter run_loop, poll queue
T->>T: await Rust future
T->>Q: push fulfillment event
Q->>R: deliver event
R->>R: invoke Ruby callback safelyJudge calls this "quite a bit of hoop jumping" and contrasts it directly with the one-line PyO3 experience. The practical takeaway is that the difficulty of this architecture varies substantially by target language, and that variance should inform which languages you attempt first.
Concern Three: Memory Management
Once again the advice starts with the helpers: use them where they exist, because they largely handle this for you. Without them you are writing unsafe Rust, which Judge describes as very much like writing C.
His warning here is managerial rather than technical. The premise of Rust, he says, is that even very smart people get memory management wrong — and that premise is true. Someone on your team will say they have it handled because they are a good engineer, and they will still make mistakes. Do not plan around individual heroics; take the time to do it properly.
This reinforces the thinness principle. Less code in the bridge means fewer opportunities to introduce a memory bug. It also identifies the one structural advantage over writing the whole system in C: the unsafe surface is confined to the bridge layer rather than spread through 70,000 lines.
The core rule is that memory must be created and destroyed in the same place. Judge describes both directions:
- Rust allocates. Convert the allocation to a raw pointer and pass it to
the language side. Work happens there. The language passes the pointer back,
Rust reconstitutes the original owning type — a
Box, anArc, or similar — and frees it. - The language allocates. Because most of these languages are garbage collected, you must first pin the value so the collector will not move or reclaim it. Pass it to Rust, do the work, pass it back, then release the pin — and free it or not, depending on ownership.
The asymmetry is the point: the allocating side owns the deallocation, and the pointer round-trip exists purely to return ownership to its origin.
Limitations of the Architecture
Judge devotes a full section to what has gone wrong, which is the most transferable part of the talk.
Injecting Behaviour Into the Core
The core performs the gRPC calls to the Temporal service. Users sometimes want to introspect or modify that behaviour — his example is a user wanting to increment a metric whenever a specific RPC is made with a specific parameter.
Without a generic extension mechanism designed in from the start, you are cornered. Do you add a narrowly specific option that serves one user while 99% of users derive no value from it? Judge describes this as a genuinely difficult position, and the accumulation of such "fiddly knobs" as unpleasant.
His retrospective fix is to plan the hooks in advance. Ideally every gRPC operation in the core would route through a generic callback that goes back out to the language layer, letting the language do whatever it wants — potentially including executing the RPC itself, so the side effect never lives in the core at all. He returns to this idea later as a general principle.
Shipping Native Code
Distribution quality varies sharply by ecosystem, and the variance is not something you control.
Python is good at this. The data science community depends on native extensions because pure Python is slow, so PyPI handles platform- and architecture-specific wheels well. Users automatically receive only the binary matching their machine.
npm is not. Judge states that npm does not support this model, so every user taking Temporal's dependency downloads the binaries for every architecture. His comment is simply "too bad."
The Java and Go Wall
This is the hardest limitation. Temporal's Java and Go SDKs are the only two not built on the Rust core, initially because they predated the project. But Judge says the bigger obstacle to porting them — beyond the sheer investment — is that developers in those ecosystems dislike running native extensions.
Java's JNI raises operational concerns. Go's cgo is worse: users must set different build flags and change their build process. Judge is blunt that forcing this on users "just sucks," and treats it as a real, unresolved constraint on the pattern rather than a preference to be argued away.
What Judge Would Do Differently
WebAssembly
Judge presents WebAssembly as genuinely promising for anyone in the business of shipping portable code. His summary for readers unfamiliar with it: WebAssembly is a bytecode, comparable in kind to JVM bytecode and sharing some of its goals. It is fast, portable in the sense that there is no platform-specific compilation step, and constrainable — you can restrict side effects and compute, which makes it strong for running untrusted code. He notes that last property does not matter for his use case but is a nice attribute. Despite the name, it is not limited to browsers.
Three ways it could help this architecture:
- One artifact instead of many. You ship a single bytecode module rather than a matrix of platform and architecture blobs, which directly addresses the npm distribution problem.
- Rust targets it exceptionally well. Judge calls Rust probably the best language for targeting WebAssembly — you select a compiler backend and you are largely done. The caveat is operating system access: disk and similar operations work or fail depending on how the WebAssembly VM is being executed.
- Possibly no native extensions at all. This is his candidate answer to the Java and Go problem. Java has Chicory and Go has wazero, both WebAssembly VM interpreters written in their respective host languages, which therefore need no native extension. Judge says they appear quite complete and will work for pure compute, but qualifies this carefully: he has not used them in anger, and filesystem or network access may or may not work.
He raises one speculative possibility: because the core is just bytecode, you could push dynamic updates of core logic without users upgrading and redeploying. He immediately hedges that this "might be a really bad idea" depending on the use case. (Supplementary context: shipping new executable logic into a user's process without their explicit consent carries obvious supply-chain, auditing, and reproducibility risks; the talk raises the idea without endorsing it.)
A Non-Serializing IDL
Judge would replace Protobuf if he could. The alternatives he names are FlatBuffers and Cap'n Proto. These generate code in multiple languages that uses the same physical memory layout, so there is no encode step at all — both sides read the same bytes directly. He notes wryly that Cap'n Proto's site advertises "infinity faster," which follows trivially when the step is eliminated.
The justification is a striking number: profiling Temporal's core shows it spends 90% of its time serializing. That figure is Temporal's measurement of its own workload, not a general claim, but it explains why he considers this one of his top regrets.
Note the tension with his earlier benchmark advice. Serialization cost was negligible in the narrow object-creation microbenchmark, yet dominates the core profile overall. Both observations are his; the reconciliation is that a cost which is irrelevant at one call site can still dominate when it is on every path.
Route Side Effects Back Through the Language Layer
His third change ties the previous two together. He would push everything with a side effect — network calls, logging, metrics, and anything users might want to customise — back out to the language layer.
This serves a dual purpose. It provides the customisation hooks whose absence created the fiddly-knobs problem. And it removes the WebAssembly caveat entirely: if the core makes no OS calls, you no longer care whether a given WebAssembly runtime supports them. As he puts it, you can kill a number of birds with one stone.
Architecture and Data Flow
Pulling the recommendations together, the architecture Judge would build today differs from the one he has in two ways: the core is delivered as WebAssembly bytecode rather than platform binaries, and all side effects invert to run in the language layer.
flowchart TD
subgraph Today["Shipped architecture"]
A1["Language SDK"] --> A2["Bridge crate"]
A2 -->|C FFI| A3["Rust Core (native binary per platform)"]
A3 -->|"gRPC, logging, metrics"| A4["Outside world"]
end
subgraph Retro["Architecture Judge would build today"]
B1["Language SDK"] --> B2["Bridge / Wasm host"]
B2 -->|"Wasm calls"| B3["Rust Core (single bytecode module, pure logic)"]
B3 -->|"side-effect callback"| B2
B2 -->|"gRPC, logging, metrics"| B4["Outside world"]
endThe second shape makes the core a pure function of its inputs, which is what makes both the single-artifact distribution and the user extension hooks possible.
Trade-offs and Limitations
The pattern is not free, and Judge is explicit about the costs.
- Complexity relocates rather than vanishes. You stop writing state machines seven times, but you start maintaining seven bridges with type conversion, async adaptation, and memory management in each.
- Language support is uneven. Python and Node are comfortable; Ruby requires an event-loop workaround for the GVL; Swift and .NET required hand-written C interop.
- Some ecosystems reject native code culturally. Java's JNI and especially Go's cgo impose operational and build-process burdens that Judge considers a legitimate reason not to force the architecture on users. Temporal's Java and Go SDKs remain independent implementations.
- Distribution quality depends on the package manager. PyPI handles per-platform binaries well; npm forces every user to download every architecture.
- Unsafe code is unavoidable without helper libraries, and Judge's stated experience is that competent engineers will still get it wrong.
- Serialization can dominate. Temporal's core spends 90% of its time serializing, a direct consequence of a Protobuf choice that was itself forced by the existing gRPC contract.
- Missing extension hooks are expensive to retrofit. Without a generic mechanism, each user request becomes a narrow, low-value option.
- The WebAssembly escape route is not yet proven for this use case. Judge has not used Chicory or wazero in anger, and OS-level access through them is uncertain.
- Idiomatic APIs still cost per-language work. You cannot auto-generate the top layer and call it done; new features still touch every language SDK.
Was It Worth It?
Judge's verdict is unambiguous: yes. His reported outcomes, framed as his own estimation rather than measured study, are:
- New language development time cut roughly in half.
- Ongoing payoff on every new feature, which he considers the more important benefit. The language layers still need work to keep APIs good, but the logic is written once.
- Fewer bugs. He acknowledges this is difficult to quantify and argues it from volume: dramatically less code means dramatically fewer bugs, and there is no plausible world in which the shared-core architecture produces more bugs than seven independent implementations.
The reliability and consistency requirements from the start of the talk are what this delivers. Seven implementations of a 7,000-line state machine would diverge; one implementation cannot.
Practical Takeaways
- Check whether your client logic can actually be thin before choosing this pattern. The architecture is justified by unavoidable client-side complexity, not by a general preference for code sharing.
- Survey helper libraries before committing to a language set. PyO3 and Neon do heavy lifting; their absence roughly changes the cost tier of adding a language.
- Keep bridges as thin as you can. Thinness is your primary defence against memory bugs and your main lever on maintenance cost.
- Use code generation, and accept it will not cover everything. Combine an IDL with Rust macros for the mechanical field mapping the IDL leaves behind.
- Prefer simple types at the FFI boundary. Keep expressive generic types inside the core and convert to plain representations for the crossing.
- Choose a non-serializing IDL if you are free to. FlatBuffers or Cap'n Proto eliminate the encode step entirely; Judge's core spends 90% of its time on serialization it does not conceptually need.
- Measure before rejecting an approach on principle. Redundant in-process serialization may cost less than the engineering time to avoid it — but profile the whole system, not just one call site.
- Design generic extension hooks from day one, especially around network calls, logging, and metrics. Retrofitting them is painful.
- Consider routing all side effects out to the language layer. It supplies the hooks and keeps the core portable enough for a WebAssembly target.
- Evaluate WebAssembly for distribution, particularly if you need to reach ecosystems hostile to native extensions — but verify OS-level capability in the specific runtime you plan to use.
- Invest in idiomatic top-layer APIs. Judge presents this as philosophy rather than technique: auto-plumbing everything produces an SDK that feels uncared for.
Key Terms
- Durable execution — A model in which ordinary application code is made resilient to crashes and restarts by the platform, requiring substantial state tracking on the client side.
- FFI (Foreign Function Interface) — The mechanism by which code in one language calls functions compiled from another. Here, the C ABI is used as the universal intermediary.
- C ABI / C-compatible interface — The long-standing binary calling convention that nearly every language can target. Rust has no stable ABI of its own, so cross-language calls must be expressed C-compatibly.
- Bridge crate — A Rust library, living in a language-specific repository, that translates between the shared Rust core and one host language.
- IDL (Interface Definition Language) — A schema language such as Protobuf, FlatBuffers, or Cap'n Proto that generates data types and access code in multiple languages from one definition.
- Serializing IDL — An IDL like Protobuf that encodes data into a wire format, incurring CPU cost even between components sharing a process.
- Zero-copy IDL — An IDL like FlatBuffers or Cap'n Proto whose generated code in different languages reads the same physical memory layout, removing the encode/decode step.
- GIL / GVL — Global Interpreter Lock (CPython) and Global VM Lock (Ruby): runtime locks that serialize execution and constrain which threads may execute host-language code.
- Tokio — The dominant asynchronous runtime for Rust. Tasks spawned on it may run on any of its worker threads, which is what breaks naive callbacks into thread-affine runtimes.
- Native extension — A compiled, platform-specific library loaded into a managed runtime, via JNI in Java or cgo in Go. Both impose operational and build-process costs that some communities resist.
- WebAssembly (Wasm) — A portable bytecode format with no platform-specific compilation step, executable inside and outside browsers, with constrainable side effects and compute.
- Chicory / wazero — WebAssembly interpreters written in pure Java and pure Go respectively, allowing Wasm execution without a native extension.
- Unsafe Rust — Code where the compiler's memory-safety guarantees are suspended, required for raw pointer manipulation across FFI boundaries.
Reference: Spencer Judge, Rust at the Core - Accelerating Polyglot SDK Development, QCon San Francisco 2025, published by InfoQ on June 25, 2026.