I was able to drastically improve the performance of my Spring Boot REST API by generating a Native Image with the GraalVM toolkit. The “drastically” adjective translates to numbers as:
ă…¤ | Startup Time | Memory Usage | Most CPU Usage |
With GraalVM JVM | 3,310 ms | 280.9 MB | 15% |
Native Image | 0,157 ms | 147.2 MB | 0.13% |
Comparison | 95% less | 50% less | 98% less |
If you like to skip to the How-To, click here.
Story
My Use-Case
I developed a backend REST API to act as the BFF for my
portfolio-website
. All it did was make authenticated requests to GitHub’s API and cache those responses. On my professional job, I got used to deploying applications without worrying too much about resources, so you can image my surprise when this tiny API ran out of memory and went down.The first thing that came to my mind was that Fly.io’s free plan gave an unrealistically small memory size. But when actually looking at it, the memory limit was of 228 MB. For an API that did so little, 228 MB should have been more than enough. Then I realized why some people trash talk Java and the JVM… My app was using up to 280 MB for that simple flow. So I had to either increase the machine’s memory or reduce its usage.
Fly.io offers more memory if you pay 5 dollars, but I did not want to give up that easily. So I tried a little more and remembered about GraalVM. I knew it was great to speed up the application cold-start and overall performance. But I did not expect these improvements.
As shown with the table on the beginning of this article, the memory usage reduced ~ 50%. Besides that, the cold-start and average CPU usage also improved a lot. And with the memory usage reduction, I can now rest assured that my API will not run out of memory.


How-To
1. What is GraalVM Native Image?
GraalVM is a set of tools which include the Native Image builder. The Native Image builder reads the bytecode produced by the JDK compiler and performs ahead-of-time compiling. The difference between the two compilations is that the JDK compiler generates bytecode, whereas the Native Image builder generates binaries. The bytecode is interpreted by the JVM and can run anywhere a JVM can. The binaries, on the other hand, run directly on the OS and are specific to an OS and processor architecture.

2. Preparing your Environment
2.1. Hardware Requirements
The build process requires a lot of RAM. My environment has an Intel Core i5-1035 with 8 GiB of RAM and when I had less than 4 GiB free, I got an
OutOfMemoryException
from the native image builder. So be sure to have at least 4 GiB of RAM available before building your image.2.2. Using Buildpacks
If you plan on deploying your application with a Docker image, and have a Docker daemon running on your environment, the easiest way to build your image is to use Buildpacks. The Spring Boot plugin sets up everything for you. Just jump to 3.2 Setting up Gradle and continue from there.
Using a Docker container to build your image is probably a good idea. It can work as a way to skip 2.2 Install libraries for the Build. But I have not tried it yet.
2.3. Install GraalVM JDK
I recommend using SDKMAN as it gives an easy CLI to download and change the JDK on your system. (If you cannot use SDKMAN for any reasons, you can download directly from the GraalVM website.)
Simply run:
sdk install java 17.0.8-graal // press Y or simply enter to set this JVM as the default
As you can see, I will be using the Java 17 for this How-To.
2.4. Install Libraries for the Build
The GraalVM compiler needs some libraries to build the native-image for your OS. Which libraries you need to download depend on your environment. So check the GraalVM prerequisites page.
Since I use Ubuntu, this is what I ran:
sudo apt-get install build-essential libz-dev zlib1g-dev
3. Setting Up the Tracing Agent
3.1. What is the Tracing Agent?
First, you need to know why you (probably) need it. The native image executables run without the JVM and with a closed-world assumption. That means it only adds code that is reachable during build time to the final binary. So a few extremely important things we take for granted on a Spring Boot application are not available. For instance: Reflection, Dynamic Proxies, Serialization, and more are not supported or supported with limitations.
This means we would not be able to use any of the following:
@Controller public class MyController{} @Cacheable public List<Data> getMyData() {} @Value("${application.cache.data-ttl}") private Duration dataCacheTtl;
Not being able to use those features at run-time, requires us to bring them into build time. To do that, GraalVM uses a set of configuration files that provide the native image builder with reachability metadata. Writing those files manually would be too cumbersome. So here comes in the tracing agent.
The tracing agent watches as your application run with the JVM and writes the reachability metadata for the native image builder. You can use the agent while going through your application flows manually, or while running automated tests.
Just remember that only code that is reached during the tracing agent’s watch will be included into the reachability metadata (i.e. if you or your tests don’t call all your API’s endpoints, and every possible outcome of them your native image will be failing). Otherwise, you will need to manually add them to the metadata files.
3.2. Setting up Gradle
I used Gradle for my project and for this How-To, but there is a documentation for Maven as well.
Include the GraalVM Native Build Tools plugin:
plugins { id 'org.graalvm.buildtools.native' version '0.9.25' }
3.3. Setting up Tracing Agent on Tests
I do not want to manually run all scenarios from my API every time I implemented a change — so I implemented a set of integration tests. When I run the
test
(or tracingAgentTest
) it watches my test cases and writes the reachability metadata. All you need to run the tracing agent is to add
-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image
as a JVM argument../gradlew test -Dorg.gradle.jvmargs=-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image
The default path where GraalVM Native Image builder will look for reachability metadata is
/src/main/resources/META-INF/native-image
.Or add it to a custom testing task, if you do not want to re-write the META-INF on every test run during development:
tasks.register("tracingAgentTest", Test) { group "verification" useJUnitPlatform() jvmArgs "-agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image" }
3.4. Setting up Tracing Agent on Local Run
It is pretty much the same setup as with tests. If you already have the GraalVM JDK installed, all you need to do is:
./gradlew bootRun -Dorg.gradle.jvmargs=-agentlib:native-image-agent=config-merge-dir=src/main/resources/META-INF/native-image
I ran into some issues when trying to run the native image builder Gradle tasks through IntelliJ. So I’d recommend running these commands directly on the terminal, if you find any issues.
4. Generating the Native Executable
All you have to do now is generate the native executable.
./gradlew tracingAgentTest nativeCompile
It takes a few minutes to build, and it consumes a lot of memory, but it compensates with the performance improvement during run-time. The output should be similar to this:

The generated native image will be on your project, under
/build/native/nativeCompile/
.5. Running the Native Image
You have a native executable that works for any machine with the same architecture and OS you have used to build it. There might be a way to generate for a different set of architecture and OS, but I have not looked into it.
To run your executable:
./build/native/nativeCompile/<your app name>
That is it. You have your native executable to do whatever you want. Wrap it into a docker image to make the deployment easier.