Blog · 4 min read

GraalVM native-image can only build for the machine it's running on

Kristof Polleunis · August 19, 2026

Target: This Week in Rust, Java Weekly, r/java, r/rust. ~1000 words. Note: TWiR requires substantial Rust-specific content — the Tauri sidecar section carries that. Disclose LLM assistance if used, per their rules.


I needed a universal macOS binary — arm64 and x86_64 in one file — that included a Java regex engine. Not an approximation of java.util.regex, the actual thing, because Java's dialect has possessive quantifiers, its own \p{...} block semantics, and lookbehind rules that differ from PCRE in ways that bite.

GraalVM native-image compiles Java to a standalone binary. Perfect. Build it twice, lipo the slices together, done.

Except native-image can only emit for the architecture of the JVM running it.

The constraint

There is no --target flag. The compiler is not a cross-compiler in the sense you're used to from clang or rustc. It emits for its host.

So producing a universal binary requires two complete GraalVM installations — an arm64 one to build the arm64 slice, an x86_64 one to build the x86_64 slice. The build script has to detect which is which:

# native-image can only emit for the architecture of the running JVM,
# so producing both slices of a universal build requires *two* GraalVM
# installs. Locate one whose `java` binary matches the target arch.

You resolve that by reading each candidate java binary's file header — the part of a Mach-O executable that records which architecture it was built for — and matching it against the target you're building. It's not difficult, but nothing tells you it's necessary — you discover it when the second slice comes out the wrong architecture and lipo refuses.

Then it got worse

Running the x86_64 GraalVM under Rosetta on an arm64 Mac gets you an x86_64 JVM, so native-image should emit x86_64. It does. But it fails first, on two separate checks:

-H:-CheckToolchain
-H:CCompilerOption=-target x86_64-apple-macos

The first disables a toolchain sanity check that fires because the surrounding environment looks arm64 to it. The second forces the C compiler that native-image shells out to — for linking — to actually target x86_64 rather than inheriting the host default.

Without both, you get either a hard failure or, worse, a binary that links against the wrong architecture's system libraries.

The part that cost the most time

These flags were in the build for months and looked like cargo-culted noise. Somebody had added them, nobody remembered why, and removing them seemed like cleanup.

They were removed. The build kept working.

The build kept working because CI ran on GitHub's Intel-hosted runners, where building the x86_64 slice is a native build and neither flag is needed. The flags were only ever load-bearing on an arm64 host.

Moving to a self-hosted arm64 runner broke it, and the failure looked like a GraalVM bug rather than a reverted workaround.

The lesson is not about GraalVM. It's that "it worked without this" is only evidence if the environment was the same — and CI architecture is exactly the kind of environment difference nobody records in a commit message. It's now written into the build script as a comment explaining why the flags must not be removed.

Where Rust comes in

The Java engine is one of fourteen sidecars in a Tauri application. Each is a standalone binary that speaks a small JSON protocol over stdin/stdout:

{ "op": "test", "pattern": "\\h+", "flags": "", "text": "abc 123 def" }

The Rust host spawns them as long-lived child processes rather than per-call, which is what makes this viable — process startup dominates otherwise. Steady-state round-trip is 1–3 ms, which is fast enough to run on every keystroke as you type a pattern.

Managing fourteen of those on macOS involves a few things worth knowing:

  • They must all be signed and notarized individually. A Developer ID build with an unsigned helper binary inside will pass locally and fail Gatekeeper on someone else's machine.
  • Under App Sandbox, spawning helpers needs the right entitlements, and some engines need filesystem exceptions that others don't.
  • Universal builds multiply everything. Fourteen sidecars × two architectures = twenty-eight binaries to produce, sign, and lipo before the app bundle even assembles.

The trade is size and build complexity against correctness. The whole app is ~72 MB, most of which is engines. In exchange, when the tool says a pattern matches, it's java.util.regex saying so, not a JavaScript approximation wearing a Java label.

If you're doing this

  • Check the Mach-O arch of your java binary, don't trust JAVA_HOME.
  • Budget for two GraalVM installs in CI, or accept building each slice on a matching runner.
  • If you find flags you don't understand in a working build, find out which environment made them necessary before deleting them.

This is from RegexPilot, a macOS regex editor built with Rust and Tauri that bundles fourteen native regex engines.


More from this series: