Skip to content

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
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):

./gradlew runWithNativeAgent

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:

{
    "type": "io.ktor.network.selector.InterestSuspensionsMap",
    "allDeclaredFields": true
}

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:

dependencies {
    implementation("io.github.kdroidfilter:nucleus.graalvm-runtime:<version>")
}

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 — Buffers Font.createFont(int, InputStream) to a temp file on Windows, working around streams that lack mark/reset support in native image.
  • Win32FontManagerSubstitution — Replaces Win32FontManager.getFontPath() with a pure-Java implementation, fixing InternalError: platform encoding not initialized.
  • FcFontManagerSubstitution — Fixes FcFontManager.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:

./gradlew packageGraalvmNative -PnativeMarch=compatibility

Output location

The raw native binary and its companion shared libraries are generated in:

<project>/build/compose/tmp/<project>/graalvm/output/
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:

<project>/build/compose/binaries/<buildType>/graalvm-<format>/

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