IT
OmnvertImage • Document • Network

Compressing images without visible loss

7 min read
A hand holding a phone up against a sunset over the sea; the phone's screen shows the same horizon it is framing.

Quality 80 is not 80% of anything, it is a scale factor on a quantisation table. Finding the point where loss stops being visible takes measurement, not a setting copied from another project.

What the quality number is not

A JPEG encoder's quality setting is not a percentage of anything. The encoder splits the image into 8 × 8 blocks, converts each block into frequency coefficients, then divides those coefficients by entries in a quantisation table and rounds. The quality value scales that table. Nothing in the pipeline promises that quality 80 in one encoder produces the same table, or the same visual result, as quality 80 in another, and the same encoder at 80 behaves differently again once you change chroma subsampling.

So the number you settled on for one project does not transfer to the next. The method transfers. Four steps, in this order: get the pixel dimensions right, remove the bytes that are not pixels, pick subsampling from the content, then find the quality knee by measuring instead of squinting.

Dimensions beat compression, by a lot

A photo straight off a recent phone is roughly 4032 × 3024, which is 12.19 megapixels. If your content column displays it at 1200 × 900, you need 1.08 megapixels, so you are compressing about eleven times more data than the page can show. Serve a 2x asset for dense displays and it is 2400 × 1800, or 4.32 megapixels, still nearly three times more than the layout needs at 1x.

No quality slider gives you an 11x reduction without visible damage, and resizing costs you nothing visible at all, because the pixels were never going to be displayed. Start every job with resize, and use crop where the composition allows it. Tuning the encoder before the dimensions are right is optimising the wrong variable.

The bytes that are not pixels

A camera JPEG carries more than an image. There are EXIF blocks, GPS coordinates, maker notes, sometimes an XMP packet, and almost always an embedded preview. That preview is itself a complete JPEG, and depending on the camera it ranges from a few kilobytes to a few hundred. None of it is needed on a web page, and the EXIF remover clears the lot in one pass. It is also the privacy-safe default, since location data in a product photo is a real leak.

One exception: the ICC colour profile. Dropping it from an sRGB image is harmless. Dropping it from a Display P3 photo is not, because browsers treat an untagged image as sRGB and the saturated reds and greens shift visibly. The fix is not to keep a large profile around but to convert the image to sRGB first, then strip.

Chroma subsampling: cheap win, sharp trap

Human vision resolves brightness detail far better than colour detail, and every lossy codec exploits that. At 4:4:4 each pixel gets three samples: Y, Cb and Cr. At 4:2:0 a 2 × 2 block keeps four luma samples but only one Cb and one Cr, so six samples replace twelve. Half the data disappears before compression proper even starts.

On photographs that is nearly free. It stops being free on red or blue text over white, thin coloured rules, hard-edged logos and interface screenshots, where the colour edge smears across the block. For that kind of content, switching to 4:4:4 and accepting a lower quality number produces a better result than keeping 4:2:0 at a higher one. Worth knowing: lossy WebP has no 4:4:4 mode at all, which is one of several format ceilings covered in WebP vs AVIF.

Measure the difference instead of judging it

"Looks fine to me" does not scale. It depends on your monitor, your zoom level and how carefully you were looking that afternoon. Perceptual metrics give you a repeatable number instead. Butteraugli, and SSIMULACRA 2 which builds on the same ideas, weight the difference between two images by how sensitive human vision is to that kind of error.

SSIMULACRA 2 is particularly useful because its documentation anchors scores to concrete encoder settings:

  • Score 90: visually lossless. Indistinguishable even in a flicker test at 1:1. Roughly libjpeg-turbo at 4:4:4 quality 95.
  • Score 85: excellent. An average observer comparing in place at 1:1 sees no distortion. Roughly 4:4:4 quality 90.
  • Score 80: very high. No distortion noticed in a side-by-side at 1:1. Roughly 4:2:2 quality 85.
  • Score 70: high. Artifacts are perceptible but not annoying, and without the original in front of them an average observer does not notice. Roughly 4:2:0 quality 70.

For the web, 70 to 85 is a sensible working band: around 85 for hero and product imagery, around 70 for thumbnails in a grid. Keep archive and pre-press copies at 90 or above.

Build a ladder, do not hunt for one number

