Government photo upload forms are strict about file size, and they are strict in both directions. The US DS-160 visa form wants a JPEG of at most 240 KB with a compression ratio no higher than 20:1. China's online visa form wants 40–120 KB. Upload a 30 KB file and it gets rejected for being "too compressed"; upload 300 KB and it is too big.
Most people fix this by re-saving in an image editor until it works. When you build a tool that does it automatically, it turns into a neat little search problem. Here is how I handle it in the browser, with no server involved.
Step 1: turn the rules into a byte window
A "compression ratio" limit is really a minimum file size in disguise. The ratio compares the raw bitmap size to the compressed file size:
raw bytes = width × height × 3 (24-bit RGB)
ratio = raw bytes / file bytes
For a 600×600 DS-160 photo the raw bitmap is 1,080,000 bytes, so a ratio of at most 20:1 means the file must be at least 54,000 bytes. Put that next to the stated maximum and the rules become a window:
export function byteBounds(spec: PhotoSpec) {
const d = spec.digital;
if (!d) return undefined;
const raw = spec.output.widthPx * spec.output.heightPx * 3;
const fromRatio = d.maxCompressionRatio ? Math.ceil(raw / d.maxCompressionRatio) : 0;
return { minBytes: Math.max(d.minBytes ?? 0, fromRatio), maxBytes: d.maxBytes };
}
One more gotcha: official pages say "KB" without saying whether they mean 1000 or 1024 bytes. For a minimum I use ×1024, and for a maximum ×1000. The file then passes whichever way the validator reads it.
Step 2: binary-search the JPEG quality
canvas.toBlob(cb, "image/jpeg", quality) gives you exactly one knob. File size grows with quality, roughly monotonically, so binary search works well. I inject the encoder so the logic can be unit-tested without a canvas:
const Q_MIN = 0.3;
const Q_MAX = 1;
export async function fitJpeg<T extends { size: number }>(
encode: (quality: number) => Promise<T>,
{ minBytes, maxBytes }: { minBytes: number; maxBytes: number },
iterations = 8,
) {
const best = await encode(Q_MAX);
if (best.size <= maxBytes) {
return best.size >= minBytes ? { ok: true, result: best } : { ok: false, reason: "too-small", result: best };
}
const worst = await encode(Q_MIN);
if (worst.size > maxBytes) return { ok: false, reason: "too-large", result: worst };
let lo = Q_MIN, hi = Q_MAX, fit = worst;
for (let i = 0; i < iterations; i++) {
const q = (lo + hi) / 2;
const r = await encode(q);
if (r.size <= maxBytes) { fit = r; lo = q; } else { hi = q; }
}
return fit.size >= minBytes ? { ok: true, result: fit } : { ok: false, reason: "too-small", result: fit };
}
A few design choices:
- Aim for the largest file that fits, not the first one. Officers look at these photos. The more quality you keep under the cap, the fewer "blurry photo" rejections you get.
- Check the extremes first. If quality 1.0 already fits, stop there. If quality 0.3 is still too large, the pixel dimensions are wrong and no quality setting will fix it. Report that instead of looping.
- Eight iterations is plenty. That narrows quality to about 0.003, well below the point where file size visibly changes.
- "Too small" is a real failure. A flat, mostly white photo can compress below the minimum even at quality 1.0. It's better to report that clearly than to pad the file with junk bytes.
In the browser the encoder is just a promise wrapper:
const toBlob = (c: HTMLCanvasElement, q: number) =>
new Promise<Blob>((res, rej) => c.toBlob((b) => (b ? res(b) : rej(new Error("encode failed"))), "image/jpeg", q));
const fit = await fitJpeg((q) => toBlob(photo, q), byteBounds(spec)!);
Step 3: test it without a browser
Because the encoder is injected, the tests use a fake whose size is a function of quality:
const fake = (bytesAt: (q: number) => number) => async (q: number) => ({ size: bytesAt(q), q });
it("returns the largest file under the cap", async () => {
const r = await fitJpeg(fake((q) => Math.round(400_000 * q)), { minBytes: 54_000, maxBytes: 240_000 });
expect(r.ok).toBe(true);
expect(r.result.size).toBeLessThanOrEqual(240_000);
expect(r.result.size).toBeGreaterThan(235_000);
});
Where the numbers come from
The limits above come from the official pages (travel.state.gov for DS-160, the Chinese visa application service for China). I keep them, with sources, in an open dataset: passport-photo-requirements on GitHub (CC BY 4.0, JSON). There is also a side-by-side table of requirements by country.
This code runs inside pixtidy, a passport and visa photo maker I built. Face detection, cropping and this size fitting all happen in the browser, so the photo never leaves the device. If you've run into other odd government upload rules, I'd like to hear about them in the comments.
Top comments (0)