In my last post I wrote about why I built a tool that makes images bigger instead of smaller. A few people asked the obvious follow-up: how do you actually increase a JPEG to a specific size, like 50 KB, without a server?
Here's the approach, step by step, all in plain browser JavaScript.
The goal
A form says: photo must be 20 KB – 50 KB. The user's photo is 12 KB. We want a valid JPEG that lands inside that range, and we want to do it entirely on the user's device, no upload.
There are three levers, and I use them in this order:
- JPEG quality (cheapest, improves the image)
- Pixel dimensions (more pixels = more bytes)
- Padding (last resort, adds bytes without changing the image)
Step 1: Draw the image on a canvas
async function loadToCanvas(file) {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
return canvas;
}
function canvasToJpeg(canvas, quality) {
return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', quality));
}
Step 2: Binary search on quality
File size grows with quality, but not in a straight line. So instead of guessing, binary search for the highest quality that stays under the upper limit.
async function bestQualityUnder(canvas, maxBytes) {
let lo = 0.1, hi = 1.0, best = null;
for (let i = 0; i < 8; i++) {
const q = (lo + hi) / 2;
const blob = await canvasToJpeg(canvas, q);
if (blob.size <= maxBytes) {
best = blob; // fits, try higher
lo = q;
} else {
hi = q; // too big, go lower
}
}
return best;
}
Eight rounds is plenty. If even quality 1.0 is under the minimum, we move to step 3.
Step 3: Add pixels
A 300×300 photo at max quality simply doesn't have enough data to reach 50 KB. So scale it up. File size roughly follows pixel count, so a square-root ratio is a good first guess:
function upscale(canvas, factor) {
const out = document.createElement('canvas');
out.width = Math.round(canvas.width * factor);
out.height = Math.round(canvas.height * factor);
const ctx = out.getContext('2d');
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(canvas, 0, 0, out.width, out.height);
return out;
}
// currentBytes = size at quality 1.0
const factor = Math.sqrt(targetBytes / currentBytes);
After upscaling, run the quality search again. One catch: many forms also have pixel limits (for example a fixed width × height). If the user needs exact dimensions, you can't use this lever freely, which brings us to step 4.
Step 4: Padding with a JPEG comment segment
This is the part most people don't know about. A JPEG file is a list of segments, and one of them is a COM (comment) segment, marker 0xFFFE. Decoders read past it and ignore the content. So you can add bytes to the file without touching a single pixel.
Rules to respect:
- Each segment has a 2-byte length field (which counts itself), so one segment holds at most 65,533 bytes of payload. For bigger gaps, add several segments.
- Insert it after the JFIF header (APP0), not right after the start marker, to keep strict validators happy.
async function padJpeg(blob, targetBytes) {
const bytes = new Uint8Array(await blob.arrayBuffer());
let missing = targetBytes - bytes.length;
if (missing <= 0) return blob;
const segments = [];
while (missing > 0) {
const payload = Math.min(65533, Math.max(missing - 4, 0));
const seg = new Uint8Array(4 + payload).fill(0x20); // spaces
seg[0] = 0xFF;
seg[1] = 0xFE; // COM marker
const len = payload + 2; // length includes these 2 bytes
seg[2] = len >> 8;
seg[3] = len & 0xFF;
segments.push(seg);
missing -= seg.length;
}
// Insert after APP0 (JFIF) if present, else after SOI
let insertAt = 2;
if (bytes[2] === 0xFF && bytes[3] === 0xE0) {
insertAt = 4 + ((bytes[4] << 8) | bytes[5]);
}
return new Blob(
[bytes.slice(0, insertAt), ...segments, bytes.slice(insertAt)],
{ type: 'image/jpeg' }
);
}
The result can overshoot the target by up to 3 bytes (a segment can't be smaller than 4), which is fine when the form accepts a range.
Being honest about padding
Padding does not improve the photo. It only makes the file satisfy a size check. That's exactly what the user needs in this case, because the photo itself was already fine; the form just had a minimum. I still try quality first and pixels second, so the padding only covers the last small gap.
Putting it together
async function increaseToRange(file, minBytes, maxBytes) {
let canvas = await loadToCanvas(file);
let blob = await bestQualityUnder(canvas, maxBytes);
if (blob.size < minBytes) {
const atMax = await canvasToJpeg(canvas, 1.0);
if (atMax.size < minBytes) {
canvas = upscale(canvas, Math.sqrt(minBytes / atMax.size));
blob = await bestQualityUnder(canvas, maxBytes);
}
}
if (blob.size < minBytes) {
blob = await padJpeg(blob, minBytes);
}
return blob;
}
(If the form fixes the pixel dimensions, skip the upscale step and let padding handle the gap.)
Why do it client-side?
These are ID photos and signatures. Nobody should have to upload those to a random server just to change a number. Canvas + Blob does everything above in milliseconds, and the file never leaves the device.
If you want to see it working, this is the tool I built with this approach: Increase Image Size in KB.
Questions or better tricks for the padding step? Happy to hear them in the comments.
Top comments (0)