Regardless of the tooling, several layers have to work together to make JavaFX run as a native image. All of the "magic" boils down to solving three problems:

  1. Reachability metadata: Native image uses a closed-world assumption, so every resource, class, and method that gets accessed reflectively at runtime needs to be known at build time (e.g. reachability-metadata.json). The metadata consists of two separate parts: the metadata specific to the user’s application, and the generic metadata for the underlying engine. The engine metadata is tied to the JavaFX version and operating system.

  2. The static binaries: Roughly half of JavaFX is C/C++/Objective-C code (windowing, rendering, fonts, image loading, effects). A self-contained executable needs these libraries compiled and linked in as static archives. jfx already supports building the archives via -PSTATIC_BUILD=true, but they currently don’t get published.

  3. Static integration glue: A few things work differently under AOT and need to be bridged with glue code. The static archives are baked into the image at build time rather than loaded at runtime, so the native code has to be registered, linked, and initialized differently, and each of these steps comes with its own pitfalls.

Next, we’ll go over how each approach solves each part:

Table 2. Build Solutions
Gluon SubstrateLiberica NIKStaticFX

Metadata

JSON files
(per-OS)

patches in
distribution

annotations→JSON
(conditional)

Static libraries

swaps in their version

bundled

user specified
${javafx.version}

Integration glue

code in the
build tool

patches in
distribution

GraalVM feature
on the class path

Application Metadata

The application part of the metadata is independent of the build solutions that are the focus of this blog post, but it’s by far the most common problem from a developer’s perspective, so I want to cover it before the engine parts.

On the application side, most of the reflection and resource loading comes from FXML and CSS. Desktop applications load many types of files at runtime (e.g. .css, .fxml, .png, .jpg, .ttf) that need to be included in the native image, and the metadata also needs to cover side-effects such as importing other resources or reflectively instantiating classes (e.g. @import, -fx-skin, fx:controller).

The corresponding metadata lives in a reachability-metadata.json file that is hard to maintain manually and keep in sync. The typically recommended way to create the metadata is GraalVM’s tracing agent, which attaches to a normal JVM run and traces all reflective accesses. That works reasonably well for a backend service with an automated test suite that hits every endpoint, but it is a major pain for GUI apps. Developers would have to click through every feature on every operating system, and redo it every time the app changes.

Missing items often result in rather unhelpful runtime errors, e.g., missing a file resource:

Caused by: javafx.fxml.LoadException: Location is not set.
        at javafx.fxml.FXMLLoader.loadImpl(...)

In practice, nearly every issue we encountered in production was due to agent data becoming stale. It got annoying enough that we created reachability-annotations, which let us define metadata via annotations that live next to the source of truth and are regenerated on every build:

// Whenever this class is included, the resources are automatically added
@Reachable(resources = { "images/*.png" })
public class ImageLoader {}

We also added some JavaFX-specific annotations that automatically parse FXML/CSS files and generate appropriate metadata for everything they reference:

@ReachableFxResources({
        "/assets/images/*.png",
        "views/**/*.fxml",
        "views/**/*.css",
})
public class MyApp extends Application {}

With a few annotations we can make all gluon-samples (see unmerged PR) run out of the box on all platforms without ever running an agent. Even our most complex apps do not use the agent anymore outside of explorative runs. By default, all entries are conditional, so unused views do not increase the image size.

Unfortunately, there are still some third-party libraries that are notoriously hard to get running, e.g., some reflection-based parsing libraries, and they may need to be replaced with something more compatible. Note that this is not specific to JavaFX, but it remains a pain point for native images in general.

Problem 1: Engine Metadata

Unlike the application metadata, the engine metadata is constant for a specific version and operating system. Users see a pure Java API, but internally there is a lot of native code for dealing with OS behavior and rendering pipelines, and a lot of reflection to pick appropriate implementations at runtime. Between the reflection, JNI, and resources (shaders, css files, fonts, etc.), it currently takes close to 1,000 metadata entries to cover the whole framework.

Additionally, a lot of the metadata only applies to specific operating systems. Many of the platform-specific classes (e.g. WinApplication.class) get filtered out of the platform jars, but a few classes (e.g. platform fonts) are always available. Adding those unnecessarily bloats the native-image and can create build issues.

