/ Performance Engineer
← All posts
Jul 19, 2026 · 5 min read · Build performance

The fastest way to compress a file is to not compress it

A fat JAR build spends most of its CPU decompressing files and compressing them back into... the exact same bytes. Here is the ZIP-format trick shaded-jar uses to skip that work entirely — explained with moving boxes.

Building a fat JAR sounds mundane: zip up your .class files, merge in the dependencies, done. But it usually sits on the critical path — a developer, or an AI coding agent, waiting to find out whether a fix actually works. So I spent a few days digging into how it’s actually built, mostly to answer one question: is the popular tooling here already close to as fast as it could be?

The tool of choice for this job is the com.gradleup.shadow Gradle plugin (formerly com.github.johnrengelman.shadow). It’s open source and widely used, so I expected it to already be fast. It isn’t — and the reason why turned out to be the interesting part.

What exactly is a fat or shaded JAR

A JAR is just a ZIP file holding your .class files plus a MANIFEST.MF that describes the project. Nothing fancy. A fat JAR is your compiled classes plus every dependency JAR, merged into one big JAR. If your project has zero dependencies, a fat JAR is just… a JAR.

So why bother with a shaded JAR on top of that? Because dependency trees collide. Say library A depends on B-1.0.0, and library C depends on B-1.2.0. Pack both into a fat JAR and you end up with two copies of the same classes under the same package names — the classloader won’t know which one to use.

Shading fixes this by renaming and relocating packages so the two copies never collide. It can also strip unused code and apply other transformations. With no transformations applied, a shaded JAR is just a fat JAR again.

What takes so long

Imagine you’re moving to a new apartment. Everything you own is already packed in sealed, labeled boxes. The movers arrive, and here’s their process: open every box, take everything out, look at it, put it all back, seal a brand-new identical box, and carry that one to the truck.

You’d fire them on the spot. Yet this is exactly what most fat-JAR tooling does with your dependencies, on every single build.

This post is about one mechanism inside shaded-jar — I call it verbatim copy — and why “just carry the sealed box” is both possible and surprisingly rare.

Sixty seconds on ZIP

Two properties of ZIP make everything below work:

  1. Every file inside is compressed independently. Unlike tar.gz, which squashes the whole archive into one stream, a ZIP compresses each entry on its own (usually with an algorithm called DEFLATE). Each entry is its own sealed box.
  2. There’s a table of contents at the end — the central directory. For every entry it stores the name, where its bytes start, the compressed and uncompressed sizes, and a CRC-32 checksum of the content. A complete manifest of every box, without opening any of them.

What a fat JAR build actually does

The input to a fat-JAR build is a handful of freshly compiled .class files, plus megabytes of dependencies that are already compressed — every library you pull from Maven Central ships as a ZIP full of already-DEFLATE’d entries.

The standard approach (Gradle’s Jar task, the Shadow plugin) merges them like this: for each entry in each dependency, decompress it into raw bytes, then compress those bytes again into the output JAR.

Unpack the box, repack the identical box.

The repacking is the expensive half. DEFLATE compression runs at tens of MB/s per core; decompression is several times faster. So most of the CPU your build burns goes into re-creating compressed bytes that are, byte for byte, exactly what was already sitting in the source JAR.

The trick

Since every entry is its own sealed box, and the central directory tells us exactly where each box starts and how big it is, we can do this instead:

Copy the compressed bytes, untouched, straight from the source JAR into the output JAR.

No decompression, no recompression — just moving bytes, which your machine does at gigabytes per second instead of tens of megabytes. And it gets better:

  • The checksum is free. A ZIP writer normally has to compute a CRC-32 of each entry’s content. But the source JAR’s central directory already stores it, and the content hasn’t changed — so we copy the checksum too.
  • Renaming is free. An entry’s name lives in the ZIP metadata, not inside the compressed bytes. So even when shading renames a resource’s path, the sealed box travels as-is; only the label changes.

Back-of-the-envelope

Some deliberately rough numbers to build intuition. Take a realistic mid-size service: ~20,000 entries, ~30 MiB of compressed dependencies, maybe ~80 MiB uncompressed.

  • Recompress everything: ~80 MiB through DEFLATE at ~40 MB/s ≈ 2 seconds of pure CPU, before the build does anything else.
  • Verbatim copy: ~30 MiB of memcpy-style I/O ≈ tens of milliseconds.

Two orders of magnitude, on the dominant cost of the task — not by compressing faster, but by noticing the work doesn’t need to happen at all. (Real-world results are naturally messier; measured numbers live in the benchmarks.)

The fine print

If this trick always worked, everyone would already be doing it. Verbatim copy is only safe when an entry’s content is guaranteed not to change, so shaded-jar checks eligibility per entry:

  • Plain fat JAR (no relocation rules): essentially everything is eligible. This is the best case.
  • Shaded JAR: every .class file must still go through bytecode rewriting — even classes whose own package isn’t being renamed — because their bytecode may reference a class that is. Miss that and you ship a JAR that throws ClassNotFoundException at runtime. Non-class files (resources, configs) stay eligible: relocation only ever renames their path, never their bytes.
  • Files that get merged, not copied: META-INF/services/*, Spring’s spring.factories and friends, log4j2’s plugin cache. Several dependencies contribute their own copy, and the output needs the combined result — so their content changes by definition.
  • Anything unusual falls back. An entry stored uncompressed in the source, an oversized (Zip64) archive the cheap index can’t parse, a malformed header — that entry (or the whole JAR) quietly takes the slow decompress/recompress path. Verbatim copy is an opportunistic fast path, never a correctness gamble.

The takeaway

The dependencies in your fat JAR were compressed once, by the library’s author, possibly years ago. There’s no reason to redo that work on your CI at 2 p.m. today — the ZIP format has always carried everything needed to skip it: independent entries, known offsets, stored checksums.

Verbatim copy is one of a few mechanisms that make shaded-jar fast (the biggest one — packing entries in parallel across Gradle’s worker pool — deserves its own post). But it’s my favorite kind of optimization: the fastest way to do expensive work is to prove you don’t have to do it.

ŁJ
Łukasz Jarocki
Performance Engineer