TL;DR: Online forms demand images under a hard limit β "signature under 20 KB," "photo under 50 KB." Most compressors make you upload your file to a server to hit that. I built SwiftShrink to do it entirely in the browser: a binary search over JPEG quality, with a dimension-shrink fallback, all via the Canvas API. Your image never leaves the tab. Here's how it works.
The problem
If you've ever filled out a government, exam, or visa form online, you've hit this:
- "Upload your signature as a JPG between 10β20 KB."
- "Photo must be under 50 KB, 200Γ230 px."
- "File size should not exceed 100 KB."
These limits are strict and dumb. And the tools people reach for β the top Google results for "compress image to 50 KB" β almost all work the same way: you upload your file to their server, it gets compressed somewhere, and you download the result. For a signature or an ID photo, that's a genuinely bad deal. Your file sits on someone's server, maybe logged, maybe cached, for a 50 KB convenience.
There's no technical reason it needs to. Browsers have had everything required to do this locally for years. So I built one that does.
The core idea: compression is a search problem
The naive approach is "lower the JPEG quality until it's small enough." But quality is a knob from 0 to 1, and the relationship between quality and file size is non-linear and image-dependent. Guessing wastes encodes.
The right framing: find the highest quality whose encoded size still fits under the target. That's a monotonic search space β higher quality never produces a smaller file β so it's a clean binary search.
function toBlob(canvas, type, quality) {
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}
// At a FIXED resolution, binary-search the quality knob for the highest-quality
// encode that still fits under the target.
async function fitQuality(canvas, type, targetBytes) {
// Best case: even max quality fits. Ship it.
const hi = await toBlob(canvas, type, 0.95);
if (hi && hi.size <= targetBytes) return hi;
// Worst case: even the lowest quality overshoots β quality alone can't do it.
const floor = await toBlob(canvas, type, 0.3);
if (floor.size > targetBytes) return null; // signal: we must shrink dimensions
// Otherwise, search between floor and max for the best quality under target.
let lo = 0.3, hi2 = 0.95, under = floor;
for (let i = 0; i < 7; i++) {
const mid = (lo + hi2) / 2;
const blob = await toBlob(canvas, type, mid);
if (blob.size <= targetBytes) { under = blob; lo = mid; }
else { hi2 = mid; }
}
return under;
}
Seven iterations gets you within ~1% of the quality ceiling β imperceptible, and fast enough to feel instant.
When quality isn't enough: shrink the pixels
Here's the case the naive tools get wrong. A 3000Γ4000 px photo at JPEG quality 0.3 can still be hundreds of KB. You physically cannot reach a 20 KB target by lowering quality alone β there are too many pixels.
So when fitQuality reports that even floor quality overshoots, we drop the resolution. File size scales roughly with pixel area, i.e. with widthΒ², so we can estimate the width that lands near the target instead of blindly halving:
// bytes β area β widthΒ² β to scale bytes by k, scale width by βk.
// Aim for ~90% of target to leave headroom for the quality search.
const ratio = Math.sqrt((targetBytes * 0.9) / floorBlob.size);
const nextWidth = Math.round(currentWidth * ratio);
Then re-run the quality search at the new resolution. A couple of passes converges on the largest image that fits under the limit β you get the best possible quality and hit the byte target, instead of a needlessly tiny, over-compressed result.
The "never grow" guard and transparency
Two details that bite you in production:
- Don't ship a result bigger than the input. Re-encoding an already-small JPEG can produce a larger file. If the best candidate exceeds the original, just hand back the original.
- PNG transparency β JPEG. JPEG has no alpha channel. Naively encoding a transparent PNG gives you black where it should be white (the form will reject it). Flatten onto a white background before encoding:
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(bitmap, 0, 0, w, h);
Why client-side is the right call here
- Privacy is the whole point. Your signature/ID never touches a server. You can verify it: open DevTools β Network, or just turn off Wi-Fi β it still works.
-
It's faster. No upload, no round-trip, no queue. The bottleneck is one
toBlobcall per search step. - It scales to zero cost. The entire thing is static β Astro + vanilla JS, a few KB of tool code, hosted on the edge. No compute bill means no signup, no watermark, no limits to enforce.
Try it / take the code
The live tool is at swiftshrink.com β it does exact KB targets (10 KBβ1 MB), batch, and signature/exam-form presets, all in-browser.
If you're building something similar, the two ideas worth stealing are: treat compression as a binary search over quality, and fall back to a β-ratio dimension shrink when the target is below what quality can reach. That combination is what lets you hit an exact byte budget reliably across wildly different inputs.
Feedback on the compression accuracy or UX is very welcome β that's why I'm posting.











