Enterprise Java

GraalVM Native Image: Why Reflection Still Breaks in 2026 and How to Fix It

GraalVM Native Image has solved the startup time problem. A Spring Boot application that previously took 3–5 seconds to start now does it in under 100 milliseconds, with 60–80% less memory. That part works. What still breaks — consistently, silently during the build, and loudly at runtime — is reflection metadata. This article explains exactly why, what the errors look like, and how to fix them using the three strategies available in 2026: the Spring RuntimeHints API, the GraalVM tracing agent, and manual JSON configuration.

The reflection problem is not a bug and it is not going away. It is a fundamental consequence of how native images work. Understanding the root cause is the first step toward fixing it confidently — rather than cargo-culting hints until the error goes away.

The Root Cause: Closed-World Compilation

When the JVM runs your application, it has access to every class file on the classpath and can load, introspect, and invoke anything at runtime. The dynamic language features of the JVM — reflection and resource handling — compute the dynamically-accessed program elements such as fields, methods, or resource URLs at run time. On HotSpot this is possible because all class files and resources are available at run time and can be loaded by the runtime.

GraalVM Native Image works on the opposite assumption — the closed-world assumption. To make native binaries small, the native-image builder performs static analysis at build time to determine only the necessary program elements that are needed for the correctness of the application. Small binaries allow fast application startup and low memory footprint, however they come at a cost: determining dynamically-accessed application elements via static analysis is infeasible as reachability of those elements depends on data that is available only at run time.

In plain terms: if GraalVM cannot see at build time that a class will be loaded or a method will be called via reflection, that class and method are simply not included in the binary. When your application tries to access them at runtime, the result is not a ClassNotFoundException or a NoSuchMethodException — it is a harder error, designed to be unforgeable.

The Exact Errors You Will See

GraalVM is deliberate about its error types. Understanding what each one means tells you immediately where to look and which fix to apply. Contrary to what many tutorials imply, there is no single “reflection error” — there are three distinct error types, each covering a different kind of missing metadata.

Error 1 — MissingReflectionRegistrationError

This is the most common. Invocation of methods without the provided metadata will result in throwing MissingReflectionRegistrationError which extends java.lang.Error and should not be handled. It extends Error, not Exception — meaning you cannot swallow it in a catch block and move on.

org.graalvm.nativeimage.MissingReflectionRegistrationError:
The program tried to reflectively access
com.example.order.OrderEventPayload.getConstructors()
without it being registered for runtime reflection.

Add com.example.order.OrderEventPayload.getConstructors()
to the reflection metadata to solve this problem.
See https://www.graalvm.org/latest/reference-manual/native-image/metadata/#reflection for help.

    at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:217)
    at com.example.order.OrderService.deserialize(OrderService.java:89)
    at com.example.order.OrderController.handleEvent(OrderController.java:54)

The error message itself is actionable: it names the exact class and the exact operation (getConstructors()) that lacks registration. GraalVM deliberately formats this message this way so you know precisely what to add to your metadata.

Error 2 — MissingRegistrationError (type lookup)

When Class.forName()Class.getDeclaredConstructor(), or other bulk-query methods are called on a type that has no metadata entry at all, GraalVM throws MissingRegistrationError. Individual queries are methods like Class.getField(String) which return a single element. Those queries will succeed — or throw the expected ReflectiveOperationException — if either the element was individually registered for reflection, or the corresponding bulk query was registered. If that is not the case, a MissingReflectionRegistrationError will be thrown.

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
No serializer found for class com.example.payment.WebhookPayload
and no properties discovered to create BeanSerializer