The metadata cannot be generated with the tracing agent as it is impossible to cover all possible paths in a single application. The shaders and effects alone form a large family of generated classes, each run is limited to a single pipeline, and some accesses even depend on the class loading order. Therefore, this metadata needs to be curated in some way, and ideally use wildcards to cover all shader resources at once.

Substrate bundles JSON metadata with their downloaded fx jars (META-INF/substrate/config/reflectionconfig.json), and adds a custom mechanism that conditionally appends target-specific metadata before handing it to native-image (e.g. reflectionconfig-x86_64-linux.json and -javafxsw when enableSWRendering is set). Only Substrate reads these files, so the metadata does not work when using the stock native-maven-plugin.

Liberica NIK does not ship any JSON files, but they heavily extended the JavaFXFeature in the svm.jar and register rules directly from code. The metadata is tied to the bundled jfx version, and the conditions are generated using Platform.includedIn checks and triggers on specific classes.

StaticFX ships conventional JSON files with conditions that approximate OS filtering, e.g., typeReachable = ${OS}Application.class. This is compatible with the stock tooling, so it works out of the box with any current GraalVM release. The metadata itself is currently generated using @Reachable annotations in a jfx fork and updated once per release.

I considered trying to get the annotations upstream, but merging them would require a build-system change with a dependency on an external annotation processor, and it would create an expectation that OpenJFX officially supports native-image, which would be a big commitment.

Now that the initial metadata is figured out (~315 annotations in ~280 files generating ~1,000 entries), it only takes a few hours to cover a new release and run through the automated test suite. We designed it for ease of maintenance and documented the process in jfx-static-libs. We plan to release new builds in a timely manner, but it’s possible to create your own builds to avoid relying on third-party updates.

We are currently limited to generating the older 1.0.0 metadata format because the typeReached condition in 1.2.0 can’t express the HeadlessApplication conditions, i.e., it loads OS-dependent code, but never reaches any of the classes we could use as OS conditions. This means that we cannot upload the metadata to the community metadata repository.

Missing metadata fails rather annoyingly at runtime. The failures range from hard crashes (e.g. segfaults on missing classes) to silently producing bad rendering artifacts (e.g. missing the peer for the BoxShadow effect). To capture issues early, we set up a verification project that executes as many code paths as possible, including 2D and 3D scenes, effects, dialogs, popups, and rich text. Each scene exports screenshots that undergo automatic pixel checks and are exported for manual side-by-side verification. The executable can also be pointed at different pipelines (-Dprism.order=d3d/es2/mtl) to verify that their results all match. Beyond the synthetic tests, we also check the AtlantaFX sampler and our own internal applications like Scope.

Problem 2: The Static Libraries

The static binaries are primarily a distribution problem. Upstream OpenJFX has supported static builds for most modules (no media or web) via -PSTATIC_BUILD=true for years, but has never published the resulting archives. Users have to rely on third-party vendors or do the native build themselves. This gets complicated by the fact that the versions have to be an exact match, i.e., the static libraries have to be based on the same commit as the runtime jars.

Substrate downloads Gluon’s own static JavaFX SDK and silently replaces the user-specified JavaFX jars before invoking native-image. This ensures matching jars and archives, but it is impossible to use newer versions. Even if you specify jfx 27, the build tools will use jfx 21. Substrate defaults to only including the hardware pipelines, but it offers an opt-in to include the software fallback via the enableSWRendering flag.

Liberica NIK fixes the version by bundling the JavaFX modules with their jdk, which takes precedence over any org.openjfx jars declared in the build. They support the LTS releases, so the latest is currently JDK 25 + JavaFX 25. Note that their JavaFX build contains some minor source patches that are not published. Their feature omits the software pipeline, so applications fail on machines without a qualifying GPU.

