DEV Community

Trilok Singh
Trilok Singh

Posted on

How to make a JPEG hit an exact KB size in the browser (going up, not down)

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:

  1. JPEG quality (cheapest, improves the image)
  2. Pixel dimensions (more pixels = more bytes)
  3. 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));
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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' }
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

(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)