A traditional Java framework does most of its thinking at startup. It scans the classpath for annotations, parses configuration, builds a dependency-injection graph, generates proxies with reflection, and only then serves its first request. That work is repeated every time the process starts, it costs memory for metadata that is never used again, and it is the reason a small service can take seconds to start and hundreds of megabytes to idle.

Quarkus starts from a different question: which of that work could be done once, at build time, and frozen into the artifact? The answer turns out to be most of it. This article explains how Quarkus does that, what it means for the way you write code, how its threading model works, how its development loop and Dev Services behave, and how to package and operate it in production. It assumes you know basic Java and dependency injection; the Native Image details it relies on are covered in the GraalVM deep dive, and the closest point of comparison is Spring Boot.

Advertisement

Doing startup work at build time

Quarkus calls the build-time phase augmentation. When you run ./mvnw package, the Quarkus Maven or Gradle plugin indexes your compiled classes and selected dependencies with Jandex, a fast annotation index, then runs a graph of build steps contributed by every extension on the classpath. Those steps discover beans, REST endpoints, entities and configuration, decide what the application needs, and emit two kinds of output: generated bytecode (bean factories, endpoint invokers, accessors that replace reflection) and recorded initialisation code that will replay the decisions at startup.

Compiled classesapp + depsJandex indexannotations, typesBuild steps from extensionsconsume and produce BuildItemsGeneratedbytecodeRecordedinit codefast-jartarget/quarkus-appNative executableGraalVM or MandrelBuild-time configfrozen into artifactread at buildStartup replays STATIC_INIT then RUNTIME_INIT recordings; no classpath scan, no reflection-driven wiringRuntime config (URLs, credentials, ports) is still read when the process starts
Augmentation. Extensions' build steps read the Jandex index and build-time configuration, then generate bytecode and record initialisation, which is packaged as a JVM fast-jar or compiled into a native executable.

The consequence for you as a developer is that many errors move from startup to build: an unsatisfied injection point, an ambiguous bean or an invalid configuration fails mvn package, not the first deployment. The cost is a set of rules about what can change after the build.

Extensions, build items and recorders

Everything in Quarkus, including REST, Hibernate and Kafka support, is an extension, and every extension has two modules. The runtime module is what your application ships. The deployment module runs only during augmentation and contains @BuildStep methods. Build steps communicate through build items: a step declares the items it consumes as parameters and the items it produces as return values or through a BuildProducer, and Quarkus orders the steps from those dependencies. A SimpleBuildItem has exactly one producer; a MultiBuildItem collects contributions from many. Here is the core of a small extension that finds classes annotated @Audited and registers them at startup:

// deployment module
public final class AuditedBuildItem extends MultiBuildItem {
    private final String className;
    public AuditedBuildItem(String className) { this.className = className; }
    public String className() { return className; }
}

public class AuditProcessor {
    private static final DotName AUDITED = DotName.createSimple("com.example.audit.Audited");

    @BuildStep
    FeatureBuildItem feature() { return new FeatureBuildItem("audit"); }

    @BuildStep
    void scan(CombinedIndexBuildItem index, BuildProducer<AuditedBuildItem> out) {
        for (AnnotationInstance ai : index.getIndex().getAnnotations(AUDITED)) {
            out.produce(new AuditedBuildItem(ai.target().asClass().name().toString()));
        }
    }

    @BuildStep
    @Record(ExecutionTime.STATIC_INIT)
    void register(AuditRecorder recorder, List<AuditedBuildItem> items) {
        recorder.register(items.stream().map(AuditedBuildItem::className).toList());
    }
}

// runtime module
@Recorder
public class AuditRecorder {
    public void register(List<String> classNames) { AuditRegistry.init(classNames); }
}

The recorder call does not run during the build. Quarkus records the invocation and its arguments as bytecode, and replays it when the application starts. STATIC_INIT recordings run in a static initialiser, which in a native build means at image build time, so their results are baked into the image heap; RUNTIME_INIT recordings run on every start and are where anything depending on runtime configuration, sockets or threads must go. Putting socket or thread creation in STATIC_INIT is a classic native-build failure.

Advertisement

ArC and configuration