StaticFX works with the official org.openjfx release jars and provides accompanying static builds that were built from the same commit. The builds currently live in my openjfx fork at jfx and contain no changes to the Java or native code beyond the annotations. Each release adds one commit for setting up the annotation processor, and a second that adds annotations for generating the metadata. The output format matches Substrate’s in case it ever accepts custom download urls. The static archives are actually reasonably small, so for the artifact on Maven Central we bundle all 5 platforms (including linux-aarch64) plus metadata into a single ~10 MB jar without classifier. The archives are only used by jfx-static-feature, so the artifact can be used as a standalone dependency purely to add reachability metadata.

Problem 3: Integration Glue

Lastly, we need the glue required for adding the actual linking arguments and fixing some pitfalls that show up during static linking.

Substrate owns the entire build chain, so all linker arguments live inside their build tool.

Liberica NIK works with GraalVM’s stock native-maven-plugin, but ships a heavily modified JavaFXFeature inside their svm.jar that registers the libraries and metadata for the bundled JavaFX version.

StaticFX was designed to work with the stock build tool and zero source changes, so any integration glue has to be implemented as a custom Feature. The feature gets picked up from the classpath automatically, extracts the platform-specific archives, checks that they match the runtime version, and adds the necessary linker commands for the static libraries and their system dependencies.

Static Linking

Statically linked JNI code can get into the image in two different ways. The simple route is forcing the entire archives into the executable, e.g., via /WHOLEARCHIVE on Windows, -force_load on macOS, or --whole-archive on Linux. The Java_* entry points are exported symbols, so they end up in the executable’s own export table, and the image resolves them at runtime with the same lookup a JVM uses for statically linked JNI (JEP 178). This disables build-time verification and includes all object files, independent of whether they get used or not.

The alternative is registering the archives as built-in JNI libraries, the same mechanism GraalVM uses for the JDK’s own libraries. Native-image then emits a link-time reference for every reachable native method, so the linker pulls in only the objects that are actually needed. The verification happens at build time, so a genuinely missing implementation fails early.

Historically, the glass library in OpenJFX reported lower JNI versions. Since GraalVM 25 strictly enforces JEP 178’s minimum JNI_VERSION_1_8 for static linking, the official sources are not compatible until this is updated upstream.

Substrate forces whole archives on Windows and macOS, but on Linux their internal GluonFeature does the built-in registration. The highest supported GraalVM version is 23, so the JNI version is not an issue.

Liberica NIK does the built-in registration for all platforms. Their bundled glass build is patched to report JNI_VERSION_1_8.

StaticFX also does the built-in registration for all platforms, and we rely on GraalVM substitutions to patch the call sites to ensure compatibility without changing the actual sources.

Linking Missing Symbols

As mentioned in the metadata section, a few platform-specific classes are present in every platform’s jar even though their native code is not. Out of the 95 graphics classes that declare native methods, this only applies to six classes related to font rendering, as well as the iOS image loader.

Unfortunately, even with perfectly provided metadata, users can easily cause issues by e.g. committing agent-generated files and building on another platform. Until those classes are added to the upstream exclude list, making e.g. the macOS CoreText font backend reachable on Windows breaks the build with 55 unresolved symbols even though none of them are needed. The missing symbol errors give the impression that the binaries were not compiled correctly, rather than highlighting a metadata issue.

helloworld-graal.obj : error LNK2001: unresolved external symbol
                       Java_com_sun_javafx_font_coretext_OS_CFArrayGetCount
[... 54 more unresolved CoreText symbols ...]
helloworld-graal.exe : fatal error LNK1120: 55 unresolved externals

One possible fix would be covering the missing symbols with small C stubs, similar to what Substrate does for some missing JDK symbols on its mobile targets. However, the font backends alone would come out to dozens of stubs per platform that would need to be bundled with the archives and maintained.

Substrate's whole-archive linking avoids the problem on Windows and macOS: wrong-OS classes are tolerated the same way a JVM tolerates them, and as long as they never get called, they only add some image bloat. An implementation that is genuinely missing only shows up on the first call as an UnsatisfiedLinkError:

java.lang.UnsatisfiedLinkError: Can't load library: javafx_font_pango
java.library.path = [...]

On Linux their GluonFeature registers a curated prefix list that leaves out the wrong-OS font backends, so their symbols are never referenced.

