Last Black Friday, one of our retail customers pushed 14 million product images through our edge in under six hours. Their origin was set to a blanket `q=82` for every JPEG and WebP variant. It worked, sort of — until we looked at the bytes. Nearly 40% of those images could’ve been 25-30% smaller with zero perceptible loss, while another 12% were visibly blocky on high-DPI displays. That’s when I stopped trusting fixed quality values for good.
Why Hardcoded `q=80` Falls Apart at CDN Scale
For years, most teams picked a “safe” quality number and called it a day. Early in my CDN career, we did the same. We’d run a few side-by-side comparisons in Chrome, agree that 80 or 85 looked fine, and ship it globally. The problem isn’t that 80 is a bad number — it’s that it’s a single answer to thousands of different questions. A soft studio portrait with creamy bokeh compresses beautifully at q=65, while a detailed textile close-up with high-frequency weave patterns falls apart below q=83. When you force both through the same setting, you’re either wasting bandwidth or hurting fidelity. And at scale, that waste multiplies into terabytes per day and real money on egress.
I’ve seen this mismatch play out most painfully after a new Image CDN Architecture Design: Building a Scalable Image Processing Pipeline goes live. Architects obsess over caching, sharding, and origin shielding — all critical — but they treat quality as an afterthought, a config constant instead of a per-asset decision. The right mental model isn’t “what quality should we use?” but “what SSIM or perceptual score do we need to guarantee, and what’s the lowest quality that gets us there for this specific image?” That shift is subtle but it changes everything. As documented in web.dev Articles, modern encoders like MozJPEG and libwebp have non-linear quality curves, so a drop from 85 to 75 isn’t a 10% quality loss — it might be imperceptible on one image and catastrophic on another. You can’t predict it without measuring it.
That’s why we moved away from PSNR entirely. PSNR is mathematically convenient but it doesn’t correlate well with what humans actually see. SSIM and MS-SSIM do a far better job, especially for structural artifacts around edges and text. Our internal threshold is MS-SSIM >= 0.97 compared to the source (or highest-quality mezzanine). For most photographic content, that maps to “visually lossless” in double-blind reviews. We learned the hard way that you have to measure against the mezzanine master, not a previously compressed derivative, or your search converges on the wrong target and you inherit generational loss. Here’s the tiny Python utility we still use to validate that threshold before any tuning loop runs:
import cv2
from skimage.metrics import structural_similarity as ssim
def is_visually_lossless(source_path, candidate_path, threshold=0.97):
# Always compare against the mezzanine master, not an intermediate
src = cv2.imread(source_path, cv2.IMREAD_GRAYSCALE)
cand = cv2.imread(candidate_path, cv2.IMREAD_GRAYSCALE)
# MS-SSIM proxy: compute SSIM at multiple scales, simplified here
# In production we use libvips + custom MS-SSIM for speed
score, _ = ssim(src, cand, full=True)
# For color, run on Y channel after YCbCr conversion
# Threshold tuned from 800+ human ratings (0.97 = ~98% "identical" votes)
return score >= threshold, score
# Example: reject any candidate below threshold
ok, score = is_visually_lossless("master.tiff", "candidate_q72.webp")
print(f"SSIM: {score:.4f} -> {'PASS' if ok else 'FAIL'}")
Hunting the Sweet Spot With SSIM-Guided Binary Search
A naive way to find the optimal quality is to try every value from 100 down to 1 and stop when SSIM drops below your threshold. It’s accurate and unbelievably slow. When you’re processing tens of thousands of variants per minute during a cache fill, you can’t afford 40 encodes per image. We tried linear scanning in 2019 and watched our image workers saturate at 70% CPU just on quality search. The fix was embarrassingly simple: treat it as a monotonic search problem. As quality increases, SSIM never meaningfully decreases — so you can binary search the quality range.
Our current production loop searches between q_min=45 and q_max=92 for most formats. We don’t go below 45 because artifacts become structural rather than just lossy, and we cap at 92 because beyond that you’re paying huge byte costs for <0.002 SSIM gain — basically noise. For each mid-point quality, we encode an in-memory buffer (no disk I/O), decode, and compute MS-SSIM on the luma channel downscaled to 1080p if the source is larger, which keeps the metric fast and avoids punishing irrelevant 4K detail that won’t be seen on delivery. That optimization alone cut our p95 search latency from 340ms to 88ms per image. If you’re generating responsive variants, this pairs directly with the logic in Responsive Image CDN Automation: Generating srcset Variants on the Fly, where each width gets its own optimal quality — a 320w thumbnail often lands at q=62 while the 2048w hero from the same source needs q=78 to hit the same 0.97 target.
One gotcha we hit early: not all content is photographic. Screenshots, line art, and images with overlaid text need a different strategy. For those, we bump the threshold to 0.985 or switch to Butteraugli/SSIMULACRA2 distance metrics because SSIM under-penalizes sharp edge halos. We run a fast classifier (edge density + color histogram) before tuning to pick the profile. The core binary search, though, stays the same. This is what it looks like in a worker:
// Go-style pseudo for an image worker
func FindOptimalQuality(master []byte, format string) int {
lo, hi := 45, 92
best := hi
targetSSIM := 0.97
if IsGraphicHeavy(master) {
targetSSIM = 0.985 // stricter for text/line-art
}
for lo <= hi {
mid := (lo + hi) / 2
candidate := Encode(master, format, mid) // in-memory encode
score := ComputeMSSSIM(master, candidate) // on Y channel
if score >= targetSSIM {
best = mid // this quality is good enough, try lower
hi = mid - 1
} else {
lo = mid + 1 // need higher quality
}
// Early exit: if byte savings between best and mid < 2%, stop
if hi - lo < 2 { break }
}
return best
}
What We Actually Save When Quality Tunes Itself
When we first rolled this out to 8% of traffic as an A/B test, I expected modest 10-12% savings. The real numbers made me double-check the logging. Photographic catalogs — fashion, home goods, marketplace UGC — consistently saw 24-31% byte reduction at identical perceived quality. Even more interesting, about 7% of images got *higher* quality than the previous fixed setting because the old default was too aggressive for high-detail content. So auto-tuning didn’t just save bandwidth, it quietly fixed quality regressions we’d never reported. Our origin team loved it because cache hit ratios didn’t change; we were just storing smaller objects. And because we honor HTTP Working Group Specifications for content negotiation, the tuned variant is keyed correctly on `Accept` and `Width` without polluting the cache.
The operational win is just as important as the byte win. With per-image tuning, you don’t need quarterly “quality review” meetings where someone manually eyeballs 50 images and declares q=78 the new global. The algorithm adapts when a new encoder version ships or when you enable AVIF. We saw this when we switched AVIF encoder settings: the optimal quality distribution shifted down by ~8 points for the same SSIM target, which would’ve required a manual global change if we were still on fixed values. Pair this with your loading strategy — we coordinate tuning decisions with Balancing Lazy Loading and Preloading with CDN: Optimal Image Loading Strategies so above-the-fold heroes can optionally target 0.98 while below-the-fold thumbnails stay at 0.97 — and you’ve got a system that’s responsive to both content and context.
| Image Category (Sample Size) | Fixed q=80 Avg. Size | Auto-Tuned Avg. Quality | Avg. MS-SSIM vs Master | Byte Savings |
|---|---|---|---|---|
| Fashion Product Photo (12k images) | 182 KB | q=68 | 0.973 | 31.2% |
| Detailed Textile / UGC (8k images) | 247 KB | q=79 | 0.971 | 14.8% |
| Soft Portrait / Lifestyle (10k images) | 165 KB | q=65 | 0.975 | 28.6% |
| Graphic / Text Overlay (5k images) | 98 KB | q=84 | 0.986 | -6.1% (quality increase) |
| Mixed Catalog Average (35k images) | 178 KB | q=71 | 0.974 | 24.3% |
Putting It Into Production: An Edge Worker That Actually Pays for Itself
Look, the binary search is the easy part. The hard part is making it fast enough that you’re not blowing your TTFB budget on every cache miss. We learned this the hard way — our first Python prototype ran SSIM comparisons server-side and added 180-220ms to origin fetches. Fine for batch processing, terrible at the edge. What works in production is a constrained search inside a lightweight worker that reuses the decoded image buffer instead of re-encoding from scratch each time.
We’ve settled on a pretty opinionated setup: decode once with libvips (sharp, if you’re in Node), target an SSIM of 0.96-0.98 for photographic content and 0.99+ for anything with text or line art, and limit the search to 5 iterations max. That gives you a quality granularity of about ±2 points, which is more than enough — going from q=83 to q=84 saves you maybe 3KB on a 1200px hero, not worth another encode cycle. We also strip metadata *before* we calculate SSIM — you don’t want EXIF orientation or a 40KB thumbnail throwing off your structural comparison. If you haven’t already, get your Image Metadata Stripping at CDN Edge: EXIF Removal for Privacy and Performance pipeline in place first, then tune.
// Cloudflare / Fastly Compute@Edge style worker - sharp + ssimulacra2
import sharp from 'sharp';
import { getSSIM } from 'ssimulacra2';
async function findOptimalQuality(imageBuffer, { targetSSIM = 0.97, minQ = 55, maxQ = 92 } = {}) {
const reference = sharp(imageBuffer);
const { data: refData, info } = await reference.raw().toBuffer({ resolveWithObject: true });
let low = minQ, high = maxQ, bestQ = 75, bestBuffer = null;
for (let i = 0; i < 5; i++) {
const mid = Math.floor((low + high) / 2);
const candidate = await sharp(imageBuffer).jpeg({ quality: mid, mozjpeg: true }).toBuffer();
const { data: candData } = await sharp(candidate).raw().toBuffer({ resolveWithObject: true });
const ssim = getSSIM(refData, candData, info.width, info.height);
// Keep the smallest file that still hits our target
if (ssim >= targetSSIM) {
bestQ = mid;
bestBuffer = candidate;
high = mid - 1; // try lower quality / smaller file
} else {
low = mid + 1; // need higher quality
}
}
return { quality: bestQ, buffer: bestBuffer };
}
Advanced Patterns and Troubleshooting When the Score Lies
You’ll quickly find that SSIM isn’t gospel. It’s great for photos, but it’ll happily tell you a q=65 version of a screenshot with tiny 11px UI text is “0.97 similar” when it’s actually turned into a smeary mess. We don’t auto-tune everything with the same target. Our classifier now checks for edge density and flat-color ratio before picking a target. If more than 35% of the image is flat color or sharp edges (think charts, logos, product UI), we bump the target to 0.985 and set a floor of q=82. For true vector art, we don’t tune at all — you’re better off serving it as SVG anyway. If you’re still juggling lots of small icons as raster, you’ll save more by revisiting CSS Sprite Generation and CDN Optimization: When Sprites Still Beat HTTP/2 than by shaving 2KB off each PNG. And for mixed raster/vector workflows, our rules from SVG Delivery Optimization Through CDN: Inline, External, and Sprite Approaches still apply — don’t waste CPU tuning what should’ve been an inline SVG.
The other gotcha is drift at scale. You’ll tune an image once, cache it, and then your source artist re-uploads a slightly sharper version. If you’re keying only on URL, you’ll serve the old q=71 variant that now scores 0.94 against the new source. We version our cache key with an `x-source-etag` or `content-hash` and re-run the search on any change. Also watch out for quality oscillations with AVIF/WebP — AVIF at q=55 can look better than JPEG at q=80 but SSIM will score it lower because of subtle film grain differences. For next-gen codecs, we’ve switched to DSSIM or Butteraugli for the comparison, but we keep the same binary search wrapper. It adds maybe 8-12ms per encode, still well under our 45ms p95 budget for a cache miss.
When debugging, don’t just log the final Q. Log the whole curve: quality, bytes, and SSIM for each iteration. If you see a flat curve — say q=75 is 112KB at 0.972 and q=85 is 168KB at 0.974 — you’ve got a noisy or already-compressed source, and you’re burning cycles for 0.002 SSIM. We short-circuit in that case and just serve q=75. Conversely, if you need a jump from q=70 (0.95) to q=88 (0.98) to hit your target, flag it. That image probably shouldn’t be a JPEG in the first place.
Frequently Asked Questions
What's the right SSIM target? Is 0.97 always the sweet spot?
Not always. 0.96-0.97 is our default for photos because in double-blind tests, fewer than 5% of users notice a difference at that level, and you’ll typically save 28-42% versus q=85. For e-commerce product shots on white, we use 0.98. For images containing text, diagrams, or human faces in close-up, push to 0.985-0.99. Anything below 0.95 and you’ll start getting support tickets about "blurry images," no matter what the metrics say.
How many binary search steps do I actually need? Won't this kill my edge latency?
Five iterations is the sweet spot. With a range of 55-92, binary search narrows to within ±1-2 quality points in 5 steps. In our edge workers, that’s 4-6 encodes using mozjpeg or libwebp, which runs in 18-35ms total on a 2048px image because we keep the decoded raw buffer in memory. If you’re tighter on latency, do 4 iterations and accept ±3 points — you’re still within 4% of optimal file size. Don’t do a linear scan from 92 downwards, you’ll waste 12+ encodes.
Does this work the same for WebP and AVIF, or just JPEG?
The algorithm is codec-agnostic, but the targets shift. WebP at q=80 is roughly equivalent to JPEG at q=88 in file size, so your search range should be lower — try 45-85 for WebP and 35-70 for AVIF. AVIF is also much slower to encode (4-7x JPEG), so we often pre-tune AVIF at origin during the build step and only do live JPEG/WebP tuning at the edge. Also, use a perceptual metric like Butteraugli or SSIMULACRA 2 for AVIF — classic SSIM undervalues AVIF’s grain preservation.
Should I auto-tune every image, including thumbnails and icons?
No. Don’t tune images smaller than ~10KB or under 200px — the overhead isn’t worth it and you risk visible artifacts. For thumbnails, we just use a fixed q=75-78. Same for tiny UI icons; if you’ve got dozens of them, sprites or an SVG sprite will beat any per-image tuning on both performance and cache efficiency. Reserve auto-tuning for hero images, product photos, blog imagery, and user-generated content where the size variance is huge.