Quarkus implements Jakarta CDI with ArC, a container that resolves the whole bean graph during augmentation and generates plain factory classes instead of using reflection. Three practical effects follow. Beans that nothing injects or looks up are removed by default, which shrinks the artifact but surprises code that looks beans up dynamically; annotate those @Unremovable. Injection into private fields works only by falling back to reflection, which in a native build needs registration and grows the image, so the guides recommend package-private fields or constructor injection. And dependency classes are only discovered as beans if they are indexed, which means the JAR ships a Jandex index or you list it with quarkus.index-dependency properties; a missing index shows up as an unsatisfied dependency at build time.

Configuration is read by SmallRye Config from application.properties, environment variables (QUARKUS_HTTP_PORT maps to quarkus.http.port) and system properties, with profile prefixes %dev., %test. and %prod.. The crucial distinction is that some properties are build-time fixed: they shape what augmentation generates, so changing them at runtime has no effect, and Quarkus logs a warning if you try. The database kind is one; a JDBC URL is not. The extension guides mark each property, and the rule of thumb is that anything deciding which code exists is build time, while anything deciding where it connects is runtime. Typed configuration groups use @ConfigMapping:

@ConfigMapping(prefix = "orders")
public interface OrdersConfig {
    @WithDefault("50")
    int maxPageSize();
    Duration paymentTimeout();   // orders.payment-timeout=2s
}

The threading model

Quarkus runs on Eclipse Vert.x, so a small number of event-loop (I/O) threads accept connections and drive non-blocking work, and a worker pool runs code that may block. Blocking an event loop stalls every connection it serves; Vert.x's blocked-thread checker logs warnings when an event-loop task runs too long. Quarkus REST, formerly RESTEasy Reactive (renamed in Quarkus 3.9, artifact quarkus-rest), chooses a thread from the method signature: methods returning Uni, Multi or CompletionStage run on the event loop, and methods with ordinary return types run on a worker thread. @Blocking and @NonBlocking override the default, and on Java 21 or later @RunOnVirtualThread dispatches to a virtual thread, which the virtual threads article explains in depth.

@Path("/orders")
public class OrderResource {
    @Inject StatusClient statusClient;

    @GET @Path("/{id}")
    public Order get(@PathParam("id") long id) {          // worker thread: blocking signature
        return Order.findById(id);
    }

    @GET @Path("/{id}/status")
    public Uni<Status> status(@PathParam("id") long id) { // event loop: must not block
        return statusClient.fetch(id);
    }

    @POST @RunOnVirtualThread @Transactional
    public Response create(NewOrder in) {                 // virtual thread: blocking JDBC is fine
        Order o = Order.from(in);
        o.persist();
        return Response.status(201).entity(o).build();
    }
}

The most common Quarkus production bug is a reactive signature wrapping blocking code: a method returns Uni but calls JDBC or a blocking HTTP client inside, so it runs on the event loop and the whole service slows under load. Either keep the stack reactive end to end or return a plain type. Virtual threads carry their own caveat: code that pins the carrier thread or holds limited resources such as connection-pool slots still needs a bound.

Dev mode, Dev Services and tests

Development mode, started with quarkus dev or ./mvnw quarkus:dev, keeps the JVM running and re-augments changed code when the next request arrives, so edits appear without a restart. Continuous testing reruns affected tests in the background, and the Dev UI at /q/dev-ui shows beans, configuration and extension tools.

Dev Services remove most local setup. If an extension needs a backing service and you have not configured one, for example a PostgreSQL datasource with no JDBC URL in dev or test, Quarkus starts a container through Testcontainers and wires its URL and credentials into configuration. This needs a working Docker or Podman environment, and it never happens in the prod profile. The mechanics underneath are those described in the Testcontainers article.

Tests use @QuarkusTest, which starts the application once in the test JVM and shares it across test classes, so tests are fast but share state; @TestProfile forces a restart with different configuration. @QuarkusIntegrationTest runs the same style of test against the packaged artifact, which is the only way to catch problems that appear only in the fast-jar or the native executable.

Worked example: an orders service

A small orders service exercises the whole stack. Create it with the Quarkus CLI and the extensions it needs:

quarkus create app com.example:orders \
  --extension=rest-jackson,hibernate-orm-panache,jdbc-postgresql,flyway,smallrye-health,micrometer-registry-prometheus
@Entity
@Table(name = "orders")        // ORDER is a reserved SQL word; the default table name would break
public class Order extends PanacheEntity {
    public String customer;
    public BigDecimal total;
    public String status;