Liberica NIK uses their JavaFXFeature to register curated prefix lists for each platform, so the native methods are bound at link time. Wrong-OS classes that enter the image through bad metadata get treated as regular JNI, so they add some image bloat but do not break the build.

StaticFX registers a combined prefix list and uses GraalVM’s @Delete substitution to delete the wrong-OS classes. Leaving them unregistered would only downgrade them to regular JNI that stays in the image and fails at runtime. Deleted classes are removed from the analysis entirely, so bad metadata never reaches the linker, and a code path that does hit a deleted class fails with a more meaningful UnsupportedFeatureError instead of an UnsatisfiedLinkError:

@Delete
@TargetClass(onlyWith = {NotLinux.class, ClassPresent.class},
    className = "com.sun.javafx.font.freetype.FTFactory"
)
static final class Target_FTFactory {}

The conditions are negations (NotLinux, NotMacOS) combined with an existence check (ClassPresent), so a future platform port fails visibly instead of running a stubbed-out backend.

Media, WebView, and Swing

Both Substrate and Liberica NIK omit the media and web modules because their native dependencies (GStreamer and WebKit) are incredibly difficult to build statically. Since that is unlikely to ever change, dropping the modules entirely seemed unnecessarily restrictive to us.

On a standard JVM, these modules already load their binaries dynamically from the platform jars, and nothing prevents a native image from doing the same. The feature simply extracts the matching shared libraries (.dll, .so, or .dylib) from the classpath to the output directory and adds the appropriate linker arguments. Applications that require web or media can’t be built as self-contained executables, but that seems like a reasonable tradeoff.

Our javafx.swing metadata covers the pure Java interop glue. While full SwingNode utilization requires additional metadata for the JDK’s internal AWT systems, we have successfully verified it working across all five platforms using just two @Reachable annotations. However, that remains an example rather than something we actively support.

Remaining Glue

Beyond the deletions, StaticFX also contains a small number of substitutions that work around remaining gaps between GraalVM and a statically linked JavaFX. Several of them are latent issues that wouldn’t show up on a JVM, but should be fixed upstream. I only checked how Gluon and NIK handle some of them:

GraalVM

  • Overloaded native methods never link: native-image resolves builtin JNI methods by their short name, and an overloaded native only exists under its signature-mangled name on the C side, as the JNI spec requires (relevant to three classes). We added @CFunction substitutions to manually route them to the mangled symbols. BellSoft works around this with source patches that rename the overloaded natives (e.g., CreateFontFaceCreateFontFaceIndexed), and Substrate is unaffected due to lazy resolution on Windows and macOS (falls back to the mangled names at runtime), and none of the classes being relevant on Linux.

  • GraalVM’s built-in JavaFX support registers Application subclasses for reflection, but not the no-argument constructor called by the launcher. JavaFX Native Images currently fail at startup with NoSuchMethodException: MyApp.<init>() unless the application registers itself, so our feature registers the constructor of each reachable subclass.

OpenJFX

  • System.loadLibrary cannot initialize any of the static JavaFX libraries. Loading the unpatched glass as a built-in library fails with UnsatisfiedLinkError: Unsupported JNI version 0x10006, required by glass. On Linux and macOS, the JNI_OnLoad_<lib> entry points are additionally hidden by the image’s exported-symbol list, so the feature calls the initializers directly instead.

  • Objective-C categories are dropped from static links (standard linker behavior, as categories produce no symbols the linker tracks as dependencies), which shows up as an unrecognized selector exception when the first window opens. libglass.a is linked with -force_load to keep them.

  • On macOS, the process hangs before the first window appears. Cocoa requires the process’s first thread to be running a CFRunLoop while glass starts up. The java launcher handles this, but a native image runs main on the first thread. We can substitute a handoff to cover Application::launch, but Platform::startup currently needs an external native launcher.

  • JavaFX qualifies GPU support against a vendor allowlist that does not include Broadcom, so the es2 pipeline on a Raspberry Pi requires -Dprism.forceGPU=true to avoid falling back to software rendering.

  • Files shipped inside the image load using the resource: scheme, but the Media player on macOS only checks for jar: and jrt:, and hands the url to AVFoundation to get an error.