DEV Community

Muhaymin Bin Mehmood
Muhaymin Bin Mehmood

Posted on Fully Autonomous

"Photo Must Be Under 50 KB": Writing the Search That Hits a File-Size Limit

Exam portals, visa forms and job sites love rules like "photo between 20 KB and 50 KB, JPG only". A phone photo is 3 to 5 MB. The person filling the form doesn't care about quality settings. They care that the upload button stops saying "file too large".

I covered why "quality 80" can't promise a size in an earlier post. This post is the follow-up: the actual search that finds a JPEG under a byte limit, without wasting dozens of encodes.

What "best" means here

There are many JPEGs under 50 KB. A 40×40 thumbnail is one of them. The goal is the largest file that still fits, because for the same image a bigger JPEG almost always looks better.

That gives two knobs, in a clear order:

  1. Quality. Try this first. It keeps every pixel.
  2. Pixel count. Use this only when even low quality is too big. A 12 MP photo can't reach 20 KB on quality alone.

Keep the search pure

The search doesn't know about canvas, workers or blobs. The caller passes an encode function that returns a byte count:

type Attempt = { scale: number; quality: number } // scale 0-1, quality 10-95

async function searchTargetSize(
  targetBytes: number,
  encode: (a: Attempt) => Promise<number>,
  opts: { longSide: number; minLongSide?: number; maxAttempts?: number },
)
Enter fullscreen mode Exit fullscreen mode

That makes it unit-testable with a fake encoder. It also runs unchanged on the main thread or in a worker that owns an OffscreenCanvas.

Phase 1: binary search on quality

Try full size at quality 95 first. If it already fits, you're done and the user gets maximum quality.

If not, binary-search quality in steps of 5. Finer steps barely move the byte count, so they waste encodes.

const searchQuality = async (scale: number, hi: number, steps: number) => {
  let low = 10
  let high = hi
  for (let i = 0; i < steps && attempts < maxAttempts && low <= high; i++) {
    const mid = Math.round((low + high) / 2 / 5) * 5
    const bytes = await tryOne({ scale, quality: mid })
    if (bytes <= targetBytes) low = mid + 5 // fits: try higher quality
    else high = mid - 5 // too big: go lower
  }
}
Enter fullscreen mode Exit fullscreen mode

tryOne records two things on every encode: the best attempt that fits (largest bytes under the limit), and the smallest attempt seen overall. It also caches by scale|quality, so the same attempt is never encoded twice.

Phase 2: shrink, but measure instead of stepping

If quality 10 at full size is still too big, the image has too many pixels. The obvious loop shrinks by a fixed 15% each time. That works, but it's slow. Going from 690 KB to 20 KB that way takes about 11 encodes.

There's a better guess available. JPEG size roughly tracks pixel count, and pixel count goes with the square of the scale. So read the next scale straight off the last measurement:

let scale = 1
let lowBytes = await tryOne({ scale: 1, quality: 10 })

while (!found.best && attempts < maxAttempts && scale > minScale) {
  const next = Math.max(minScale, scale * Math.sqrt(targetBytes / lowBytes) * 0.97)
  if (next >= scale) break // no progress possible
  scale = next
  lowBytes = await tryOne({ scale, quality: 10 })
  if (lowBytes <= targetBytes) {
    await searchQuality(scale, 85, 4) // it fits: spend the rest on quality
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details:

  • The 0.97 aims slightly under the target, because the square-root rule is an estimate.
  • Once a size fits at quality 10, the loop goes back to quality search at that size. A smaller image at quality 70 usually beats a bigger one at quality 10.

In practice this lands in one or two shrink steps instead of eleven.

Guard rails

  • A floor on size. Never go below 320 px on the long side. Below that, a face photo stops being useful, and the form will reject it for other reasons.
  • A cap on encodes. 16 attempts maximum, so a pathological image can't spin forever.
  • Honest failure. If nothing fits, return the smallest attempt with met: false and tell the user. Don't silently hand over a file that's still too big.

Always JPEG

For this use case the output is always JPEG. PNG is lossless, so the only way to shrink it is fewer pixels, and a photo PNG at 50 KB is tiny and blurry. Forms almost always accept JPG anyway.

Try it

This search runs in the browser behind BatchSet's reduce image size in KB tool, where you type any target. For the most common form limit there's a preset page, compress image to 50 KB. Nothing is uploaded, which matters when the photo is for an ID document.

The pattern works beyond images too. Any time you have an expensive "try it and measure" step and a monotonic knob, binary-search the cheap knob first and use the measurements to jump on the expensive one.

This article was drafted with an AI assistant from the code of a production tool. The snippets are taken from that code.

Top comments (0)