(to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)
    at com.fasterxml.jackson.databind.ser.BeanSerializerFactory...
    at com.example.payment.PaymentController.handleWebhook(PaymentController.java:

The Jackson variant of this error looks like a serialisation failure but is actually a reflection metadata failure in disguise. Jackson’s ObjectMapper uses reflection to discover fields and methods at runtime; without registration, those members are invisible to it inside the native binary.

Error 3 — Silent build success, runtime crash

The most deceptive scenario: the native build completes without errors, tests pass on JVM, and the binary crashes on first use of a code path that exercises unregistered types. This happens because Spring’s AOT engine correctly registers everything it can statically analyse — but third-party libraries may need manual hints. The build succeeds because GraalVM does not know those code paths will be reached; it simply excludes the unregistered members. The crash comes at runtime when those paths are actually hit.

Build-time validation tip: Run your application with -Dspring.aot.enabled=true on the regular JVM before doing a full native compile. If your application starts with the spring.aot.enabled property set to true, then you have higher confidence that it will work when converted to a native image. This catches most AOT-processing errors in seconds rather than after a 10–20 minute native compile.

Sources of Native Image Reflection Failures in Spring Boot Apps (2026)

Strategy 1 — The Spring RuntimeHints API Preferred

1 RuntimeHints API & Convenience Annotations

The Spring RuntimeHints API is the right first choice for any reflection requirement that lives inside your own codebase. The RuntimeHints API collects the need for reflection, resource loading, serialization, and JDK proxies at runtime. It is the Java-native, refactor-safe way to register hints: you reference actual class literals, which means a rename in the IDE updates the hint automatically.

The simplest form — for DTOs and POJOs that Jackson needs to serialise or deserialise — is @RegisterReflectionForBinding directly on the application class or a @Configuration class:

@RegisterReflectionForBinding({
    OrderEventPayload.class,
    WebhookPayload.class,
    PaymentConfirmation.class
})
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

For anything more nuanced — custom serialisers, resources, JNI, serialisation, or dynamic proxy registration — implement RuntimeHintsRegistrar and annotate your configuration with @ImportRuntimeHints:

@ImportRuntimeHints(OrderServiceHints.class)
@Configuration(proxyBeanMethods = false)
public class NativeImageConfig {

    static class OrderServiceHints implements RuntimeHintsRegistrar {

        @Override
        public void registerHints(RuntimeHints hints, ClassLoader classLoader) {

            // 1. Reflection — custom serialiser not visible to Spring's bean scan
            hints.reflection()
                .registerType(OrderEventPayload.class,
                    MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                    MemberCategory.INVOKE_DECLARED_METHODS,
                    MemberCategory.DECLARED_FIELDS)
                .registerType(MoneyAmount.class,
                    MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS,
                    MemberCategory.INVOKE_PUBLIC_METHODS);

            // 2. Resources — classpath files that GraalVM would otherwise exclude
            hints.resources()
                .registerPattern("validation-rules/*.json")
                .registerPattern("db/migration/*.sql");

            // 3. Serialisation — for types that travel over ObjectOutputStream
            hints.serialization()
                .registerType(OrderEventPayload.class);

            // 4. JDK proxies — for custom interface-based proxies (not Spring AOP)
            hints.proxies()
                .registerJdkProxy(OrderRepository.class);
        }
    }
}

Spring Boot 4 improvement: Spring Boot 4 introduces a redesigned AOT pipeline that addresses the limitations of earlier versions. The new pipeline handles more patterns automatically, including conditional beans, profile-specific configurations, and complex injection scenarios that previously required manual hints.

 In practice, this means fewer RuntimeHintsRegistrar entries are needed for first-party Spring features compared to Spring Boot 3.x — but third-party library gaps remain your responsibility.

Spring Framework also ships @Reflective as a meta-annotation for marking methods or types that will be invoked reflectively by the framework. For instance, @EventListener is meta-annotated with @Reflective since the underlying implementation invokes the annotated method using reflection. You can apply the same pattern to your own framework-like infrastructure code.

Strategy 2 — The GraalVM Tracing Agent Third-Party Libraries

2 native-image-agent: Observe, Record, Generate

When the reflection requirement comes from a third-party library you cannot modify — a custom serialisation library, a legacy ORM adapter, an SDK with internal reflective wiring — the tracing agent is the practical solution. The GraalVM native image tracing agent allows you to intercept reflection, resources or proxy usage on the JVM in order to generate the related hints.

The agent attaches to a regular JVM run, observes every reflective operation that actually executes, and writes the results to JSON files that GraalVM can consume directly. The key insight is that the agent only records what actually runs — so the more code paths you exercise during the agent run, the more complete your metadata will be.

Step 1 — Run the application with the tracing agent attached

java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
     -jar target/order-service-0.0.1-SNAPSHOT.jar

After starting, exercise all the code paths that involve the problematic library — call the endpoints, trigger the serialisation logic, run any custom reflection. Then stop the application. The agent writes a reachability-metadata.json file to the output directory.

Step 2 — Alternatively, run tests with the agent (recommended for CI)

# Maven: run tests with agent, merge results
./mvnw -Pagent test

# The Spring Boot parent POM binds this to the native profile automatically.
# Results land in target/native/agent-output/

Step 3 — Inspect the generated reachability-metadata.json

{
  "reflection": [
    {
      "condition": {
        "typeReached": "com.example.order.OrderService"
      },
      "type": "com.example.order.OrderEventPayload",
      "allDeclaredConstructors": true,
      "allDeclaredMethods": true,
      "allDeclaredFields": true
    },
    {
      "type": "com.fasterxml.jackson.databind.ser.BeanSerializer",
      "queryAllDeclaredMethods": true
    }
  ],
  "resources": [
    {
      "glob": "validation-rules/*.json"
    }
  ]
}

Place the generated file at src/main/resources/META-INF/native-image/<groupId>/<artifactId>/reachability-metadata.json. GraalVM picks it up automatically during the next native compile.

Agent limitation in 2026: There is a known regression in GraalVM 25 where the tracing agent misses field-level reachability metadata for fields accessed via AtomicReferenceFieldUpdater or Unsafe.objectFieldOffset. If you encounter a MissingReflectionRegistrationError pointing to a field after running the agent, you will need to supplement the generated JSON manually (see Strategy 3 below) with an allDeclaredFields: true entry for the affected type.

Strategy 3 — Manual reachability-metadata.json Precise Surgical Fix

3 Manual JSON: Conditioned, Minimal, Portable

Manual JSON is the lowest-level strategy — but it is also the most portable, since it requires no Spring, no agent, and no specific framework. It is the right choice when fixing agent gaps, contributing metadata for a library you maintain, or registering types conditionally (so metadata is only applied when a specific class is reached, keeping the binary lean).

Each entry in JSON-based metadata should be conditional to avoid unnecessary growth of the native binary size. A conditional entry is specified by adding a condition field with a typeReached property — a metadata entry with a typeReached condition is considered available at run time only when the specified type is reached.

src/main/resources/META-INF/native-image/com.example/order-service/reachability-metadata.json


  "reflection": [
    {
      "condition": {
        "typeReached": "com.example.order.OrderService"
      },
      "type": "com.example.order.OrderEventPayload",
      "allDeclaredConstructors": true,
      "allDeclaredMethods": true,
      "allDeclaredFields": true
    },
    {
      "condition": {
        "typeReached": "com.example.payment.PaymentController"
      },
      "type": "com.example.payment.WebhookPayload",
      "methods": [
        { "name": "", "parameterTypes": [] },
        { "name": "getAmount",  "parameterTypes": [] },
        { "name": "getCurrency","parameterTypes": [] }
      ],
      "fields": [
        { "name": "amount"   },
        { "name": "currency" },
        { "name": "eventId"  }
      ]
    }
  ],
  "resources": [
    { "glob": "validation-rules/*.json"  },
    { "glob": "db/migration/V*.sql" }
  ],
  "serialization": [
    {
      "condition": {
        "typeReached": "com.example.order.OrderEventPayload"
      },
      "type": "com.example.order.OrderEventPayload"
    }
  ]
}

