What happens when a large photo must reach 20 KB
By Sucheet BoppanaPublished Updated
A 1600×1067 synthetic test scene of 393,418 bytes came out of the 20 KB page at 20,111 bytes, and it got there by giving up pixels rather than quality: 524×350 at quality 68, in 1.83 s. The quality dial alone stops at a floor of 50, so 5 shrink steps did the rest and the picture finished at 33% of the width it started with. A second measurement at a looser ceiling runs the other way: asked for 100 KB, compressing the full 1600 px frame scored 40.95 dB PSNR and 0.9794 SSIM, against 37.88 dB and 0.9694 for resizing to 1200 px first, both judged at 1200 px. So shrinking first is what a tight ceiling forces, not a trick that improves a loose one.
Methodology
Every figure here came from driving the real page in a real browser. Chromium opened the 20 KB page, picked the sample through the file input, pressed the button and caught the download; the sentence quoted further down is the one it read off the screen. Nothing in the run calls the compressor directly, because a number produced that way describes a module rather than the thing a visitor uses.
The build and the machine: commit b1db35c on branch feat/seo-growth-system, Chromium 151.0.7922.34, Node v20.20.2, on an Apple M1 Pro running darwin 25.5.0 arm64 with 8 cores, serving a production build at http://127.0.0.1:3911.
- Build what ships, because a development build measures the wrong bundle: npm run build
- Serve it: npx next start -p 3910 — this run answered on port 3911, which BENCH_URL selects
- Drive it from a second terminal, leaving the build alone while it runs: npm run bench
Three caveats travel with these numbers. The sample is a synthetic photograph-like scene drawn by a seeded generator and committed to the repository, not a photograph anybody took, and it is chosen to give an encoder grain and gradients to argue with. Times come from one machine in one thermal state, and this one was busy — the one-minute load average was 7.9 across 8 cores when the run began — so read the seconds as an upper bound. Bytes, dimensions and quality settings are deterministic for a given build; the seconds are not.
The two quality scores are computed over BT.601 luma with both sides decoded by the same library, so a browser codec and the reference decoder cannot disagree their way into looking like a quality difference. SSIM here uses an 8×8 uniform window and is not the multi-scale variant, so a figure from this page is not comparable with a paper reporting MS-SSIM.
Where 20 KB has to go on a picture this size
The ceiling is a byte count, but the thing that decides the outcome is how many pixels are sharing it. The sample arrived carrying 1.84 bits for each of its 1,707,200 pixels. Holding every one of them under 20,480 bytes would have left 0.09 bits apiece — one bit shared between 11 pixels — and no encoder turns that back into a picture.
| Stage | Bytes | Pixels | Quality | Time |
|---|---|---|---|---|
| Went in | 393,418 (384.20 KB) | 1600×1067 | — | — |
| Came back | 20,111 (19.64 KB) | 524×350 | 68 | 1.83 s |
What the page settled on instead was 0.88 bits for each pixel, bought by removing 89% of them. Those two lines are the whole trade: the byte count landed almost exactly where it was asked to, and the pixel count did all of the moving.
The steps it actually took to get there
Two levers, spent in a fixed order. Quality goes first and halts at 50, because further down a JPEG shows the blocking and ringing nobody wants on a document. The width and height only start moving once an encode at that floor is still over the line.
Each step then takes 20% off both sides, and — this is the part that is easy to get wrong — every step resamples from the original file rather than from the step before it. Stacking 8 rounds of filtering on top of each other comes out visibly softer than one round straight to the same size, for identical dimensions and identical bytes.
524 is exactly the 5th rung of that ladder, so 5 shrinks ran before anything fit. The quality that finally won was 68, which is above the floor, and that is the search climbing back: the floor only binds while the picture is too big. At 524×350 an encode at 50 fit with room to spare, so the search spent the room on looking better.
The walk stops after 8 steps, or when the short side would fall under 32 pixels, or when it runs out of time. This one needed 5 of the 8 and finished in 1.83 s.
What the page said it had done, and the number worth re-reading
Under the finished picture it printed: "Asked for 20 KB — landed on 19.64 KB at quality 68 after shrinking the picture from 1600×1067 to 524×350. Nothing was resized silently: this is the Shrink to fit policy you chose."
Two things in that sentence repay a second look. It names the size it stopped at, because a page that quietly hands back a smaller picture gets found out by the form rather than by you. And 19.64 KB is 20,111 bytes: a kilobyte is 1,024 bytes here, exactly as it is in the file size your computer shows you.
So the file that met a 20 KB ceiling sits above a round twenty thousand. A field written against the round number will refuse it, and the fix is to set the figure a little lower and run it again rather than to argue with the form.
Is it better to shrink the picture yourself first?
Not at every ceiling, and the second measurement says so plainly. The same source was taken to 100 KB twice: once by resizing to 1200 px and compressing after, once by compressing the full 1600 px frame and downscaling the result to 1200 px afterwards. Both were scored against one reference, the source at 1200 px, which is what somebody displaying the picture at that width would actually see.
| Lane | Bytes | Pixels | Quality | PSNR (dB) | SSIM |
|---|---|---|---|---|---|
| Resize to 1200 px, then compress to 100 KB | 100,327 (97.98 KB) | 1200×800 | 85 | 37.88 | 0.9694 |
| Compress to 100 KB at the full 1600 px | 102,184 (99.79 KB) | 1600×1067 | 66 | 40.95 | 0.9794 |
The full-size lane won on both scores, by 3.08 dB and 0.0100 SSIM, and it did it at the lower quality setting of the two — 66 against 85. The explanation the numbers point to is the downscale at the end: it averages several encoded pixels into every displayed one, and averaging hides compression noise. Resizing first hands the encoder fewer pixels to spend the budget on, but buys none of that help afterwards.
Read that narrowly. It is one sample, one ceiling, one machine, and a drawn scene rather than a photograph — not a statement about JPEG and not a statement about every target. At 20 KB the same source could not stay at full size at all, which is the far end of the same dial. Shrinking is what a tight ceiling forces, not a trick that improves a loose one.
If a form is what sent you here
Settle the pixel box before the byte count when the field states both. The fit policy chooses a size in order to satisfy the ceiling and knows nothing about the box the form wanted, so compare the width printed under your result against what was asked for before you attach anything.
Crop before you compress when the subject is small in the frame. Margin costs the same bits as a face does, and at this ceiling there are very few bits to spend on anything nobody will look at. Cutting the frame down first is often the entire fix, and it leaves the size of what remains under your control instead of the policy’s.
And start looser if the requirement allows it. A ceiling that the quality dial can reach on its own leaves the width and height untouched, which is the outcome to prefer whenever the form will accept it; coming back down later costs you nothing but the second run.
Where to go next
- Compress to 20 KB — Run the same 20 KB ceiling on your own file and read the size it stopped at.
- Compress to 50 KB — Try the looser ceiling first, where the quality dial often gets there with the pixels intact.
- Resize Image — Pick the width yourself, rather than letting the fit policy choose it for you.
- Signature Resizer — Fit a scanned signature to a pixel box and a byte ceiling in a single pass.
Frequently asked questions
Why did it come back at quality 68 when the floor is 50?
Because the floor only binds while the picture is still too big. At each size the page encodes once at 50 to find out whether that size can fit at all; when 524×350 did, the search climbed back up and stopped at the best quality still under the ceiling, which was 68.
Would a different picture have kept more of its pixels?
Very likely, and it has little to do with how big the file was to begin with. The search is driven by what the encoder produces at each size, so a flat graphic or a tightly cropped signature fits at a far larger size than a grainy scene does. Grain is the expensive thing, because it is detail with no pattern in it to exploit.
Should I compress at full size every time, then?
No. The 100 KB result above is one sample at one ceiling on one machine, and it turns over completely at 20 KB, where staying at full size was never on the table. Take it as a reason to try the ceiling before reaching for the resize tool, not as a rule about which order always wins.
Are these times what I would see?
Treat them as an upper bound rather than a typical figure. The run used one laptop in one thermal state with a one-minute load average of 7.9 across 8 cores, and your own hardware decides the rest. The byte counts and quality settings are reproducible for a given build; the seconds are not.
Was the sample a real photograph?
No, and it is never described as one. It is a drawn scene — a sky gradient, a low sun, ridge lines, broken water highlights and fine grain over the whole frame — produced by a seeded generator so that a rerun uses byte-identical inputs. It behaves like a photograph for an encoder, which is what the measurement needed, and it carries no licence question with it.