Skip to content

From Pure JVM to libvips: Benchmarking Image Processing in bluetape4k

Editorial illustration of an image processing pipeline moving from JVM pixels to native acceleration
An image backend decision changes CPU time, memory pressure, deployment shape, and operational risk together.

bluetape4k-image provides two image-processing paths: a pure-JVM scrimage path and a native high-throughput path backed by libvips.

Neither path is always the right answer. scrimage is simple to deploy and has no native dependency. For high-throughput thumbnail, resize, and encode workloads, libvips’ demand-driven pipeline can be much more efficient.

This post uses the images-benchmark results to answer three questions:

  • How large is the gap between the pure-JVM baseline and the native pipeline?
  • How should we interpret Java 21 JNI and Java 25 FFM backends?
  • Where does this difference matter in an actual service workflow?
Benchmark summary comparing scrimage pure JVM and libvips Java 25 FFM image processing
Benchmark results for scrimage vs libvips, Java 21 JNI host limits, Java 25 FFM measurements, and a workshop derivative pipeline example.

The benchmarks live in the images-benchmark module of bluetape4k-image. The Gradle task is the benchmarkBenchmark task generated by kotlinx-benchmark.

Terminal window
./gradlew :bluetape4k-images-benchmark:benchmarkBenchmark \
-Pvips.impl=java25 --console=plain

The metric is JMH AverageTime, measured in ms/op. Lower is better.

The macOS Java 25 results used in this post are recorded in these reports:

The first thing to look at is not the number, but the input image. Older headline numbers were measured with a single-color synthetic fallback. That is useful for smoke checks, but it does not represent real codec work such as texture, noise, and chroma variation.

This run copied natural photo fixtures from bluetape4k-image/images/src/test/resources/images/ into images-benchmark/src/main/resources/bench/, committed them as classpath resources, and reran the same benchmarks.

cafe.jpglandscape.jpg
Cafe benchmark inputLandscape benchmark input
4032x3024 JPEG, 2.9 MiB4032x3024 JPEG, 3.4 MiB

These numbers are still not universal image laws. They are results for two natural-photo JPEGs. Document scans, flat graphics, animated images, and small UI assets can have different decode/resize/encode cost shapes. Still, this is much closer to real photo-pipeline decisions than the single-color fallback.

The resize benchmark processes the cafe and landscape natural photo fixtures in the same way.

@Benchmark
fun scrimage_scaleTo(state: VipsBenchmarkState, bh: Blackhole) {
val resized = BenchmarkImageSets.naturalPhoto(state.imageName)
.scaleTo(targetWidth, targetHeight)
bh.consume(resized)
}
@Benchmark
fun vips_resize(state: VipsBenchmarkState, bh: Blackhole) {
if (!state.vipsAvailable) {
bh.consume(null)
return
}
state.createVipsImage(state.photo4kJpegBytes).use { img ->
val resized = img.resize(targetWidth, targetHeight)
bh.consume(resized)
}
}

Full source: ImageResizeBenchmark.kt

Resize is where the difference is most dramatic.

WorkloadInputscrimagelibvips Java 25 FFMResult
4K resize to 1920x1080cafe114.89 ms/op0.257 ms/op446x faster
4K resize to 1920x1080landscape115.64 ms/op0.244 ms/op473x faster
JPEG encodecafe137.95 ms/op58.35 ms/op2.4x faster
JPEG encodelandscape144.96 ms/op46.75 ms/op3.1x faster
PNG encodecafe884.10 ms/op585.29 ms/op1.5x faster
PNG encodelandscape989.37 ms/op546.39 ms/op1.8x faster

scrimage works inside the JVM around BufferedImage. That is portable and easy to understand, but large images expose memory allocation and pixel-processing costs quickly.

libvips uses a lazy, demand-driven pipeline. For operations such as resize, where only the output pixels and their source regions matter, that structural difference is huge.

The encode benchmark follows the same shape. The pure-JVM path uses a scrimage writer, while the native path calls VipsImage.toBytes().

@Benchmark
fun scrimage_encodeJpeg(state: VipsBenchmarkState, bh: Blackhole) {
val bytes = BenchmarkImageSets.naturalPhoto(state.imageName).bytes(JPEG_WRITER)
bh.consume(bytes)
}
@Benchmark
fun vips_encodeJpeg(state: VipsBenchmarkState, bh: Blackhole) {
if (!state.vipsAvailable) {
bh.consume(null)
return
}
state.createVipsImage(state.photo4kJpegBytes).use { img ->
val bytes = img.toBytes(VipsImageFormat.JPEG)
bh.consume(bytes)
}
}