The condition.typeReached guard is important. Without it, every reflection entry in the file is unconditionally included in every build, bloating the binary with metadata for classes that may not be reachable in certain configurations. With conditions, the metadata is applied only when the guarding class is actually reached during the static analysis pass.

Contributing back: If the library that requires manual metadata is open-source, consider contributing the JSON to the GraalVM Reachability Metadata repository on GitHub. GraalVM automatically downloads and applies metadata from this community repository during builds, which means your fix benefits every user of that library without requiring them to write the JSON themselves.

The Full Spring Boot 4 Example: Putting All Three Strategies Together

The following is a production-realistic annotated snippet combining all three strategies for a typical order-service scenario. Spring AOT handles the @RestController and @Service beans automatically. The three remaining gaps — a custom Jackson deserialiser, a third-party audit library, and a resource file — are each handled by the most appropriate strategy.

OrderServiceApplication.java — Spring Boot 4 native image configuration

@SpringBootApplication
// Strategy 1a: quick annotation for simple Jackson DTOs
@RegisterReflectionForBinding({
    OrderEventPayload.class,
    PaymentConfirmation.class
})
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

// Strategy 1b: RuntimeHintsRegistrar for richer / conditional registration
@ImportRuntimeHints(OrderServiceApplication.OrderNativeHints.class)
@Configuration(proxyBeanMethods = false)
class NativeConfig {

    static class OrderNativeHints implements RuntimeHintsRegistrar {

