GraalVM Native Image¶
Experimental — for advanced developers only
GraalVM Native Image compilation for Compose Desktop is highly experimental. If reflection is not fully resolved at build time, the application will crash at runtime. This mode requires significant effort to configure and debug. Proceed only if you are comfortable with native-image internals.
Why Native Image?¶
For most Compose Desktop applications, AOT Cache (Leyden) is the recommended way to improve startup. It's simple to set up and provides a major boost. But there are cases where even Leyden isn't enough:
- Background services / system tray apps — a lightweight app that mostly sits idle in the background will consume 300–400 MB of RAM on a JVM, versus 100–150 MB as a native image. For an app that's always running, this matters.
- Instant-launch expectations — Leyden brings cold boot down to ~1.5 s, but a native image starts in ~0.5 s. For utilities, launchers, or CLI-like tools where every millisecond counts, native image is the way to go.
- Bundle size — no bundled JRE means a much smaller distributable.
GraalVM Native Image compiles your entire application ahead of time into a standalone native binary that feels truly native to the OS.
Trade-offs¶
Native image is not a free lunch. In addition to significantly more complex configuration (reflection, see below), there is a real CPU throughput penalty: the JVM's JIT compiler optimizes hot loops and polymorphic calls at runtime far better than AOT compilation can. For CPU-intensive workloads (heavy computation, real-time rendering, large data processing), a JVM with Leyden AOT cache will outperform a native image in sustained throughput.
| JVM + Leyden | Native Image | |
|---|---|---|
| Cold boot | ~1.5 s | ~0.5 s |
| RAM (idle) | 300–400 MB | 100–150 MB |
| CPU throughput | Excellent (JIT) | Lower (no JIT) |
| Bundle size | Larger (includes JRE) | Smaller |
| Configuration | Simple (enableAotCache = true) |
Complex (reflection metadata) |
| Stability | Stable | Experimental |
Choose native image when startup speed and memory footprint are critical and CPU throughput is secondary. Choose Leyden when you want the best balance of performance, simplicity, and stability.
Requirements¶
BellSoft Liberica NIK 25 (Full)¶
GraalVM Native Image compilation requires BellSoft Liberica NIK 25 (full distribution, not lite). This is the only supported distribution — standard GraalVM CE does not include the AWT/Swing support needed for desktop GUI applications.
Will not work with other distributions
Using Oracle GraalVM, GraalVM CE, or Liberica NIK Lite will fail. Desktop GUI applications require the full Liberica NIK distribution which includes AWT and Swing native-image support.
Platform toolchains¶
| Platform | Required |
|---|---|
| macOS | Xcode Command Line Tools (Xcode 26 for macOS 26 appearance) |
| Windows | MSVC (Visual Studio Build Tools) — ilammy/msvc-dev-cmd in CI |
| Linux | GCC, patchelf, xvfb (for headless compilation) |
Build Configuration¶
Gradle DSL¶
nucleus.application {
mainClass = "com.example.MainKt"
graalvm {
isEnabled = true
imageName = "my-app"
// Gradle Java Toolchain: auto-downloads Liberica NIK 25
// if it's not already installed on the machine.
// In CI, the JDK is set up by graalvm/setup-graalvm@v1 instead.
javaLanguageVersion = 25
jvmVendor = JvmVendorSpec.BELLSOFT
buildArgs.addAll(
"-H:+AddAllCharsets",
"-Djava.awt.headless=false",
"-Os",
"-H:-IncludeMethodData",
)
nativeImageConfigBaseDir.set(
layout.projectDirectory.dir(
when {
org.gradle.internal.os.OperatingSystem.current().isMacOsX ->
"src/main/resources-macos/META-INF/native-image"
org.gradle.internal.os.OperatingSystem.current().isWindows ->
"src/main/resources-windows/META-INF/native-image"
org.gradle.internal.os.OperatingSystem.current().isLinux ->
"src/main/resources-linux/META-INF/native-image"
else -> throw GradleException("Unsupported OS")
},
),
)
}
}
DSL Reference¶
| Property | Type | Default | Description |
|---|---|---|---|
isEnabled |
Boolean |
false |
Enable GraalVM native compilation |
javaLanguageVersion |
Int |
25 |
Gradle toolchain language version — triggers auto-download of the matching JDK if not installed locally |
jvmVendor |
JvmVendorSpec |
— | Gradle toolchain vendor filter — set to BELLSOFT to auto-provision Liberica NIK |
imageName |
String |
project name | Output executable name |
march |
String |
"native" |
CPU architecture target (native for current CPU, compatibility for broad compatibility) |
buildArgs |
ListProperty<String> |
empty | Extra arguments passed to native-image |
nativeImageConfigBaseDir |
DirectoryProperty |
— | Directory containing reachability-metadata.json |
Recommended build arguments¶
| Argument | Purpose |
|---|---|
-H:+AddAllCharsets |
Include all character sets (required for text I/O) |
-Djava.awt.headless=false |
Enable GUI support (mandatory for desktop apps) |
-Os |
Optimize for binary size |
-H:-IncludeMethodData |
Reduce binary size by excluding method metadata |
Reflection Configuration¶
This is the hardest and most critical part of native-image compilation. GraalVM performs a closed-world analysis — any class accessed via reflection must be declared explicitly, otherwise the application crashes at runtime.
Step 1: Copy the pre-configured metadata¶
The example app ships with pre-configured reachability-metadata.json files for all three platforms. These cover Compose Desktop, decorated-window-jni, and standard AWT/Swing classes — representing hundreds of entries that would be tedious to build from scratch. Always start from these files:
# From the Nucleus repository
cp -r example/src/main/resources-macos your-app/src/main/
cp -r example/src/main/resources-linux your-app/src/main/
cp -r example/src/main/resources-windows your-app/src/main/
This gives you a working baseline for a basic Compose Desktop application. The next steps add entries specific to your own dependencies.
Step 2: Run the tracing agent¶
Nucleus provides a Gradle task that runs your application with the GraalVM tracing agent. The agent records all reflection, JNI, resource, and proxy accesses and merges the results into your existing configuration (your manual entries are never overwritten):
The goal is not to run the app for a fixed duration — it is to trigger every code path that loads resources or uses reflection. This is critical because Compose loads resources (images, fonts, strings) via reflection at runtime. If a resource isn't loaded during the tracing run, it won't be included in the native binary and the app will crash.
During the tracing run, make sure to:
- Navigate to every screen of your application
- Toggle dark/light theme — if your icons differ between themes, both variants must be loaded
- Open every dialog, menu, tooltip, and dropdown
- Trigger all lazy-loaded content (expand lists, scroll to bottom, etc.)
- Exercise all features that load resources dynamically
Compose resources are loaded by reflection
Compose's painterResource(), imageResource(), and similar functions resolve resources reflectively. If an icon is only shown on a specific screen or only in dark mode, you must visit that screen and switch to that mode during the agent run — otherwise the resource will be missing in the native binary.
Prefer Kotlin-generated icons over resource files
To avoid resource-loading headaches entirely, convert your icons to Kotlin ImageVector definitions (using tools like Composables or the Material Icons library). Kotlin-generated icons are compiled directly into the binary and require no reflection or resource resolution. This is strongly recommended for native-image builds.
The agent output is automatically merged into your nativeImageConfigBaseDir.
Step 3: Review and fix the configuration¶
The agent captures most reflection calls, but it cannot capture code paths that weren't exercised during the tracing run. You may need to manually add or adjust entries in reachability-metadata.json.
For example, the ktor networking library requires manually adding:
The agent might have generated an entry for this class, but without allDeclaredFields: true — causing a runtime crash when ktor tries to access fields reflectively.
Debugging missing reflection
Run your native binary from the terminal. Reflection failures produce clear error messages like Class not found or No such field. Add the missing entries and recompile.
Step 4: Repeat on each platform¶
The reflection configuration is platform-specific. Each platform uses different AWT implementations, security providers, and system classes. You need three separate configurations:
src/main/resources-macos/META-INF/native-image/reachability-metadata.json
src/main/resources-linux/META-INF/native-image/reachability-metadata.json
src/main/resources-windows/META-INF/native-image/reachability-metadata.json
Run steps 2 and 3 on each platform separately. The pre-configured files from step 1 already contain platform-specific entries, but your own dependencies may require additional entries that differ per OS.
Application Bootstrap¶
graalvm-runtime module¶
The graalvm-runtime module provides everything needed to bootstrap a Compose Desktop application in a GraalVM native image. Add it to your dependencies:
Then call GraalVmInitializer.initialize() as the first line of your main() function, before any AWT or Compose usage:
import io.github.kdroidfilter.nucleus.graalvm.GraalVmInitializer
fun main() {
GraalVmInitializer.initialize()
application {
Window(onCloseRequest = ::exitApplication, title = "MyApp") {
App()
}
}
}
The initializer handles all of the following automatically:
| Concern | What it does |
|---|---|
| Metal L&F | Sets swing.defaultlaf to avoid unsupported platform modules |
java.home |
Points to the executable directory so Skiko finds jawt |
java.library.path |
Sets execDir + execDir/bin so fontmanager/freetype/awt are discoverable |
| Charset init | Forces early Charset.defaultCharset() to prevent InternalError: platform encoding not initialized |
| Fontmanager preload | Calls System.loadLibrary("fontmanager") early to avoid crashes in Font.createFont() |
| Linux HiDPI | Detects and applies the native scale factor via linux-hidpi (works in both JVM and native image) |
The native-image-specific steps only run when org.graalvm.nativeimage.imagecode is set. The Linux HiDPI detection runs unconditionally (it's a no-op on non-Linux platforms).
You can also check GraalVmInitializer.isNativeImage at any point to branch on native-image vs JVM execution.
Font substitutions¶
The module also ships GraalVM @TargetClass substitutions (Java source files) that fix font-related crashes in native image on Windows and Linux:
FontCreateFontSubstitution— BuffersFont.createFont(int, InputStream)to a temp file on Windows, working around streams that lack mark/reset support in native image.Win32FontManagerSubstitution— ReplacesWin32FontManager.getFontPath()with a pure-Java implementation, fixingInternalError: platform encoding not initialized.FcFontManagerSubstitution— FixesFcFontManager.getFontPath()on Linux native image.
These substitutions are automatically picked up by the native-image compiler — no configuration needed.
Decorated Window¶
The decorated-window-jni module was specifically designed to work with GraalVM Native Image (no JBR dependency). Use it instead of decorated-window-jbr for native-image builds.
Gradle Tasks¶
| Task | Description |
|---|---|
runWithNativeAgent |
Run the app with the GraalVM tracing agent to collect reflection metadata |
packageGraalvmNative |
Compile and package the application as a native binary |
packageGraalvmDeb |
Package the native image as a .deb installer (Linux) |
packageGraalvmDmg |
Package the native image as a .dmg installer (macOS) |
packageGraalvmNsis |
Package the native image as an NSIS .exe installer (Windows) |
# Collect reflection metadata (run on each platform)
./gradlew runWithNativeAgent
# Build the raw native image
./gradlew packageGraalvmNative
# Build platform-specific installers (requires Node.js for electron-builder)
./gradlew packageGraalvmDeb # Linux
./gradlew packageGraalvmDmg # macOS
./gradlew packageGraalvmNsis # Windows
# NOTE: The `homepage` property is required in nativeDistributions for DEB packaging.
# electron-builder will fail without it. See Configuration > Package Metadata.
Use -PnativeMarch=compatibility for binaries that should run on older CPUs:
Output location¶
The raw native binary and its companion shared libraries are generated in:
| Platform | Output |
|---|---|
| macOS | output/MyApp.app/ (full .app bundle with Info.plist, icons, signed dylibs) |
| Windows | output/my-app.exe + companion DLLs (awt.dll, fontmanager.dll, etc.) |
| Linux | output/my-app + companion .so files (libawt.so, libfontmanager.so, etc.) |
The packageGraalvm<Format> tasks produce installers in:
CI/CD¶
Native image compilation must happen on each target platform. Use setup-nucleus with graalvm: 'true':
name: Build GraalVM Native Image
on:
push:
tags: ["v*"]
jobs:
build-natives:
uses: ./.github/workflows/build-natives.yaml
graalvm:
needs: build-natives
name: GraalVM - ${{ matrix.name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- name: Linux x64
os: ubuntu-latest
- name: macOS ARM64
os: macos-latest
- name: Windows x64
os: windows-latest
steps:
- uses: actions/checkout@v4
# Download pre-built JNI native libraries here...
- name: Setup Nucleus (GraalVM)
uses: kdroidFilter/Nucleus/.github/actions/setup-nucleus@main
with:
graalvm: 'true'
setup-gradle: 'true'
setup-node: 'true' # Required for packageGraalvm<Format> tasks
- name: Build GraalVM native packages
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
./gradlew :myapp:packageGraalvmDeb \
-PnativeMarch=compatibility --no-daemon
elif [ "$RUNNER_OS" = "macOS" ]; then
./gradlew :myapp:packageGraalvmDmg \
-PnativeMarch=compatibility --no-daemon
elif [ "$RUNNER_OS" = "Windows" ]; then
./gradlew :myapp:packageGraalvmNsis \
-PnativeMarch=compatibility --no-daemon
fi
- uses: actions/upload-artifact@v4
with:
name: graalvm-${{ runner.os }}
path: myapp/build/compose/binaries/**/graalvm-*/**
See CI/CD for the full release workflow with publishing to GitHub Releases.
Best Practices¶
Avoid reflection-heavy libraries¶
Every library that uses reflection needs manual configuration. Prefer libraries that work without reflection (compile-time code generation, direct method calls). If you must use reflection-heavy libraries (ktor, serialization), expect to spend significant time on configuration.
Test on all platforms early¶
Don't wait until the end to test native-image on all three platforms. Each platform has its own set of reflection requirements and quirks. Test early and often.
Use the Jewel sample as reference¶
The jewel-sample in the Nucleus repository demonstrates a more complex native-image setup with the Jewel UI library, including custom font resolution patches for Windows and extensive reflection configuration. It is an excellent reference for advanced use cases.
Future: Automatic Reflection Resolution Plugin¶
Looking for sponsors
The biggest pain point of GraalVM Native Image is reflection configuration. Today, you must run the tracing agent, manually review the output, and repeat for every dependency and every platform. This is tedious, error-prone, and the primary reason native-image remains "experimental" for most developers.
It is technically feasible to build a Gradle plugin that would automatically resolve nearly all reflection requirements for a Compose Desktop application. Such a plugin would:
- Analyze the classpath at build time and generate reflection/JNI/resource metadata automatically
- Cover Compose, AWT/Swing, Skiko, and common libraries (ktor, kotlinx.serialization, coil, etc.)
- Report libraries for which it could not resolve reflection, so the developer knows exactly what needs manual attention
- Dramatically lower the barrier to entry for native-image builds
This would be a game changer for the Compose Desktop ecosystem — it would make GraalVM Native Image practical not just for small utilities but for large, real-world applications that need the JVM ecosystem's libraries while benefiting from native startup, low memory, and small bundle size.
However, building this plugin represents a massive engineering effort (deep analysis of bytecode, annotation processing, library-specific heuristics, cross-platform testing). This is far beyond what can be done in spare time.
If you or your company would like to sponsor this work, please reach out! I would genuinely love to work on this. Contact me via GitHub Issues or GitHub Discussions.
Further Reading¶
- GraalVM Native Image documentation
- BellSoft Liberica NIK
- Nucleus example app — minimal Compose Desktop + native-image setup
- Nucleus Jewel sample — advanced setup with reflection-heavy dependencies