For teams building Java microservices, performance problems rarely start with the business logic itself. First the number of services grows. Then Kubernetes shows up, along with autoscaling, more environments, and more traffic. Eventually someone looks at the cloud bill or response times under load - and it turns out even a small service needs hundreds of megabytes of memory, takes several seconds to start, and doesn't hold up as well as it should once traffic picks up.
That's exactly where Spring Boot 3.x and Java 21+ give you two concrete levers for optimization. The first is GraalVM Native Image - Ahead-of-Time compilation that cuts startup time and memory use. The second is Project Loom, or more precisely virtual threads, which let you handle much higher concurrency without moving to a reactive programming model. WebFlux is still around too, but it's no longer the obvious answer to every scaling problem.
This isn't a topic just for architects. It directly affects how much services cost to run, whether autoscaling makes sense, deployment times, and the team's day-to-day work. If one technology decision can cut startup time from several seconds down to a few hundred milliseconds and noticeably lower memory use, that's not “optimization for its own sake” anymore. It's a decision with a real effect on the product's economics.
Where Spring Boot usually hurts in the cloud
In classic JVM-based deployments, three problems keep coming back.
- Startup time - a new instance needs several seconds, so autoscaling reacts more slowly, rollouts take longer, and in a serverless model costs go up while the user experience gets worse.
- Memory use - memory in the cloud costs money, and with a dozen or several dozen microservices, even modest usage per service starts to put a real dent in the budget.
- Predictability under load - a good-looking average response time isn't enough.
That's a good starting point for an architectural decision. Instead of asking whether a technology is “modern” or trendy, it's more useful to focus on what will actually improve startup time, memory usage, and stability under growing load - at an acceptable implementation cost.
JIT vs. AOT: what GraalVM Native really changes
A classic JVM runs on a Just-In-Time model. Code gets analyzed and optimized while the application runs. That works well for long-running processes that get time to “warm up.” Under those conditions, HotSpot can optimize the most frequently executed code paths very effectively and reach high performance.
That comes at a price, though. The application needs time to start and optimize itself, plus more memory for the virtual machine and its own machinery.
GraalVM Native Image works differently. The application gets compiled ahead of time into a native binary. Once it's running, there's no classic JVM and no warm-up phase, and far fewer things run in the background. In practice, that gives you a clear effect:
- startup time drops from several seconds to a few hundred milliseconds, often below 0.2s,
- memory usage drops by as much as 70-80%,
- the application is ready to serve traffic almost as soon as it starts.
This matters a lot wherever instances start up frequently or where there are a large number of services. In a serverless model, that means less of a “cold start” problem. In Kubernetes, new instances join traffic faster. In a microservices architecture, it's a genuine chance to lower memory costs.
There are trade-offs, though. Native doesn't win in every scenario. Under CPU-heavy load, a classic JVM, once warmed up, can be faster. If an instance runs for a long time and does complex computation, the JIT advantage still matters. In typical web services, APIs, and I/O-bound integrations, the numbers usually favor the native approach.
The simplest takeaway: if fast startup and lower memory use are what matter, GraalVM Native makes a lot of sense. If squeezing maximum performance out of a single, long-running instance is the priority, it's worth testing before deciding.
When does GraalVM Native pay off the most?
Not every service needs a native approach. The biggest gains usually show up in scenarios like:
- services running under autoscaling or a serverless model,
- microservices with a large number of replicas,
- components where memory is the main cost driver,
- APIs and integrations dominated by I/O rather than heavy computation,
- systems that need to start handling traffic quickly after deployment or a sudden traffic spike.
In cases like these, GraalVM Native isn't just “a nice benchmark result.” It's a way to use infrastructure more efficiently and genuinely cut costs.
What does Spring Boot 3 handle for you, and what won't it do?
Not long ago, using GraalVM Native with Spring meant a lot of manual work. Things look much better today. Spring Boot 3 officially supports Native Image and introduces AOT (Ahead-of-Time) mechanisms that reduce reliance on reflection and generate the hints the compiler needs.
For a team, that means a shorter path to a first working prototype. You no longer need to manually describe a large chunk of the framework. Many popular libraries already ship with the required metadata, and Spring itself does a lot of the work behind the scenes.
That doesn't mean every project can “just” move to GraalVM Native without analysis, though. Problems tend to show up wherever an application or its libraries lean on dynamic mechanisms:
- reflection,
- dynamic class loading,
- code generation at runtime,
- plugin systems,
- scripting and heavy instrumentation.
If a system leans on these patterns, moving to Native may need extra configuration - or it simply may not be the best choice. This is one of the first things worth checking before starting a migration.
What determines library compatibility with GraalVM Native?
In a native approach, something that most teams barely notice on a classic JVM starts to matter a lot: reachability metadata. This is the information that determines which classes, resources, and reflection elements get included in the native binary.
The AOT model follows a “closed world” rule. If the compiler doesn't detect something at build time, it may not be available once the application is running. That's a common source of bugs that never showed up on a classic JVM.
The good news: the Spring ecosystem is in a much better place today than it was a few years ago. Many popular libraries already ship with ready-made metadata, and Spring Boot 3 makes using it a lot easier.
Even so, a structured approach pays off:
- test the native binary against real-world scenarios,
- check less common libraries and integrations,
- account for dynamic cases that may need their own configuration.
In practice, that means moving to GraalVM Native isn't just flipping a build setting. It's an architectural decision that needs testing and deliberate analysis.
How does GraalVM Native affect CI/CD and build costs?
This is a topic that often only surfaces at the first real deployment. Building a native version of the application is simply heavier than producing a standard JAR. It takes longer, uses more memory and CPU, and on larger projects can tack several minutes or more onto the pipeline.
That's not an argument against GraalVM Native. It's a cost that needs to be planned for deliberately and fitted into the process well.
The most sensible approach usually looks like this:
- don't build the native version on every commit,
- run it on merges, releases, or once a day,
- keep separate tests for the native version,
- measure build time and weigh it against the runtime savings.
In practice, the build takes longer and CI costs go up. On the other hand, the application uses less memory and scales faster. If a service runs constantly and has several or a dozen replicas, those savings are usually bigger than the extra build cost.
WebFlux vs. Loom: where the real difference lies
For years, WebFlux was, for a lot of teams, synonymous with “scalable Spring.” That conversation looks different today, because Project Loom offers a third option: a simple, blocking programming model, but with much better concurrency than before.
WebFlux still has the edge when the entire system runs fully asynchronously. If you're using a reactive database driver, a non-blocking HTTP client, streaming, Server-Sent Events, or WebSockets, the reactive approach gives you tight control over resources and handles a very large number of simultaneous connections well.
The problem is that most business systems don't run in an environment that clean. There's JDBC, older vendor SDKs, blocking external APIs, and libraries that were never designed with a reactive model in mind.
That's where bottlenecks appear. If part of the workload blocks, it has to get pushed into separate thread pools, which get swamped easily once traffic grows.
Loom takes a different approach. Virtual threads let you write code the classic way, without the cost that used to come with a large number of threads. When an operation blocks, it only parks its own virtual thread - not the whole application.
That's why, in a lot of typical APIs and CRUD systems, Loom delivers a better outcome with less complexity than WebFlux.
What do the benchmarks usually show?
Performance testing tends to reveal a pretty consistent pattern.
In a fully asynchronous environment:
- WebFlux is sometimes marginally better on throughput,
- high percentiles (like p99) are more stable,
- resource usage is very efficient with a high number of simultaneous connections.
In the real, mixed world:
- Loom often comes out ahead, since it handles blocking elements better (JDBC, SDKs, older integrations),
- it doesn't overload the main handler threads or extra pools,
- it lets you increase concurrency without rebuilding the code into a reactive model.
For most systems built on classic APIs and CRUD operations, that's a key finding. In many cases, you don't need to move to WebFlux to improve scalability. Java 21, Spring Boot 3, and virtual threads are often enough.
Where does WebFlux still make a lot of sense?
It doesn't make sense to treat Loom as a replacement for the reactive approach. It's simply a different tool. There are areas where WebFlux is still a very good choice.
That mainly applies to:
- data streaming,
- Server-Sent Events,
- WebSockets,
- systems that run fully asynchronously,
- teams that already have reactive experience and want to keep building on it.
If a team's architecture and skills are already built around Reactor, there's no point rewriting everything just because Loom showed up. On the flip side, there's no need to adopt the reactive approach preemptively either. If a system leans on blocking integrations, simpler solutions will often be more effective and easier to maintain.
Where does Loom give the best return for the effort?
For a lot of teams, this is exactly where Java 21's biggest value shows up. Virtual threads work well in cases like:
- classic REST APIs,
- systems using JDBC or JPA,
- applications with blocking integrations,
- elaborate CRUD systems,
- teams that want simpler debugging and a lower cost of maintaining the code.
From a business standpoint, that matters a lot. It's not just about better concurrency. What counts just as much is not having to introduce a more complex programming model where it doesn't actually give you an edge. That makes the system easier to maintain, changes faster to ship, and development costs lower over time.
What changes in system maintenance after moving to GraalVM Native?
Moving to GraalVM Native affects more than just performance - it also changes what the application's life looks like after deployment. On a classic JVM, a lot of things can be diagnosed dynamically. In the native version, more needs to be planned in advance.
The key areas worth setting up ahead of time:
- application metrics,
- tracing requests across services,
- logging with adjustable verbosity,
- testing the native version of the app as part of CI,
- monitoring memory, startup time, and behavior under load.
One common trap involves logging. If the build process leaves out certain debug or trace paths, you can't simply turn them on in production later without rebuilding the app. That's why the logging strategy needs to be decided in advance - not once a problem already shows up.
This is one of the details that separates a working prototype from a stable production deployment.
Costs and the real return on investment
This is where the topic starts to matter to a CTO or whoever owns the budget. Building the native version costs more, but running the application is usually cheaper. The key is working out where the break-even point sits.
The simplest way to look at it: if one service uses roughly 250-300 MB less RAM after moving to GraalVM Native, and you have 20 services with 3 replicas each, that adds up to 15-18 GB less memory across the cluster.
In practice, that can mean:
- smaller instances,
- fewer nodes in the cluster,
- better resource utilization,
- lower costs for additional environments,
- more headroom to grow without immediately adding infrastructure.
The second area is application startup time. If a new instance starts in a fraction of a second instead of several seconds, autoscaling reacts faster and you can reduce the number of “buffer” replicas you keep around.
In some scenarios that matters a lot, especially when scaling to zero and in serverless environments.
For a lot of companies, this is exactly where the real return on investment shows up - not at the level of a single service, but in the total cost of the whole system.
When don't GraalVM Native or WebFlux make sense?
Not every service should run as a native build. Not every system needs WebFlux.
The most common cases where GraalVM Native doesn't work well:
- systems built around dynamic plugins,
- heavy use of reflection and dynamic class loading,
- very dynamic frameworks and scripting,
- projects that need extensive monitoring through JVM agents,
- applications focused on maximum CPU performance, where startup time and memory use matter less.
The most common cases where WebFlux isn't the right call:
- applications with a large number of blocking integrations,
- teams with no experience in the reactive approach,
- systems that don't need streaming or a fully asynchronous model,
- situations where WebFlux gets picked “just in case,” with no concrete justification.
The worst architectural decisions usually come from one source: a technology gets picked because it sounds good, not because it actually solves a specific problem.
How to approach the architectural decision
The most sensible approach isn't to roll out everything at once. Treat this as a controlled pilot instead.
A good sequence of decisions usually looks like this:
- first check whether the main problem is startup time and memory use, or concurrency and blocking operations,
- if the system runs on classic MVC, JDBC, and blocking integrations, start with Java 21 and virtual threads,
- if a service has a lot of replicas or runs under autoscaling, evaluate GraalVM Native's potential,
- if the architecture is fully asynchronous and relies on streaming or very high concurrency, consider WebFlux,
- as a pilot, measure startup time, memory use, throughput, p95/p99, and infrastructure cost for 1-2 services,
- only then decide on a broader rollout.
This approach rests on a simple rule: measure first, decide second.
The direction for most teams
For most teams, a good starting point today is Java 21, Spring Boot 3.x, and virtual threads in classic Spring MVC. That improves scalability without a code rewrite and without a meaningful increase in maintenance complexity.
GraalVM Native is worth rolling out selectively - wherever startup time and memory use genuinely affect cost: microservices with a lot of replicas, autoscaling systems, serverless setups, and anywhere memory is the main constraint.
WebFlux is best kept for where it delivers a real advantage: streaming, real-time communication, and fully asynchronous systems - especially where the team already has experience with that model.
The one thing that matters most: don't pick a technology “just in case.” Pick solutions that match the actual problem, how the system runs, and the team's skill set.









