JPG → WebP cuts file sizes by 25–35% at equivalent visual quality — a meaningful win for mobile apps and web performance. The JVM doesn't ship a WebP encoder out of the box, but the webp-imageio plugin plugs directly into ImageIO. Android has native WebP support since API 30. This guide covers both plus the ChangeThisFile API shortcut.
Method 1: ImageIO + webp-imageio plugin (JVM/server-side)
The webp-imageio library registers a WebP ImageWriter with the standard Java ImageIO framework. No native binaries — the encoder is bundled in the JAR.
// build.gradle.kts
dependencies {
implementation("org.sejda.imageio:webp-imageio:0.1.6")
}
import com.luciad.imageio.webp.WebPWriteParam
import java.io.File
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageWriteParam
fun jpgToWebp(inputPath: String, outputPath: String, quality: Float = 0.85f) {
val image = ImageIO.read(File(inputPath))
?: error("Cannot read image: $inputPath")
val writer = ImageIO.getImageWritersByMIMEType("image/webp").next()
val param = writer.defaultWriteParam as WebPWriteParam
param.compressionMode = ImageWriteParam.MODE_EXPLICIT
param.compressionType = "Lossy"
param.compressionQuality = quality
val outFile = File(outputPath)
ImageIO.createImageOutputStream(outFile).use { stream ->
writer.output = stream
writer.write(null, IIOImage(image, null, null), param)
}
writer.dispose()
println("Saved: $outputPath (${outFile.length() / 1024} KB)")
}
fun main() {
jpgToWebp("photo.jpg", "photo.webp", quality = 0.85f)
}
Quality guide:
- 0.60–0.70 — Aggressive compression. Visible artifacts on gradients.
- 0.80–0.85 — Good default. Indistinguishable from JPEG at same size.
- 0.90+ — Near-lossless. Larger than JPEG at equivalent quality.
For lossless WebP (e.g., illustrations, screenshots), set compressionType = "Lossless" and omit compressionQuality.
Method 2: Android / Compose (BitmapFactory + Bitmap.compress)
On Android, the platform provides native WebP encoding since API 30 (Android 11). No extra dependencies needed.
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
import java.io.FileOutputStream
fun jpgToWebpAndroid(
inputPath: String,
outputPath: String,
quality: Int = 85
) {
val bitmap = BitmapFactory.decodeFile(inputPath)
?: error("Cannot decode: $inputPath")
FileOutputStream(outputPath).use { out ->
// WEBP_LOSSY requires API 30+; use WEBP for API < 30 (always lossy)
val format = if (android.os.Build.VERSION.SDK_INT >= 30) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
@Suppress("DEPRECATION")
Bitmap.CompressFormat.WEBP
}
bitmap.compress(format, quality, out)
}
bitmap.recycle()
println("Saved: $outputPath")
}
For Compose, load with BitmapPainter(BitmapFactory.decodeFile(path).asImageBitmap()) for display, then compress with the above for saving. Android handles the encoder natively — no WebP plugin JAR needed.
API level note: WEBP_LOSSY and WEBP_LOSSLESS were added in API 30. For apps targeting API 18–29, fall back to Bitmap.CompressFormat.WEBP (deprecated but functional lossy encoder).
Method 3: ChangeThisFile API via Ktor HttpClient (no encoder deps)
No webp-imageio JAR, no Android API version concerns. POST the JPG to /v1/convert and receive WebP. Free tier: 1,000 conversions/month.
# curl reference
curl -X POST https://changethisfile.com/v1/convert \
-H "Authorization: Bearer ctf_sk_your_key_here" \
-F "file=@photo.jpg" \
-F "target=webp" \
--output photo.webp
import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import java.io.File
const val API_KEY = "ctf_sk_your_key_here"
suspend fun jpgToWebpApi(inputPath: String, outputPath: String) {
val client = HttpClient(CIO)
val response: HttpResponse = client.submitFormWithBinaryData(
url = "https://changethisfile.com/v1/convert",
formData = formData {
append("file", File(inputPath).readBytes(), Headers.build {
append(HttpHeaders.ContentDisposition, "filename=\"${File(inputPath).name}\"")
append(HttpHeaders.ContentType, ContentType.Image.JPEG)
})
append("target", "webp")
}
) {
header("Authorization", "Bearer $API_KEY")
}
File(outputPath).writeBytes(response.readBytes())
client.close()
println("Saved: $outputPath")
}
fun main() = kotlinx.coroutines.runBlocking {
jpgToWebpApi("photo.jpg", "photo.webp")
}
Source format is auto-detected from the file content. The API uses libvips for conversion — fast and memory-efficient for large images.
When to use each
| Approach | Best for | Tradeoff |
|---|---|---|
| webp-imageio (JVM) | Server-side batch conversion, any JVM platform | Extra JAR dependency; no Android support |
| Bitmap.compress (Android) | Android apps, Compose UIs | API 30+ for WEBP_LOSSY; deprecated format on older APIs |
| ChangeThisFile API (Ktor) | No encoder deps, edge functions, cross-platform | Network call, 25MB free-tier upload limit |
Production tips
- Don't re-encode WebP from an already-compressed JPEG. JPEG artifacts bake in during re-encoding. If you have the original uncompressed source, encode from that. If JPEG is all you have, quality=0.85 is a reasonable floor.
- Strip EXIF before encoding for web. JPEG EXIF data (GPS, camera model) doesn't carry over to WebP unless you explicitly copy it. For web use this is a feature — no accidental location data leak.
- Batch with a thread pool, not a coroutine-per-file. Image encoding is CPU-bound. Use a fixed-thread Executor or
Dispatchers.Defaultwith a semaphore, not an unbounded coroutine launch per file. - Verify browser support if serving to older clients. WebP is supported in all modern browsers but not IE. If serving to enterprise environments, keep a JPEG fallback or use
<picture>with WebP as the primary source. - Use lossless WebP for PNG-origin images. Lossless WebP is typically 20–30% smaller than PNG with zero quality loss. Don't compress PNGs as lossy WebP — it degrades them.
For JVM services, webp-imageio via ImageIO is the cleanest path. For Android, use the native Bitmap.compress API. For both cases where you want zero encoder dependencies, the ChangeThisFile API handles it server-side. Free tier covers 1,000 conversions/month.