Full source: ImageEncodeBenchmark.kt

Encode is not as dramatic as resize. JPEG is about 2.4-3.1x faster, and PNG is about 1.5-1.8x faster. That still matters when one upload creates several derivatives.

bluetape4k-image provides two native backends.

BackendRuntimeRole
images-vips-java21JVips / JNINative backend for Java 21 environments
images-vips-java25FFM / PanamaRecommended high-performance backend for Java 25 environments

In this macOS arm64 run, the Java 21 JNI lane is marked N/A. That does not mean Java 21 JNI is slow. It means the bundled JVips dylib on this host was x86_64 while the JVM was arm64, so we could not produce a native measurement on the same machine.

The conclusion is therefore not “Java 21 JNI is slow.” The more accurate conclusion is:

  • Java 25 FFM is the measured high-performance path on this host.
  • Java 21 JNI should be measured on Linux CI or another architecture-compatible host.
  • A chart should not mix fresh macOS Java 25 results with old Linux Java 21 numbers and present them as a current apples-to-apples comparison.

Backend selection is handled reflectively by VipsBenchmarkState based on the runtime classpath.

Full source: VipsBenchmarkState.kt

Benchmark numbers alone do not show where the change applies in a real system. That is why bluetape4k-workshop includes a production-style example.

Example: image-processing-advanced-workflow

The example is a Spring Boot 4 upload workflow.

  1. Validate file size, content type, and magic bytes.
  2. Store the original image object.
  3. Generate WebP derivatives with the Java 25 libvips backend.
  4. Store the original and variants in S3 or local storage.
  5. Return public URLs and processing metadata.

The actual derivative generation point is short:

return suspendFfmVipsImageOf(bytes).use { image ->
image.thumbnail(variant.maxDimension).use { thumbnail ->
val output = thumbnail.suspendToBytes(VipsImageFormat.WEBP, webpOptions)
ProcessedImageVariant(
name = variant.name,
key = keyFactory.variantKey(imageId, variant.name, variant.extension),
bytes = output,
width = thumbnail.width,
height = thumbnail.height,
contentType = variant.contentType,
)
}
}

Full source: DerivativeProcessor.kt

This is where the benchmark meets the service. A service can create several variants from one upload, and each variant performs both resize and encode. On that path, the difference between scrimage and libvips becomes more visible than a single number suggests.

For simple image processing, or for environments where native dependencies should be avoided, the scrimage path in images is a good starting point. It is easy to deploy and only needs the JVM.

But images-vips-java25 is worth considering first when:

  • uploads create multiple thumbnails, previews, or WebP variants;
  • 4K or larger originals are common;
  • resize latency limits request time or worker throughput;
  • Java 25 is available and libvips native dependencies can be operated safely.

In Java 21 environments, images-vips-java21 is still an option. But JNI native artifacts and host architecture must be verified. On a host such as macOS arm64 where the bundled native artifact does not match, mark benchmark and integration results as N/A and measure on Linux or an architecture-compatible host instead.

AVIF and HEIC follow the same rule. A format constant in the API does not guarantee that decode or encode works on a host. Actual support depends on the installed libvips, libheif, and AV1/HEVC codec build.

Use these links as an entry map rather than a flat appendix.

Benchmark code:

  • ImageResizeBenchmark.kt compares cafe and landscape 4K-to-1080p resize workloads through scrimage and libvips.
  • ImageEncodeBenchmark.kt keeps encode workloads symmetric so JPEG and PNG results can be read against the same natural photo fixtures.
  • VipsBackendBenchmark.kt isolates the backend-level libvips calls used to compare JNI and FFM behavior.
  • VipsBackendEncodeBenchmark.kt focuses on native encode paths where codec and host library support matter.
  • VipsBenchmarkState.kt selects the active backend at runtime and records when a backend is unavailable instead of fabricating a number.
  • BenchmarkImageSets.kt loads the cafe and landscape natural photo fixtures and keeps synthetic fallback only for optional document/thumbnail resources that are not committed yet.

Reports and raw evidence:

Workshop example:

scrimage and libvips are less competitors than two operating modes. The pure-JVM path is simple and portable. The native path requires more operational preparation, but the performance gap is clear in high-volume image pipelines.

The goal of bluetape4k-image is not to force one answer. A service can start with the pure-JVM path and move to a libvips backend when throughput starts to matter, without leaving the same ecosystem.

Comments

Leave a note or reaction with your GitHub account.