Java Serverless on AWS Lambda · Frameworks, Native Images and SDK Clients

GraalVM Native Images with Quarkus or Micronaut

When compiling Java ahead of time is worth it on Lambda, and the reflection pitfalls that break native builds.

Scenario

Example app: a latency-sensitive authorisation function must answer in under 200 ms including cold starts. The team rebuilds it with Quarkus as a GraalVM native image running on the provided.al2023 custom runtime.

What you see in production

Cold starts drop to around 300 ms. But the first deployment fails at runtime with ClassNotFoundException and JSON fields missing from responses, although all JVM-mode tests passed. CI build time grows from 2 to 9 minutes.

The naive code

// Works on the JVM, fails in a native image: the class is only reached through reflection.
Object rule = Class.forName(config.ruleClassName()).getDeclaredConstructor().newInstance();
String json = objectMapper.writeValueAsString(new Decision(true, "ok"));   // record not registered for reflection

Reproduce it

A native image includes only code it can prove is reachable at build time; reflective access must be declared:

Closed-world analysis in miniature
import java.util.*;

public class Main {
    public static void main(String[] args) {
        Set<String> reachableStatically = Set.of("Handler", "Router", "TokenVerifier");
        Set<String> registeredForReflection = new HashSet<>();
        List<String> usedReflectively = List.of("rules.GeoRule", "model.Decision");

        for (String pass : List.of("before registration", "after registration")) {
            if (pass.startsWith("after")) registeredForReflection.addAll(usedReflectively);
            List<String> missing = usedReflectively.stream()
                .filter(c -> !reachableStatically.contains(c) && !registeredForReflection.contains(c)).toList();
            System.out.println(pass + ": " + (missing.isEmpty() ? "all classes present" : "missing at runtime " + missing));
        }
    }
}

Output

before registration: missing at runtime [rules.GeoRule, model.Decision]
after registration: all classes present

Run this on your computer — see “Get Started” for setup.

Root cause

GraalVM Native Image compiles the application ahead of time under a closed-world assumption: only classes reachable from static analysis are included. Reflection, dynamic proxies, resources and JNI must be declared in configuration (or registered by framework extensions). Libraries that rely heavily on runtime reflection need explicit hints, and JVM-mode tests do not exercise the native binary.

The fix

  • Use a framework with native support (Quarkus, Micronaut, or Spring Boot AOT): their extensions generate most reflection configuration, and annotations like @RegisterForReflection cover the rest.
  • Run the native image tests in CI (Quarkus @QuarkusIntegrationTest, Micronaut test with native) and use the GraalVM tracing agent to capture reflection usage from test runs.
  • Build native images in a Linux container matching the Lambda architecture; deploy to provided.al2023 with a bootstrap executable (frameworks generate the deployment ZIP).
  • Reserve native images for functions where start-up latency matters and the dependency set is native-friendly.

Trade-offs

  • Much faster start-up and lower memory, but longer builds, less mature tooling for some libraries, and peak throughput that can be lower than a warmed-up JIT.
  • Debugging and profiling differ from the JVM.
  • SnapStart often gets most of the benefit with fewer changes; compare both on your workload.

Detect it in production

  • Native-only failures: ClassNotFoundException, missing JSON fields, missing resources; catch them with native integration tests.
  • Cold-start and warm latency compared between JVM+SnapStart and native builds.
  • CI build duration and memory use for native compilation.

Key points

  • Native images include only statically reachable code; declare reflection, resources and proxies.
  • Use framework native support and run tests against the native binary.
  • Compare with SnapStart before committing to native.

Further reading: GraalVM Native Image · Quarkus: AWS Lambda · Micronaut guides

Practice questions

Why do some libraries fail in a GraalVM native image even though tests pass on the JVM?

Answer

Native Image includes only code reachable through static analysis; classes accessed through reflection, proxies or resources must be registered. JVM-mode tests do not exercise the native binary, so these gaps only show up at runtime.

How do you deploy a native image to Lambda?

Answer

Build a Linux executable for the function's architecture and deploy it as the bootstrap file on a custom runtime such as provided.al2023 (frameworks such as Quarkus and Micronaut generate the package).

When might SnapStart be preferable to a native image?

Answer

When the application relies on reflection-heavy libraries, the team wants to keep the JVM tooling and peak JIT performance, or build-time complexity is a concern; SnapStart often achieves most of the start-up benefit with fewer changes.

Open in the app to mark lessons complete, take notes and track your progress.