Performance and Tuning

This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against.

Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change.

This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Quarkus’s headline numbers — fast startup, low memory footprint — are real, but they are also easy to misapply: a number measured on someone else’s hardware, someone else’s workload, or an older Quarkus release tells you very little about your own application. This page covers what to actually measure and how, how each packaging mode changes the numbers that matter, the handful of tuning knobs (HTTP layer, thread pools, connection pools) worth reaching for, the Quarkus-specific pitfalls that quietly erase the framework’s own advantages, and a direct warning against quoting stale benchmark numbers.

Measuring rather than believing

Four numbers matter for almost every Quarkus deployment decision, and none of them substitute for one another:

Metric Why it matters

Startup to first response

The number a rolling deployment, an autoscaling event, or a scale-to-zero cold start actually pays. Measure wall-clock time from process launch to the first successful response on a real endpoint, not just the "started in Xs" log line — that line reports when Quarkus itself finished initializing, not when the application is actually ready to serve realistic traffic (a lazily-initialized connection pool, for instance, can add real latency to the very first request after that log line prints).

RSS after warm-up

Resident memory after the JVM’s JIT compiler and any lazy initialization have settled, not the number at the instant the process starts — an idle JVM’s RSS creeps upward over its first several minutes as more code paths get JIT-compiled and more classes get loaded. Measuring RSS immediately at startup systematically understates what a long-running container actually consumes.

Throughput and tail latency

Aggregate requests/second hides the number that actually causes incidents: p99/p999 latency under sustained load. A change that improves median latency while worsening tail latency (a larger connection pool that increases contention, for instance) is a net loss for most production SLOs even though an average-latency chart would show improvement.

Container density

How many instances of the application actually fit on a given node once real RSS and CPU requests/limits are set — the number a lower per-instance footprint is ultimately supposed to improve, and the number that translates most directly into infrastructure cost.

How to measure

the Performance Measurement guide is the canonical starting point for methodology — warm-up iterations, load-generation tool choice, and how to avoid the most common benchmarking mistakes (measuring on a laptop under thermal throttling, comparing a debug build against a release build, ignoring JIT warm-up). Beyond that guide’s own tooling recommendations:

  • JDK Flight Recorder (JFR) — -XX:+FlightRecorder -XX:StartFlightRecording=…​ captures allocation, GC, and thread-scheduling detail with low overhead, suitable for recording directly against a near-production JVM instance rather than only a synthetic microbenchmark.

  • -XX:+UseSerialGC as the JVM’s own default under container memory constraints — Quarkus’s own tuned JVM defaults favor the serial collector for small-heap, single-core-ish container workloads specifically because it has the lowest fixed overhead of any collector, trading away the parallel/concurrent collectors' throughput advantage at larger heap sizes. A workload that genuinely needs G1 or a low-pause collector’s characteristics (a larger heap, multiple cores actually available to the container) should override this default explicitly rather than assume it is always the right choice.

  • Container memory limits and JVM ergonomics — a containerized JVM reads its own cgroup memory limit and sizes heap and other memory pools as a fraction of it automatically; a limit set too tight for the workload’s real footprint causes OOMKilled terminations that look like an application bug but are actually a container-limit sizing problem, while a limit set with no real ceiling defeats the density benefit measuring RSS is meant to unlock in the first place.

The effect of each packaging mode

Packaging and JVM Modes covers the mechanics of each JVM package type and native image in depth; from a pure performance standpoint, the ordering that matters is:

  • A native executable (Native Executables with GraalVM/ Mandrel) gives the lowest startup time and lowest RSS of any option, at the cost of a materially longer, more memory-hungry build and the closed-world constraints that page documents.

  • fast-jar (Quarkus’s default JVM packaging) starts and runs lighter than uber-jar or legacy-jar purely because of its layout — more of the classpath resolution work is already done at build time — without any of native image’s build-time cost or reflection constraints.

  • A JLink custom runtime image sits between fast-jar and native image: a smaller base image and somewhat faster startup than a full JRE base image, without native image’s build cost, but without native image’s startup/RSS numbers either.

Choosing among these is a real trade-off between build cost and runtime cost, not a strictly-better ladder — the packaging page's own decision table walks through when each is worth it.

Tuning the HTTP layer, thread pools, and connection pools

Quarkus’s HTTP layer sits on Vert.x’s event-loop/worker-thread model, covered in depth on Reactive Programming and Mutiny and Reactive Routes and the HTTP Layer; the properties below are the actual tuning surface on top of that model, not a restatement of how it works:

# number of Vert.x event-loop threads (defaults to the number of CPU cores)
quarkus.vertx.event-loops-pool-size=4

# the bounded worker-thread pool used for blocking-style REST methods and @Blocking code
quarkus.thread-pool.max-threads=200
quarkus.thread-pool.queue-size=1000

# HTTP-level tuning
quarkus.http.io-threads=4
quarkus.http.limits.max-connections=1000

A larger quarkus.thread-pool.max-threads raises the ceiling on concurrently in-flight blocking requests but also raises worst-case memory (each pooled platform thread carries a real stack) and can increase context-switch overhead past a point that helps — this is a genuine trade-off to load-test, not a number to raise blindly. The connection-pool equivalent for a JDBC datasource is Agroal’s own sizing, covered in depth on Datasources and Transactions (quarkus.datasource.jdbc. min-size/max-size); undersizing it caps throughput under load, oversizing it wastes database-side connection slots and, for many databases, actually reduces throughput past the point the database’s own concurrency handles efficiently.

Avoiding common Quarkus-specific pitfalls

  • Blocking the event loop. The single most common Quarkus-specific performance bug: calling blocking code (a synchronous JDBC call, Thread.sleep, a blocking HTTP client) from a Uni/Multi-returning method or a reactive route without @Blocking/@RunOnVirtualThread. One blocked event-loop thread stalls every other request currently assigned to it — see the cardinal rule on Reactive Programming and Mutiny for the full explanation and how Quarkus’s own BlockedThreadChecker helps catch it in dev mode and tests.

  • Unremovable beans bloating the image. Marking beans @Unremovable (or triggering unremovability via dynamic CDI.current().select(…​) lookups) opts them out of ArC’s dead-bean elimination, covered on CDI and the Programming Model — reaching for it reflexively, rather than only where a bean is genuinely looked up dynamically, quietly grows both JVM RSS and native-image size with beans that are never actually used.

  • Over-broad reflection registration. Registering a whole package or a broad class hierarchy with @RegisterForReflection/ReflectiveClassBuildItem "just in case" — rather than the specific classes actually needed — inflates native-image size and analysis time for no benefit; see Native Executables with GraalVM/Mandrel for the narrower, targeted alternative.

  • Dev Services leaking into production config. Dev Services ( Developer Experience) exist to remove configuration burden in dev mode and tests, not to run in production — a production profile that accidentally leaves a datasource/broker URL unset relies on Dev Services activating where a real, explicitly configured connection was expected, which is at best a startup failure and at worst a silent connection to an ephemeral container that is not the real production dependency at all.

A note on stale benchmark numbers

Any concrete startup-time, RSS, or throughput figure quoted in the two books this reference’s bibliography lists, or in a blog post more than a release or two old, should be treated as stale and not quoted — Quarkus’s own JVM defaults, GraalVM/Mandrel versions, and the underlying JDK all move fast enough that a number measured even a few Quarkus releases ago is no longer representative of a current build. The only trustworthy number for a given application is one measured against the current Quarkus version, on representative hardware, using the methodology in the section above — not a number carried over from someone else’s write-up.