Quarkus JAR Tree Shaking: Trim Dead Weight From JVM Builds
Measure fast-jar output, read JarTreeShaker summaries, and learn where smaller quarkus-app artifacts are real.
./mvnw package finishes, you open target/quarkus-app/lib/main/, and the totals are honest: this is what the JVM will load and what your registry layer will push. A lot of those classes will never run. They are along for the ride because something on the classpath once referenced them, or because a fat dependency carries entire subsystems you never touch.
Native image already does aggressive dead-code elimination; JVM mode did not have the same story at the JAR layer until recently. In Quarkus 3.35 the project shipped an experimental bytecode reachability pass that can remove unreachable dependency classes during packaging. The full behavior, limits, and knobs are documented in the Tree-shaking JAR dependencies guide.
What tree shaking means in this stack
The JavaScript world stole the word “tree shaking” from compilers; in Quarkus it means build-time reachability over dependency bytecode, not wishful thinking about pom.xml. The guide walks the algorithm in detail (roots, BFS, dynamic ClassLoader edge cases, exclusions). The practical contract for this article is smaller:
Config —
quarkus.package.jar.tree-shake.mode=classesenables it;noneis the default.Packaging — works for
fast-jar,uber-jar,legacy-jar, andaot-jar. It does not apply tomutable-jar(re-augmentation needs complete dependency bytecode).Scope — only runtime dependency classes are candidates. Your application classes are never removed. Non-class resources mostly stay; a dependency can remain in the layout even if every class file was stripped, because resources might still matter.
If you prefer a picture to a reachability lecture, this is the package-time path we are talking about:
One easy wrong turn here is to confuse tree shaking with quarkus.index-dependency.*. That property is about Jandex indexing, which is how Quarkus builds its view of third-party JARs for CDI, JAX-RS scanning, and similar build-time work.
Tree shaking is a different step later in packaging. It follows bytecode reachability, plus explicit exclusions such as quarkus.package.jar.tree-shake.excluded-artifacts, so indexing alone will not rescue a class the shaker decided is dead.
If something still blows up at runtime with ClassNotFoundException, the guide’s playbook is: confirm it goes away with mode=none, then exclude the artifact or tighten your dynamic-loading story before you reopen the bug tracker.
What we build
Helios is a fictional fleet platform; we only need a slice that is credible on a résumé and small enough to read in one sitting.
You end up with a Maven aggregator folder and two independent Quarkus services:
helios-analytics— accepts a CSV payload onPOST /importusing Apache Commons CSV as aCSVParser, count-only JSON response ({"imported": n}).helios-api—GET /exportreturns plain text CSV usingCSVPrinteronly.
Both depend on the same commons-csv coordinate so you can compare how little the library jar itself moves while the overall lib/ still drops when Quarkus peels framework fat. Analysis is per application module at packaging time (Tree-shaking JAR dependencies) — identical classpaths get identical summaries; differing code would diverge.
What you need
This demo is small, but the versions still matter because we are comparing packaging output, not debugging tool drift. If you only want the finished code, grab it from my Github repository.
JDK 21 (matches the demo
pom.xml; bump if you standardize elsewhere).Network for the first Maven download.
Quarkus platform 3.35.1 on the classpath (pinned in both
pom.xmlfiles).
Build the aggregator shell
Pick a workspace root — this tutorial uses helios-tree-shaking/ as the directory name.
mkdir -p helios-tree-shaking
cd helios-tree-shakingScaffold helios-analytics
Still inside helios-tree-shaking/, generate the first application (Maven wrapper included):
mvn -q io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.helios \
-DprojectArtifactId=helios-analytics \
-Dextensions='rest-jackson' \
-DclassName="org.helios.analytics.TelemetryImportResource" \
-Dpath="/import"Open helios-analytics/pom.xml and check the few things that matter:
packagingisquarkus.quarkus.platform.versionis3.35.1(or newer 3.35.x you have validated).Add
commons-csvexplicitly (version1.12.0) alongsidequarkus-rest-jackson.
Use this dependency block inside <dependencies> (keep the BOM import dependencyManagement the generator produced; we are only adding what the demo needs):
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.12.0</version>
</dependency>I keep both apps on the same Quarkus extension set on purpose. That way the before/after numbers mostly reflect usage differences inside the shared dependency graph instead of two unrelated application shapes.
Configure HTTP so this service does not fight the sibling module during local runs:
Replace src/main/resources/application.properties with:
quarkus.application.name=helios-analytics
quarkus.http.port=8080
# Baseline measurements use `none`; switch to `classes` when you enable tree shaking.
quarkus.package.jar.tree-shake.mode=noneCreate src/test/resources/application.properties:
quarkus.http.test-port=8081Add the record next to your resource package — src/main/java/org/helios/analytics/TelemetryRecord.java:
package org.helios.analytics;
import java.time.Instant;
public record TelemetryRecord(String deviceId, double lat, double lon, Instant timestamp) {
}Replace the generated JAX-RS class with src/main/java/org/helios/analytics/TelemetryImportResource.java:
package org.helios.analytics;
import java.io.IOException;
import java.io.StringReader;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.jboss.logging.Logger;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/import")
public class TelemetryImportResource {
private static final Logger LOG = Logger.getLogger(TelemetryImportResource.class);
@POST
@Consumes(MediaType.TEXT_PLAIN)
@Produces(MediaType.APPLICATION_JSON)
public Response importCsv(String csvPayload) throws IOException {
LOG.debug("Importing telemetry CSV payload");
CSVFormat format = CSVFormat.DEFAULT.builder()
.setHeader()
.setSkipHeaderRecord(true)
.build();
try (CSVParser parser = format.parse(new StringReader(csvPayload))) {
List<TelemetryRecord> records = parser.stream()
.map(this::toRecord)
.toList();
return Response.ok(Map.of("imported", records.size())).build();
}
}
private TelemetryRecord toRecord(CSVRecord row) {
return new TelemetryRecord(
row.get("deviceId"),
Double.parseDouble(row.get("lat")),
Double.parseDouble(row.get("lon")),
Instant.parse(row.get("timestamp")));
}
}Change src/test/java/org/helios/analytics/TelemetryImportResourceTest.java:
package org.helios.analytics;
import static org.hamcrest.Matchers.is;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
@QuarkusTest
class TelemetryImportResourceTest {
@Test
void importParsesRows() {
String csv = ""
+ "deviceId,lat,lon,timestamp\n"
+ "truck-1,53.5511,10.0055,2025-04-02T09:15:30Z\n"
+ "truck-2,48.1351,11.5820,2025-04-02T09:16:01Z\n";
RestAssured.given()
.contentType(ContentType.TEXT)
.body(csv)
.when()
.post("/import")
.then()
.statusCode(200)
.body("imported", is(2));
}
}Smoke it:
cd helios-analytics
./mvnw testScaffold helios-api
Return to helios-tree-shaking/:
cd ..Generate the sibling service:
mvn -q io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.helios \
-DprojectArtifactId=helios-api \
-Dextensions='rest-jackson' \
-DclassName="org.helios.api.TelemetryExportResource" \
-Dpath="/export"Apply the same BOM + commons-csv + Quarkus dependencies pattern you used for analytics (matching versions). Use a different HTTP port and test port so both apps can coexist on a laptop.
src/main/resources/application.properties
quarkus.application.name=helios-api
quarkus.http.port=8090
quarkus.package.jar.tree-shake.mode=nonesrc/test/resources/application.properties
quarkus.http.test-port=8091src/main/java/org/helios/api/TelemetryExportResource.java
package org.helios.api;
import java.io.IOException;
import java.io.StringWriter;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.jboss.logging.Logger;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/export")
public class TelemetryExportResource {
private static final Logger LOG = Logger.getLogger(TelemetryExportResource.class);
@GET
@Produces(MediaType.TEXT_PLAIN)
public String exportSampleCsv() throws IOException {
LOG.debug("Generating sample telemetry CSV");
CSVFormat format = CSVFormat.DEFAULT.builder()
.setHeader("deviceId", "lat", "lon", "timestamp")
.build();
StringWriter buffer = new StringWriter();
try (CSVPrinter printer = new CSVPrinter(buffer, format)) {
printer.printRecord("sample-7", "52.5200", "13.4050", "2025-05-06T08:00:00Z");
}
return buffer.toString();
}
}
src/test/java/org/helios/api/TelemetryExportResourceTest.java
package org.helios.api;
import static org.hamcrest.Matchers.containsString;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
@QuarkusTest
class TelemetryExportResourceTest {
@Test
void exportReturnsCsvBody() {
RestAssured.given()
.when()
.get("/export")
.then()
.statusCode(200)
.body(containsString("deviceId"))
.body(containsString("sample-7"));
}
}cd helios-api
./mvnw testGive Both Modules a Home
Create pom.xml at the aggregator root (helios-tree-shaking/pom.xml):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.helios</groupId>
<artifactId>helios-tree-shaking</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>Helios fleet demo (aggregator)</name>
<modules>
<module>helios-analytics</module>
<module>helios-api</module>
</modules>
</project>You will populate the modules in the next two sections. Maven only needs those <module> lines once matching directories exist.
From the aggregator you can sanity-check both modules:
cd ..
mvn -pl helios-analytics,helios-api test
(Either ./mvnw inside each leaf or your system Maven at the aggregator — the generator gave each leaf its own wrapper, which keeps the demos self-contained.)
Measure before you shake anything
Go into helios-analytics/ first. Produce a fast-jar layout without tree shaking (explicit none keeps the story consistent even after you tweak defaults):
./mvnw package -DskipTestsTotal dependency library weight (lib/main is the interesting directory for fast-jar layouts):
du -sh target/quarkus-app/lib/mainCommons CSV footprint inside that directory — filenames follow group-artifact-version.jar:
ls -lh target/quarkus-app/lib/main/org.apache.commons.commons-csv-1.12.0.jar
jar tf target/quarkus-app/lib/main/org.apache.commons.commons-csv-1.12.0.jar | grep '\.class$' | wc -lOn my machine macOS aarch64 with Quarkus 3.35.1, I got ≈ 18 MB under target/quarkus-app/lib/main/ for helios-analytics, and commons-csv still carried 20 .class files inside ≈ 55 KB. Your classpath width will move those digits a bit; the important part is having a baseline you can compare against after the rebuild.
Repeat the du / ls / jar tf … | wc trio for helios-api/ so you trust the aggregator story with your own numbers.
Optional curiosity pass — classify every lib/main JAR quickly (warning: noisy output):
find target/quarkus-app/lib/main -name '*.jar' -exec sh -c \
'printf "%s: " "$1"; jar tf "$1" | grep -c "\.class$" || true' sh {} \;Enable tree shaking
Option A — edit application.properties
In each module:
quarkus.package.jar.tree-shake.mode=classesRebuild with ./mvnw package -DskipTests.
Option B — one-shot Maven invocation
Keeps defaults on disk untouched while you iterate:
./mvnw package -DskipTests -Dquarkus.package.jar.tree-shake.mode=classesReading the analyzer output
Tree shaking logs a single INFO line from io.quarkus.deployment.pkg.steps.JarTreeShaker. Example copied from helios-analytics on the reference machine:
[INFO] [io.quarkus.deployment.pkg.steps.JarTreeShaker] Tree-shaking removed 2757 unreachable classes from 90 dependencies, saving 7.2 MB (25.1%)helios-api logged the same totals here because both apps carry the identical extension set and dependency graph; divergence shows up once your services stop being clones.
Per the guide’s troubleshooting section, you can raise JarTreeShaker to DEBUG for per-dependency detail when something looks wrong:
quarkus.log.category."io.quarkus.deployment.pkg.steps.JarTreeShaker".level=DEBUGI did not paste multi-screen debug dumps — if you enable it locally, skim for the dependency that violates your intuition before you freeze an exclusion list.
After the shaker runs — rerun the rulers
Rebuild with mode=classes (either property or -D). On the tutorial reference build:
du -sh target/quarkus-app/lib/main
jar tf target/quarkus-app/lib/main/org.apache.commons.commons-csv-1.12.0.jar | grep '\.class$' | wc -lDropped to ≈ 14 MB for both modules in du -sh terms (about four megabytes slimmer at the filesystem layer). JarTreeShaker simultaneously reported saving 7.2 MB (25.1%) — that aggregate counts class file payload carved out when Quarkus rewrote dependency JARs, so du and the saving line are related but not the same accountant.
commons-csv itself only shed one .class file in this constrained example (20 → 19). That is intentional pedagogy: the demo dependency is tiny; the CSVParser‑heavy versus CSVPrinter‑only contrast will show up in which commons classes remain far more than in raw jar size. The headline savings came from shaving Netty stacks, ancillary Jackson bits, Vert.x paths, and the rest of Quarkus’s runtime graph that your imports never exercised.
Regression guard:
./mvnw test -Dquarkus.package.jar.tree-shake.mode=classesIf this fails while plain none succeeds, grab the ClassNotFoundException, locate the offending coordinate, and start with quarkus.package.jar.tree-shake.excluded-artifacts as documented upstream. If everything still points to analyzer blind spots, then it is time to open the upstream issue.
Another escape hatch shows up when you are doing the dynamic lookup yourself: @RegisterForReflection for the cases Quarkus cannot infer. If you build type names from strings or wander into service discovery paths the shaker never observed, exclusions still exist. They are blunt, but blunt is often fine when the alternative is a broken build and a heroic explanation.
Native mode already performs its own dead-code story; JVM fast-jar container pulls are where this knob tends to sting.
Optional — feel the packaging tax
Cold builds pay for reachability passes. Rough comparison on the demo machine (helios-analytics):
/usr/bin/time -p ./mvnw package -DskipTests -Dquarkus.package.jar.tree-shake.mode=none
/usr/bin/time -p ./mvnw package -DskipTests -Dquarkus.package.jar.tree-shake.mode=classesYour wall times will float with caches, Maven -T, and CPU throttle; CI is where budgeting gets serious.
Close the loop
The point of this demo is not that commons-csv suddenly becomes tiny. The point is that a plain JVM Quarkus build can stop shipping a surprising amount of dead dependency bytecode. In this setup, the two apps dropped from about 18 MB to 14 MB, the shaker removed 2757 unreachable classes, and the tests still passed after the rebuild.
That is enough for me to try it on any fast-jar service that is about to become a container image. Measure the baseline, rebuild with classes, rerun the tests, and keep excluded-artifacts nearby for the dynamic corners.