    static Order from(NewOrder in) {
        Order o = new Order();
        o.customer = in.customer();
        o.total = in.total();
        o.status = "NEW";
        return o;
    }
}
# application.properties
# db-kind is build-time fixed; the URL below is runtime
quarkus.datasource.db-kind=postgresql
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://db:5432/orders
%prod.quarkus.datasource.username=orders
%prod.quarkus.datasource.password=${DB_PASSWORD}
quarkus.flyway.migrate-at-start=true
quarkus.shutdown.timeout=20s
orders.payment-timeout=2s

Follow a request. In dev, no URL is configured, so Dev Services starts PostgreSQL and Flyway applies src/main/resources/db/migration at start. A POST /orders arrives on an event loop; because create is marked @RunOnVirtualThread, Quarkus REST hands it to a virtual thread, Jackson deserialises NewOrder, the interceptor generated for @Transactional opens a transaction, Panache persists the entity through Hibernate and Agroal's connection pool, and the response is written back through Vert.x. In prod the same artifact reads the URL and password from the environment, exposes /q/health/live and /q/health/ready (the readiness check includes the datasource) and /q/metrics in Prometheus format, and on SIGTERM stops accepting requests and waits up to the shutdown timeout for in-flight work.

Packaging: fast-jar or native

The default package type is the fast-jar: target/quarkus-app/ with quarkus-run.jar and separate directories for libraries, the application and generated code, run with java -jar target/quarkus-app/quarkus-run.jar. The layout lets container images put the rarely changing lib/ directory in its own layer. quarkus.package.jar.type=uber-jar produces a single JAR when a platform requires one. ./mvnw install -Dnative builds a native executable, and adding -Dquarkus.native.container-build=true runs the native compiler inside a builder container so CI needs no local GraalVM.

ChooseWhenWatch for
JVM fast-jarLong-running services, peak throughput matters, rich tooling and profilers neededHigher memory and slower start than native; still much faster than a runtime-scanning framework
Native executableScale-to-zero, serverless, CLIs, dense deployments where startup and RSS dominate costLong, memory-hungry builds; no JIT so peak throughput can be lower; reflection needs @RegisterForReflection or extension support

Do not take startup or memory numbers from blogs, including this one, which deliberately quotes none; measure your own service in both modes on your target hardware, under your real traffic.

Operating it: failure modes and versions

SymptomLikely causeFix
Throughput collapses under load, event-loop blocked warningsBlocking call inside a reactive methodReturn a plain type, add @Blocking, or use a reactive client
Config change has no effectProperty is build-time fixedRebuild; move the decision to a runtime property
Bean missing at runtime lookupRemoved as unused@Unremovable or inject it somewhere
Works on JVM, fails native with ClassNotFound or missing fieldReflection or resource not registered@RegisterForReflection, resource include properties, or a library with a Quarkus extension
Tests hang locallyDev Services cannot reach DockerFix the container runtime, or configure explicit test URLs
Readiness flaps during deploysPool exhausted by slow queriesSize the Agroal pool, add timeouts, alert on pool metrics

Quarkus ships a minor release every four to six weeks and a long-term-support release every six months, supported for twelve months. As of 2026-10-03, 3.40 is the newest LTS, released in late September 2026, and 3.33 LTS is supported until March 2027; check the official releases page before choosing, and use quarkus update, which applies OpenRewrite recipes, to move between versions.

What to do next

  1. Generate a service with the Quarkus CLI and run it in dev mode with Dev Services, so you see augmentation and live reload working.
  2. List your configuration and mark each property build-time or runtime; move connection details into %prod. environment-backed values.
  3. Audit every endpoint returning Uni or CompletionStage for blocking calls; convert to plain or virtual-thread methods where the stack is blocking.
  4. Add @QuarkusIntegrationTest coverage that runs against the packaged artifact in CI.
  5. Wire liveness, readiness and metrics into your orchestrator and dashboards, and set a shutdown timeout shorter than its grace period.
  6. Build a native executable with the container build, then measure startup, memory and throughput against the fast-jar before choosing.
  7. Pin to an LTS stream and schedule quarkus update runs as part of routine maintenance.
Key takeaway: Quarkus indexes your classes at build time and lets extension build steps generate bytecode and record initialisation, so startup replays decisions instead of scanning and reflecting. That moves wiring errors to the build, but makes some configuration build-time fixed. Requests run on Vert.x event loops, worker threads or virtual threads depending on signature and annotations, so never block inside reactive methods. Use Dev Services and integration tests against the packaged artifact, measure fast-jar against native, and stay on an LTS stream.