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=trueon the regular JVM before doing a full native compile. If your application starts with thespring.aot.enabledproperty 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
AtomicReferenceFieldUpdaterorUnsafe.objectFieldOffset. If you encounter aMissingReflectionRegistrationErrorpointing to a field after running the agent, you will need to supplement the generated JSON manually (see Strategy 3 below) with anallDeclaredFields: trueentry 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
| Situation | Best Strategy | Why |
|---|---|---|
| Own code, Jackson DTOs / POJOs | @RegisterReflectionForBinding | One annotation, zero boilerplate, refactor-safe |
| Own code, custom serialisers, resources, proxies | RuntimeHintsRegistrar | Full control, Java-native, IDE-navigable |
| Third-party library, no source access | Tracing agent → inspect JSON | Discovers metadata you could never write by hand |
| Agent gap (field-level on GraalVM 25+) | Manual JSON supplement | Targeted fix for what the agent misses |
| Library you maintain / OSS contribution | Manual JSON → GraalVM metadata repo | Benefits all users; auto-applied by GraalVM build tools |
| Complex multi-path, want full coverage | All three combined | Agent 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
jnisection 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
MissingReflectionRegistrationErroron 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=truein 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.







