Application forms don’t ask for “a good-quality JPEG”. They ask for a photo “between 20 KB and 50 KB” and a signature “10–20 KB”, and they reject the upload if it’s one byte over. A quality slider is the wrong tool for that: you’d drag, download, check the size and try again.
So the compress to KB tool and the exam photo resizer solve a different problem: given an image and a byte range, find the best JPEG that lands inside it. This post walks through how that search works, the trade-offs it makes, and what it does on a real photo. The code is in src/lib/image/target-size.ts.
First problem: what is a KB?
Forms say “KB” and almost never say whether they mean 1,000 bytes or 1,024. A file of 51,000 bytes is 51 KB under one reading and 49.8 KB under the other. Guess wrong and the form rejects the file.
We don’t guess. We take the stricter reading of each end of the range:
| Limit | Reading | 50 KB maximum, 20 KB minimum |
|---|---|---|
| Maximum | 1,000 bytes per KB | at most 50,000 bytes |
| Minimum | 1,024 bytes per KB | at least 20,480 bytes |
A file between 20,480 and 50,000 bytes passes whichever way the site counts. The range is slightly narrower than either reading, and that’s the point.
How file size responds to quality
To show what the search is working with, here’s one photo: NASA’s official portrait of astronaut Kalpana Chawla, a public-domain scan at 2400 × 3000 pixels, 4.7 MB. We encoded it with MozJPEG, the encoder the site uses, at several sizes and qualities:
| Pixels | q20 | q40 | q60 | q75 | q85 | q95 | q100 |
|---|---|---|---|---|---|---|---|
| 2400 × 3000 | 128 KB | 240 KB | 372 KB | 599 KB | 1.1 MB | 2.7 MB | 4.6 MB |
| 1200 × 1500 | 43 KB | 76 KB | 108 KB | 156 KB | 290 KB | 735 KB | 1.8 MB |
| 413 × 531 | 9.3 KB | 15.5 KB | 21.1 KB | 28.4 KB | 45.9 KB | 115 KB | 289 KB |
| 200 × 230 | 3.0 KB | 4.9 KB | 6.5 KB | 8.4 KB | 13.3 KB | 29.9 KB | 66.5 KB |
Two things stand out:
- Size is far from linear in quality. Going from quality 85 to 95 makes the full-size file 2.5 times bigger. Most of the bytes at the top end go on detail the eye barely sees.
- Size follows the pixel count. Halving each side (a quarter of the pixels) cuts the file to roughly a quarter to a third at the same quality. That’s what makes shrinking predictable enough to aim with.
This scan has film grain, which is expensive to encode, so it’s a hard case. A phone photo of a face against a plain wall is usually smaller at every setting.
The search, step by step
The tools call encodeWithin() with the byte range, the largest size allowed, whether the dimensions may change, and a quality floor. It runs in three steps.
1. Find a size where the lowest acceptable quality fits
The search first encodes at the quality floor: 40 for the compress-to-KB tool. Below about 40, JPEG’s 8 × 8 blocks start to show. If that file is already under the maximum, the full size is kept.
If it’s too big and the dimensions may change, both sides shrink by the same factor, which keeps the shape. Since size tracks the pixel count, the factor is the square root of how far over the file is, trimmed by 5% for margin:
const factor = clamp(Math.sqrt(range.max / floor.byteLength) * 0.95, 0.1, 0.92);
The clamp keeps each step between 8% and 90% smaller, so a wild estimate can’t overshoot. It repeats until the floor-quality file fits, and gives up with a clear message if the image would have to drop below 16 pixels on a side.
When the size is fixed, as with an exam’s exact 200 × 230 px photo, nothing shrinks. The floor drops to 20 instead, and if even that won’t fit, you get an error rather than a file the form will reject.
2. Find the highest quality that still fits
At that size, a binary search runs between the floor and 95. JPEG size rises with quality almost always, and the search keeps the best result that fit rather than trusting the last one, so a rare dip can’t push the answer over the limit.
High qualities are the slowest to encode, so the search usually doesn’t start there. There’s one shortcut: if the floor file is an eighth of the limit or less, there’s so much room that quality 95 will probably fit, so it’s tried first.
3. If it’s under the minimum, pad it
Signatures are the problem case. Black ink on white paper compresses extremely well. We drew a 140 × 60 px pen stroke, the signature size IBPS asks for, and encoded it at quality 100: 2,294 bytes. The form wants at least 10 KB.
You could make the file bigger by upscaling it or adding noise, but both make the image worse. We add bytes the image doesn’t use. JPEG has comment segments (marker FF FE) that every decoder skips. We insert them after the header segments, since JFIF requires its APP0 header to come first, and fill them with spaces:
- 2,294 bytes of image
- + 7,946 bytes of comments
- = 10,240 bytes, exactly the minimum under the stricter reading
The pixels are the same bytes they were before. The search tries the extra quality headroom first (quality 100 instead of 95) and only pads what’s still missing.
Results on the real photo
Here’s the full-size portrait run through the compress-to-KB tool’s settings (any size allowed, quality floor 40):
| Target | Output pixels | Quality | File size | Encodes |
|---|---|---|---|---|
| 500 KB | 2400 × 3000 | 69 | 493,759 B | 7 |
| 200 KB | 2082 × 2603 | 42 | 199,183 B | 8 |
| 100 KB | 1354 × 1693 | 43 | 97,092 B | 9 |
| 50 KB | 895 × 1119 | 42 | 49,986 B | 9 |
| 20 KB | 467 × 584 | 45 | 19,549 B | 10 |
And the exam presets, where the pixel size is fixed:
| Preset | Pixels | Range | Result | Encodes |
|---|---|---|---|---|
| IBPS photo | 200 × 230 | 20–50 KB | 29,869 B at quality 95 | 2 |
| GATE photo | 413 × 531 | up to 200 KB | 115,452 B at quality 95 | 2 |
| IBPS signature (drawn) | 140 × 60 | 10–20 KB | 10,240 B at quality 100, 7,946 B padding | 3 |
Every result is inside its range under either reading of “KB”, in ten encodes or fewer.
The trade-off we chose
Look at the 50 KB row again: 895 × 1119 pixels at quality 42. The search keeps as many pixels as it can and accepts quality close to the floor. A different policy could shrink further to buy quality: from the first table, 413 × 531 fits 50 KB at quality 85.
We went with more pixels because many portals scale the photo down for admit cards and ID documents. Downscaling averages away JPEG artefacts, but it can’t put back pixels that were thrown away. If you’d rather have a smaller, cleaner image, resize it to the dimensions you want first, then compress it to the limit.
Small details that matter
- The same input gives the same file. Same photo, same range: identical bytes, every time. Nothing is random, so a result you’ve checked stays checked.
- No metadata. The file is built from decoded pixels, so EXIF, GPS and camera details don’t carry over. Sideways phone photos are turned upright first.
- Transparency becomes white, because JPEG can’t store it.
- One decode, many encodes. The image is decoded once and each candidate size is resampled once (Lanczos3, in linear light), so the search only repeats the cheap part. It runs on MozJPEG compiled to WebAssembly (about 59 KB gzipped) in a Web Worker, so the page stays responsive.
How we measured
The numbers above come from the site’s own target-size.ts, bundled unchanged, driving the same MozJPEG and Lanczos3 WebAssembly builds the site ships. We ran them in Node rather than a browser, so the photo was decoded by MozJPEG instead of the browser’s own decoder; browser results can differ by a few bytes. The signature was drawn in code: a two-pixel wavy line on white.