Introduction: Why Micronaut and Quarkus Still Run Slow in Production
Micronaut and Quarkus are marketed as ultra-fast Java frameworks, and in benchmarks they absolutely deliver. Yet in real production systems, I keep seeing teams complain that their shiny new REST APIs are barely faster than their old Spring Boot services. The issue usually isn’t the frameworks themselves—it’s how we build and run them.
In my experience reviewing Micronaut and Quarkus REST API mistakes across multiple projects, the performance gap almost always comes from application design, not the runtime. Developers lean on familiar patterns from heavier frameworks, ignore framework-specific best practices, or assume that enabling GraalVM magically fixes everything. The result is high CPU, slow P99 latencies, and unstable behavior under load.
What I’ve learned the hard way is that small decisions—like how you handle JSON, blocking calls, configuration, or dependency injection—can completely erase the advantages of Micronaut and Quarkus. This article breaks down seven concrete mistakes that consistently kill performance in real-world REST APIs built on these frameworks, along with practical ways to avoid them.
1. Treating Micronaut and Quarkus Like Classic Spring Boot
One of the most damaging Micronaut and Quarkus REST API mistakes I keep seeing is teams writing code exactly as if they were still on classic Spring Boot. The whole point of these frameworks is ahead-of-time (AOT) processing, reduced reflection, and GraalVM-friendly behavior. If we drag over every dynamic, reflection-heavy pattern from the Spring era, we effectively turn off the very advantages we migrated for.
In my own migrations, the biggest improvements came only after I stopped copy-pasting Spring-style abstractions and started leaning into each framework’s native DI, configuration, and HTTP layer. The runtime got lighter, memory dropped, and latency became far more predictable.
How Spring-Era Patterns Break AOT and GraalVM
Reflection-heavy design (dynamic proxies, generic mappers, runtime scanning) forces the runtime to do more work and complicates native-image builds. Common culprits I still see in Micronaut and Quarkus services include:
- Custom reflection utilities for mapping entities to DTOs instead of explicit mapping or compile-time tools.
- Dynamic bean lookup by class name or string identifiers instead of constructor injection.
- Heavy use of annotation scanning at runtime (home-grown frameworks, plugin systems).
- Big hierarchical configuration objects loaded and parsed on every request.
Here’s a simplified example of a pattern I try to avoid in production-grade Micronaut or Quarkus REST APIs:
// Anti-pattern: runtime reflection for dynamic mapping
public class ReflectionMapper {
public <T> T mapTo(Class<T> target, Map<String, Object> source) {
try {
T instance = target.getDeclaredConstructor().newInstance();
for (Field field : target.getDeclaredFields()) {
field.setAccessible(true);
if (source.containsKey(field.getName())) {
field.set(instance, source.get(field.getName()));
}
}
return instance;
} catch (Exception e) {
throw new IllegalStateException("Failed to map", e);
}
}
}
This kind of reflection mapper hurts warmup time, confuses GraalVM native-image configuration, and adds per-request overhead. Instead, I’ve had much better results with explicit mappers or tools that generate code at build time.
What to Do Instead: Lean Into Framework-Native Patterns
To keep Micronaut and Quarkus fast under real-world load, I focus on patterns that play nicely with AOT and native images:
- Prefer constructor injection and avoid dynamic lookups through application contexts.
- Use framework-native HTTP features (Micronaut controllers, Quarkus RESTEasy / RESTEasy Reactive) instead of layering extra abstraction frameworks on top.
- Replace generic reflection mappers with explicit mapping classes or compile-time mapping libraries.
- Move expensive work to startup so AOT and build-time processing can help, keeping request handling thin.
Here’s a Quarkus-style example that behaves well in production:
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@ApplicationScoped
@Path("/health")
public class HealthResource {
private final HealthService healthService;
@Inject
public HealthResource(HealthService healthService) {
this.healthService = healthService;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
public HealthDto health() {
// Thin endpoint, no reflection magic
return healthService.currentHealth();
}
}
When I design Micronaut and Quarkus REST APIs around these principles, the frameworks finally deliver on their promise: small memory footprint, fast startup, stable tail latencies, and clean native-image builds. Ahead of Time Optimizations :: Spring Framework
2. Misconfiguring HTTP Servers and Thread Pools for REST Workloads
Many of the Micronaut and Quarkus REST API mistakes I’m asked to troubleshoot come down to one thing: the HTTP server and thread pools are tuned for demo apps, not real REST traffic. The defaults work fine on a laptop, but under sustained load they either starve the CPU or choke on blocking calls. When I first started deploying these stacks to production, I underestimated how much Netty, Undertow, or Vert.x settings could make or break latency.
What I’ve learned is that you don’t need exotic tuning, but you do need intentional tuning: matching event loops, worker threads, and request timeouts to your actual workload profile (CPU-bound vs I/O-bound, sync vs reactive, request size, and concurrency).
How Default HTTP and Thread Settings Become Hidden Bottlenecks
Micronaut and Quarkus are typically backed by Netty or Vert.x, and in many setups you’ll also have a separate worker or executor pool. Problems show up when:
- Event loops are overloaded with blocking work, causing cascading latency spikes.
- Thread pools are undersized for CPU-heavy serialization or security filters.
- Keep-alive and connection settings don’t match your API gateway or load balancer, leading to connection churn.
- Request and header size limits are too low, causing unexplained 4xx/5xx under real client payloads.
In my own projects, the biggest surprise came when a tiny misconfigured executor pool turned a powerful 8-core VM into a machine that behaved like a dual-core under load. The framework was fine; my thread counts were not.
Key Tuning Knobs in Micronaut and Quarkus
Here are some of the most important knobs I check when I’m investigating REST performance:
- Event loop and worker thread counts – Typically scaled to CPU cores.
- Max concurrent requests / queue sizes – To avoid unbounded queuing and out-of-memory issues.
- Request, body, and header limits – To avoid runtime surprises with real client payloads.
- Timeouts (read, write, idle) – To clean up slow or dead connections quickly.
For example, in a Micronaut Netty-based service, I’ll adjust the server executor for a CPU-bound REST API like this:
# application.yml (Micronaut)
micronaut:
server:
port: 8080
netty:
worker:
# Rough rule: 2-4x number of CPU cores, then validate with a load test
threads: 32
childOptions:
# Tune backpressure and concurrency
maxInitialLineLength: 8192
maxHeaderSize: 16384
maxChunkSize: 1048576
idle-timeout: 60s
On Quarkus with Undertow or Vert.x, I approach it similarly:
# application.properties (Quarkus) quarkus.http.port=8080 quarkus.http.io-threads=8 # Often = number of CPU cores quarkus.http.worker-threads=64 # For blocking handlers and filters quarkus.http.limits.max-header-size=20K quarkus.http.idle-timeout=60S
These values aren’t magic; they’re starting points. I always validate them with a load test that mimics real traffic patterns.
Separating Blocking Workloads from I/O Threads
Another silent killer I see is mixing blocking work directly on event-loop threads. This shows up as timeouts, surging P99 latency, and CPUs that look underutilized even when the service is clearly slow. In both Micronaut and Quarkus, I make sure any blocking I/O or heavy CPU sections are dispatched to dedicated pools.
For example, in Quarkus with RESTEasy Reactive, I prefer something like:
import io.smallrye.common.annotation.Blocking;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
@Path("/reports")
public class ReportResource {
@GET
@Blocking // Run on worker threads, not I/O event loop
public Response generateReport() {
// Safe to call blocking DB or external APIs here
var data = slowReportGeneration();
return Response.ok(data).build();
}
}
In my experience, simply moving the right endpoints off the event loop and onto worker pools has fixed more than one production incident. When HTTP servers and thread pools are sized and separated correctly, Micronaut and Quarkus can handle surprisingly high REST loads with stable latency. Advanced Vert.x Guide
3. Forgetting That JSON Serialization Can Dominate Latency
When I profile real Micronaut and Quarkus REST API mistakes in production, JSON serialization almost always shows up near the top of the flame graph. The core frameworks are incredibly fast, but if each request spends most of its time converting big object graphs to and from JSON, the benefits of Micronaut and Quarkus evaporate. I’ve seen endpoints where 40–60% of the latency was pure serialization overhead.
The usual problems are oversized payloads, deeply nested DTOs, and unoptimized JSON mappers. Teams often stick with default ObjectMapper settings, enable expensive features without realizing it, or serialize data that clients don’t even use. In one project, simply trimming unused fields and tightening the JSON configuration cut P95 latency almost in half.
How JSON Configuration Choices Hurt Performance
Some of the heavy JSON options I watch out for include:
- Expensive features turned on by default, like full polymorphic type handling or introspecting every field with reflection.
- Autodetected getters/setters on large domain models instead of lean DTOs tailored to each response.
- Unbounded pretty-printing or custom serializers that allocate excessively.
Here’s a simple Micronaut example where I explicitly control Jackson settings instead of relying entirely on defaults:
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.micronaut.context.annotation.Factory;
import jakarta.inject.Singleton;
@Factory
public class JsonConfig {
@Singleton
ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
// Disable features that add overhead but don’t help APIs
mapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return mapper;
}
}
On Quarkus, I’ve had good results by using small, focused response DTOs and avoiding unnecessary polymorphism or deep object graphs in JSON. Keeping payloads lean is often the cheapest performance win.
Practical Steps to Keep JSON from Dominating Latency
To stop JSON from becoming your real bottleneck, I use a few rules of thumb:
- Profile first: confirm how much time is spent in serialization/deserialization for key endpoints.
- Use dedicated DTOs: don’t serialize entire JPA entities or huge domain models when clients only need a subset.
- Disable unnecessary features in Jackson or JSON-B and avoid expensive polymorphic mappings unless you truly need them.
- Consider streaming APIs (e.g., JSON streaming, NDJSON) for very large responses instead of building giant in-memory lists.
Once I started treating JSON as a first-class performance concern, Micronaut and Quarkus finally delivered the low latencies their benchmarks promised, even under heavy real-world traffic.
4. Overusing Dependency Injection and Reflection-Friendly Features
Another pattern I keep running into with Micronaut and Quarkus REST API mistakes is teams wiring everything as a bean and leaning on reflection-friendly patterns “just in case.” Micronaut and Quarkus are designed to push as much work as possible to build time, but if we overuse dependency injection, enable broad classpath scanning, or introduce reflection-heavy helpers, we slowly give back the startup and memory benefits that drew us to these frameworks in the first place.
When I audited one particularly slow service, the DI graph contained hundreds of beans, many created solely for trivial value transformations. The app still ran, but startup time ballooned and native images became fragile because of all the hidden reflective usage.
How Excessive DI and Reflection Undermine Performance
Micronaut’s compile-time DI and Quarkus’s build-time augmentation are incredibly efficient when we keep the bean graph intentional. Problems creep in when:
- Every utility becomes a bean, even pure functions with no external dependencies.
- Automatic component scanning is left wide open across large packages or entire modules.
- Generic reflection utilities or dynamic proxies sneak in for logging, mapping, or cross-cutting concerns.
- Conditional or profile-based beans explode the number of possible configurations the runtime must handle.
Here’s a simple anti-pattern I try to avoid in performance-sensitive REST APIs:
// Anti-pattern: trivial stateless helper as a bean
@Singleton
public class StringHelper {
public String trimAndLower(String input) {
return input == null ? null : input.trim().toLowerCase();
}
}
// Instead of just using a static method in a utility class
Individually this looks harmless, but repeated across dozens of helpers it bloats the bean graph, complicates startup, and makes native-image builds more complex than necessary.
Practical Guidelines for Lean DI in Micronaut and Quarkus
What’s worked best for me is treating DI as a tool for wiring boundaries (I/O, persistence, external systems), not every line of business logic:
- Keep utility code static or plain when it has no dependencies or state.
- Limit component scanning to specific, well-defined packages instead of the entire codebase.
- Avoid reflective helpers and dynamic proxies unless you truly need that flexibility; prefer explicit, compiled code paths.
- Design small, focused beans that represent real responsibilities (repositories, services, adapters), not incidental helpers.
Once I started trimming the bean graph and removing unnecessary reflection, Micronaut and Quarkus services consistently started faster, consumed less memory, and produced more reliable native images—all without sacrificing code clarity.
5. Ignoring Native Images and JVM vs Native Trade-offs
One of the more subtle Micronaut and Quarkus REST API mistakes I see is teams either ignoring native images completely or assuming native automatically means “faster in every way.” Both frameworks are built to shine with GraalVM native-image, but the real gains depend on your workload, deployment model, and how production traffic behaves. When I started running serious load tests, I realized that choosing between JVM and native mode is a trade-off decision, not a checkbox.
If you never evaluate native builds, you might be leaving huge startup and memory wins on the table—especially for short-lived workloads or dense container deployments. On the flip side, blindly flipping everything to native can actually hurt max throughput on certain CPU-bound REST workloads where the JVM’s JIT optimizations still win.
When Native Images Help—and When They Don’t
From my own experiments, native images shine in a few clear scenarios:
- Fast startup and low memory are critical (serverless, auto-scaling, or many small containers per node).
- Spiky or bursty traffic where cold starts matter more than absolute peak throughput.
- Highly parallel microservices where packing more instances per node improves latency and resilience.
JVM mode often wins when:
- Throughput under steady load is the main goal and you can afford warm-up time.
- Heavy CPU/GC tuning and advanced JIT optimizations pay off over long uptimes.
- Dynamic or reflective libraries are hard to configure for native-image.
Here’s a very simple Quarkus configuration sketch I’ve used to compare both modes cleanly:
# JVM mode build ./mvnw clean package -DskipTests java -jar target/quarkus-app/quarkus-run.jar # Native image build (GraalVM or Mandrel) ./mvnw clean package -DskipTests -Pnative ./target/my-service-runner
By running the same load profile against both, I’ve seen cases where native used 50–60% less memory and started in tens of milliseconds, and other cases where JVM mode delivered 10–20% higher peak RPS.
Practical Guidelines for Choosing JVM vs Native
To avoid hand-wavy decisions, I now follow a simple process:
- Define your primary goal: startup vs memory vs steady-state throughput.
- Build both JVM and native variants for at least one representative REST service.
- Load test both modes with realistic payloads and concurrency levels.
- Measure end-to-end costs: latency distribution, CPU usage, memory footprint, and cloud/runtime costs.
Micronaut and Quarkus make it relatively easy to support both modes in the same codebase. The real mistake is skipping the comparison. Once I started treating JVM vs native as an engineering choice backed by measurements, my deployments became both faster and cheaper. Performance Benchmark: Spring Boot 3.3.4 vs. Quarkus 3.15.1 vs. Micronaut 4.6.3
6. Skipping Proper Metrics, Tracing, and Load Testing
Some of the most painful Micronaut and Quarkus REST API mistakes I’ve dealt with weren’t about code at all—they were about visibility. The services looked blazing fast in development, but without real metrics, tracing, and load testing, we had no idea how they behaved under stress. When production traffic hit, thread pools saturated, garbage collection spiked, and P99 latency went through the roof, all while our dashboards stayed mostly blank.
In my experience, Micronaut and Quarkus can absolutely deliver low-latency APIs, but only if you instrument them properly and validate performance with realistic benchmarks before go-live. Otherwise, you’re just guessing.
Why “It’s Fast on My Laptop” Is a Trap
Local tests usually cover only the happy path: tiny payloads, low concurrency, warm caches, and no network hops. In production, things look very different:
- APIs are called by multiple clients at once, sometimes in bursts.
- Downstream dependencies (DB, other services) introduce variable latency.
- Serialization, connection pools, and rate limiting all start to matter.
Without metrics and tracing, it’s almost impossible to pinpoint why a Micronaut or Quarkus service slows down. I’ve seen teams spend days tweaking JVM flags when the real culprit was a single slow database query or a mis-sized thread pool they couldn’t see.
Essential Metrics and Tracing for Micronaut and Quarkus
These days, I treat observability as part of the API, not an afterthought. At a minimum, I enable:
- Request-level metrics (RPS, latency histograms, error rates) per endpoint.
- JVM metrics (CPU, heap usage, GC pauses, thread counts).
- Database and HTTP client metrics (pool usage, timeouts, retries).
- Distributed tracing across services, so I can follow a request end to end.
Here’s a small Quarkus example that exposes simple custom metrics with Micrometer:
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@ApplicationScoped
@Path("/orders")
public class OrderResource {
private final Counter ordersCounter;
@Inject
public OrderResource(MeterRegistry registry) {
this.ordersCounter = registry.counter("api_orders_total");
}
@GET
public String listOrders() {
ordersCounter.increment();
// real logic here
return "[]";
}
}
In Micronaut, I’ve had equally good results using built-in Micrometer support and OpenTelemetry tracing to tie together all REST calls, DB queries, and external API calls into a single trace per request.
Realistic Load Testing Before Production
Once basic observability is in place, the next step I always take is running a realistic load test. The key word is realistic: the test should mimic your expected concurrency, payload sizes, and traffic patterns (steady, bursty, or both). I’ve caught many issues—like connection leaks, wrong thread pool sizes, or slow JSON serialization—only when a load test finally pushed the service beyond “happy path” territory.
A simple workflow that’s worked well for me:
- Capture a sample of real requests (or design close approximations).
- Use a tool like k6, Gatling, or JMeter to replay them with increasing concurrency.
- Watch metrics and traces as you ramp up: look at P95/P99 latency, error spikes, and resource usage.
- Iterate on config (thread pools, timeouts, limits) and code hot spots until the system is stable at your target load.
Here’s a tiny k6-style script sketch I’ve used to stress-test a Micronaut or Quarkus REST endpoint:
import http from 'k6/http';
import { check, sleep } from 'k6';
export let options = {
vus: 100,
duration: '2m',
};
export default function () {
const res = http.get('http://localhost:8080/api/orders');
check(res, { 'status is 200': (r) => r.status === 200 });
sleep(0.1);
}
When I combine solid metrics, tracing, and targeted load testing, Micronaut and Quarkus REST APIs stop being mysterious. They either meet the performance goals, or the data clearly shows where to tune next—long before real users feel the pain. OpenTelemetry Setup in Spring Boot Application | Baeldung
7. Mismanaging Database Connections and Reactive Access
Of all the Micronaut and Quarkus REST API mistakes I’ve debugged, database access issues are the most common “hidden bottleneck.” The framework looks lean and fast, but a badly tuned JDBC pool, misuse of R2DBC, or chatty queries can cap throughput long before CPU or HTTP limits are reached. I’ve seen teams blame Micronaut or Quarkus when the real problem was a connection pool with 5 connections serving hundreds of concurrent requests.
The tricky part is that these issues often show up only under sustained load: slow response tails, random 500s from timeouts, and DB servers running hot while app instances appear idle.
Common JDBC and Connection-Pool Pitfalls
With traditional JDBC, I see the same patterns again and again:
- Connection pools that are too small or too large – too small starves throughput, too large overwhelms the DB.
- Long-running or blocking queries on I/O threads, tying up both the DB and the app.
- Lack of timeouts – slow queries pile up, filling the pool and cascading into app-wide slowdowns.
- Opening raw connections manually instead of relying on the framework’s pooled DataSource.
Here’s a simple Quarkus HikariCP-style configuration I’ve used as a starting point for a moderately loaded REST service:
# application.properties (Quarkus JDBC example) quarkus.datasource.db-kind=postgresql quarkus.datasource.jdbc.url=jdbc:postgresql://db:5432/app quarkus.datasource.username=app quarkus.datasource.password=secret # Start conservative, then tune with a load test quarkus.datasource.jdbc.max-size=20 quarkus.datasource.jdbc.min-size=5 quarkus.datasource.jdbc.acquisition-timeout=5S
In Micronaut, I follow the same pattern: start with a pool size roughly aligned with CPU cores and expected query cost, then tune based on metrics like wait time and DB CPU usage.
Using Reactive Access (R2DBC) Without Creating New Bottlenecks
Reactive access with R2DBC can be a big win for high-concurrency REST APIs, but only if it’s used consistently. One thing I learned the hard way is that mixing blocking JDBC calls inside reactive flows completely defeats the purpose, choking event loops and making backpressure useless.
In Quarkus or Micronaut with R2DBC, I now follow a few rules:
- Stay end-to-end reactive in the request path if you choose R2DBC—no blocking call in the middle.
- Limit concurrency with operators like flatMap with a concurrency cap instead of firing unbounded queries.
- Watch connection usage just as carefully as with JDBC; R2DBC pools can still be exhausted.
Here’s a small Micronaut R2DBC-style example sketch to keep the path non-blocking:
import io.micronaut.http.annotation.*;
import reactor.core.publisher.Flux;
@Controller("/customers")
public class CustomerController {
private final CustomerRepository repository; // R2DBC-based
public CustomerController(CustomerRepository repository) {
this.repository = repository;
}
@Get
public Flux list() {
// Entire chain is reactive, no blocking calls here
return repository.findAll()
.map(CustomerDto::fromEntity);
}
}
Once I started treating the database as a first-class performance concern—monitoring pool metrics, tuning timeouts, and choosing JDBC vs R2DBC deliberately—Micronaut and Quarkus REST APIs stopped “mysteriously” slowing down and began to scale predictably with traffic.
Conclusion: Turning Micronaut and Quarkus REST APIs Into Real-World Performers
In my experience, Micronaut and Quarkus rarely fail on their own—the real damage comes from how we use them. Across projects, the same Micronaut and Quarkus REST API mistakes keep showing up: blocking I/O on event loops, poorly tuned thread pools, heavy JSON serialization, overgrown DI graphs, ignoring native-image trade-offs, weak observability, and badly managed database connections.
The good news is that each of these issues is fixable with a bit of discipline: keep I/O off event loops, right-size thread pools, treat JSON and DB access as first-class performance concerns, keep DI lean, deliberately compare JVM and native builds, and bake in metrics, tracing, and load testing from day one. When I’ve applied these practices systematically, Micronaut and Quarkus services not only hit their latency and throughput goals but also started faster and ran cheaper in production.
If you pick one or two problem areas at a time—measure, tune, and repeat—you can steadily turn a “benchmark-fast” prototype into a reliable, high-performance REST API that holds up under real-world traffic.

Hi, I’m Cary Huang — a tech enthusiast based in Canada. I’ve spent years working with complex production systems and open-source software. Through TechBuddies.io, my team and I share practical engineering insights, curate relevant tech news, and recommend useful tools and products to help developers learn and work more effectively.