        @Override
        public void registerHints(RuntimeHints hints, ClassLoader cl) {

            // Custom deserialiser — not a Spring bean, invisible to AOT bean scan
            hints.reflection()
                .registerType(MoneyAmountDeserializer.class,
                    MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
                    MemberCategory.INVOKE_DECLARED_METHODS);

            // Classpath resource read at runtime by OrderRulesEngine
            hints.resources()
                .registerPattern("validation-rules/*.json");

            // Serialisation for async queue messages (ObjectOutputStream path)
            hints.serialization()
                .registerType(OrderEventPayload.class);
        }
    }
}

pom.xml — native profile with agent support (Spring Boot 4 / GraalVM)

<parent>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-parent</artifactId>
  <version>4.0.3</version>
</parent>

<build>
  <plugins>
    <plugin>
      <groupId>org.graalvm.buildtools</groupId>
      <artifactId>native-maven-plugin</artifactId>
    </plugin>
  </plugins>
</build>

<profiles>
  <profile>
    <id>native</id>
    <build>
      <plugins>
        <plugin>
          <groupId>org.graalvm.buildtools</groupId>
          <artifactId>native-maven-plugin</artifactId>
          <extensions>true</extensions>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

Build commands — standard workflow

# Validate AOT processing without a full native compile (fast feedback)
./mvnw spring-boot:process-aot
./mvnw -Dspring.aot.enabled=true spring-boot:run

# Run tests with tracing agent (Strategy 2 — generates metadata JSON)
./mvnw -Pagent test

# Compile to native binary (10–20 min, requires GraalVM on PATH)
./mvnw -Pnative native:compile

# Or build a Docker image without local GraalVM (uses Buildpacks)
./mvnw spring-boot:build-image -Pnative

Choosing the Right Strategy

SituationBest StrategyWhy
Own code, Jackson DTOs / POJOs@RegisterReflectionForBindingOne annotation, zero boilerplate, refactor-safe
Own code, custom serialisers, resources, proxiesRuntimeHintsRegistrarFull control, Java-native, IDE-navigable
Third-party library, no source accessTracing agent → inspect JSONDiscovers metadata you could never write by hand
Agent gap (field-level on GraalVM 25+)Manual JSON supplementTargeted fix for what the agent misses
Library you maintain / OSS contributionManual JSON → GraalVM metadata repoBenefits all users; auto-applied by GraalVM build tools
Complex multi-path, want full coverageAll three combinedAgent provides the base; Hints API and JSON fill the gaps

Spring Boot 4 — JVM vs Native Image: Startup & Memory (2-vCPU cloud instance)

What Spring Boot 4 Still Cannot Do Automatically

It is worth being explicit about where Spring Boot 4’s AOT engine reaches its limits, because this is precisely where manual intervention remains required in 2026.

  • Third-party libraries that use reflection internally without shipping GraalVM metadata (Jackson custom modules, legacy ORM adapters, non-Spring SDKs)
  • Classes loaded via Class.forName() with a runtime-computed string argument — the AOT engine cannot resolve these statically
  • Serialisation of types that travel over ObjectOutputStream / Kafka serialisers with custom implementations
  • JNI calls from native code — these require separate jni section entries in the metadata JSON
  • Resources read from the classpath at runtime using string patterns that are not literals (e.g., built from environment variables at startup)
  • Dynamic proxy creation for interfaces that are not Spring-managed beans

The most common production mistake: Building native, running smoke tests, deploying — and only discovering a MissingReflectionRegistrationError on a code path that the smoke tests did not exercise (an error path, a webhook handler, a monthly report generator). Native image testing should include integration tests that cover all non-trivial code paths, run with -Dspring.aot.enabled=true in CI before the native compile step.

What We Learned

GraalVM Native Image’s reflection problem is structural: the closed-world assumption that enables fast startup means any reflective access not declared at build time produces a MissingReflectionRegistrationError at runtime — an Error, not an exception, that cannot be caught and recovered from. We looked at the three distinct error shapes (reflection invocation, type lookup, and silent-build-visible-crash), then worked through the three fix strategies in priority order. The Spring RuntimeHints API is the right first choice for your own code — @RegisterReflectionForBinding for simple DTOs, RuntimeHintsRegistrar for anything more complex — because it is refactor-safe and processed before the native compile.

The tracing agent handles third-party library gaps by recording every reflective operation during a live run and writing the result as JSON. Manual JSON fills the remaining gaps, particularly the field-level metadata that the GraalVM 25 agent sometimes misses, and it is the mechanism for contributing metadata upstream to the community repository. Spring Boot 4’s redesigned AOT pipeline reduces the number of manual hints needed for first-party patterns, but the boundary between what the framework handles automatically and what you must register manually remains important to understand — and that boundary is exactly where production native image failures cluster in 2026.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button