Pick five images that represent your catalogue, encode each at 95, 90, 85, 80, 75 and 70, and write the file sizes into a table. The shape is almost always the same: the curve is steep at the top. Going from 95 to 90 cuts a lot of bytes and leaves no visible trace. Going from 75 to 70 saves comparatively little and visibly increases artifacts. The knee usually sits between 80 and 85, but it moves with content, which is exactly why one global setting underserves half your images. The image compressor is a quick way to produce that ladder and compare outputs side by side.

Where the damage shows up first

When you inspect a candidate, do not scan around randomly. Degradation appears in the same four places every time. Go straight to them, at 100% zoom, on the device you actually ship to.

  1. Smooth gradients: skies, studio backdrops, soft shadows. Banding first, then visible 8 × 8 blocking.
  2. Text and logo edges: ringing appears as faint halos on both sides of a hard transition.
  3. Dark areas and skin tones: colour shifts and blockiness are most obvious here.
  4. Alpha edges: halos and dirty pixels along a transparent boundary.

Killing banding with noise

An 8-bit channel only has 256 steps to describe a gradient. Quantisation merges some of those steps and the transition breaks into visible stripes. The counterintuitive fix is to add a very small amount of noise or dither before encoding: the randomness scatters the step boundaries and the stripes stop reading as stripes. It costs a few kilobytes and solves the problem more cheaply than raising quality across the whole image.

The real risk is the second encode

Opening a lossy file and saving it again causes generation loss, but the detail matters. Re-saving with the same encoder, the same settings and the same pixel dimensions is surprisingly stable, because the signal already sits on that quantisation grid. Change the dimensions by even one pixel, or rotate the image, and the 8 × 8 block grid shifts underneath the content: every block is re-quantised against new neighbours, and damage accumulates fast.

The rule that follows is simple. Always derive deliverables from the untouched master, never from a file you already shipped. Raising quality on a second pass does not recover anything; it just stores the existing artifacts more faithfully in a bigger file. The same applies across formats: converting a JPEG to AVIF also encodes the JPEG's artifacts, so the saving is smaller than converting from the original would have been.

With PNG, "compression" means two different jobs

PNG is lossless, so compressing one means one of two distinct operations. The first is filter selection and deflate optimisation: pixels stay bit-for-bit identical, the gain is modest, and there is no risk. The second is palette quantisation, reducing the image to 256 colours. That is genuinely lossy, but on icons, interface graphics and flat illustration it is usually indistinguishable while cutting the file dramatically.

Photographs should not be PNG in the first place. Move them to a lossy format, or to a modern format with alpha if you need transparency; PNG to WebP lets you try both lossy and lossless and keep whichever wins.

The order that works

  1. Keep the master untouched and generate every deliverable from it.
  2. Resize to the dimensions you actually display, cropping where it helps.
  3. Strip EXIF and the embedded preview, converting to sRGB before dropping a wide-gamut profile.
  4. Choose subsampling from content: 4:2:0 for photography, 4:4:4 for text and graphics.
  5. Encode a quality ladder and pick the rung that hits your target score.
  6. Choose the delivery format, with a fallback if you serve a modern one.
  7. Check the four failure zones at 100% on a real device before shipping.

Frequently asked questions

What JPEG quality should I use for the web?

There is no encoder-independent number, but the SSIMULACRA 2 scale gives a good anchor: score 70, roughly libjpeg-turbo 4:2:0 quality 70, is the level where someone who has not seen the original notices nothing, while score 90, roughly 4:4:4 quality 95, counts as visually lossless. Aim for 70-85 on the web and 90 or above for archives.

What happens if I compress the same file twice?

Re-saving with the same encoder, settings and pixel dimensions is fairly stable because the signal already sits on that quantisation grid. Change the size or rotate the image and the 8 x 8 block grid shifts, so each save adds fresh artifacts. Always generate from the master instead.

Does stripping EXIF damage the image?

It does not touch pixels, only metadata and the embedded preview. The one thing to watch is the ICC profile: removing it from a Display P3 image makes browsers treat it as sRGB and the colours shift. Convert to sRGB first, then strip.

How do I get rid of banding in skies?

Banding comes from quantisation merging steps in an 8-bit gradient that only has 256 of them. Raising quality everywhere is an expensive fix. Adding a very small amount of noise or dither before encoding scatters the step boundaries and costs a few kilobytes.

Is compressing a PNG lossy?

It depends which operation you mean. Filter and deflate optimisation is fully lossless and leaves pixels bit-for-bit identical. Palette quantisation to 256 colours is lossy, though on interface graphics and flat illustration the difference is usually invisible.

Tools used in this post

Sources

MethodologyImage credits