npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

compress-to-size

v1.0.0

Published

Compress an image to an exact file-size budget in the browser. Searches resolution as well as quality, so small targets are actually reached. Zero dependencies, works in a Web Worker.

Readme

compress-to-size

Compress an image to an exact file-size budget, in the browser.

npm install compress-to-size
import { compressToSize } from "compress-to-size";

const result = await compressToSize(file, { targetKB: 100 });

console.log(result.bytes);         // 99_412
console.log(result.reachedTarget); // true
console.log(result.width, result.height);

Zero dependencies. No upload, no server, no worker script to host. Runs on the main thread or inside a Web Worker.

Why this exists

A form says "under 100 KB". You have a 4 MB photo from a phone.

Every image library gives you a quality knob, so the usual advice is to lower the quality until it fits. Below roughly 50 KB that advice does not work. Measured over ten 12-megapixel photographs, not one reached a 50 KB budget at full resolution at any JPEG quality down to 1. Quality has a floor, and for a 12 MP image that floor sits well above the budgets people are actually handed.

The missing move is obvious once you have seen it fail: shrink the image. What is not obvious is how much. Shrink too little and you still miss the budget. Shrink to a safe thumbnail and you have thrown away resolution you did not need to lose.

So this library searches both axes:

  1. Find the largest resolution at which the budget is reachable at all.
  2. Find the highest quality that fits at that resolution.

The result is the best-looking image that still obeys the limit, rather than the first thing that happened to fit.

What it does that a quality loop does not

| | quality loop | compress-to-size | |---|---|---| | 4 MB photo → 500 KB | fits | fits | | 12 MP photo → 25 KB | misses the budget entirely | fits, by reducing resolution | | Reports what it actually achieved | usually not | reachedTarget, bytes, width, height, quality, scale | | Already-small input | re-encodes it anyway, adding loss | returned untouched (untouched: true) | | Unsupported output format | silently emits PNG | throws, or tells you via isFormatSupported() |

That last row is a real browser trap rather than a hypothetical. canvas.toBlob(cb, "image/avif") does not reject when the browser has no AVIF encoder — it quietly hands back a PNG with the quality argument ignored. Code that trusts the requested MIME type ends up shipping a lossless PNG while believing it produced a small AVIF. This library probes encoders by encoding a 1×1 canvas and comparing the resulting blob.type against what was asked for.

API

compressToSize(source, options?): Promise<CompressResult>

source — anything you are likely to be holding: File, Blob, ImageBitmap, ImageData, ArrayBuffer, Uint8Array, HTMLImageElement, HTMLCanvasElement, or OffscreenCanvas.

options

| option | default | meaning | |---|---|---| | targetBytes | — | Size budget in bytes. Wins over targetKB. | | targetKB | — | Size budget in KB (1 KB = 1024 B). Omit both to encode once at maxQuality. | | format | source's own, else AVIF > WebP > JPEG | Output MIME. Throws if this browser cannot encode it. | | minQuality | 0.05 | Lowest quality the search may use. | | maxQuality | 0.95 | Highest quality the search may use. | | allowDownscale | true | Set false to search quality only. | | minScale | 0.1 | Smallest fraction of the original dimensions allowed. | | maxWidth / maxHeight | — | Cap the starting size before searching. Aspect ratio preserved. | | background | "#ffffff" | Painted behind the image for JPEG, which has no alpha. | | maxEncodes | 24 | Upper bound on encode operations. | | signal | — | AbortSignal to cancel the search. | | onProgress | — | Called after each encode with { encodes, bytes, quality, scale }. |

CompressResult

{
  blob: Blob;          // the encoded image
  bytes: number;       // blob.size
  format: string;      // MIME type actually produced
  width: number;
  height: number;
  quality: number;     // 0-1, what the winning encode used
  scale: number;       // 0-1, fraction of the original dimensions
  encodes: number;     // how many encodes the search spent
  reachedTarget: boolean;
  untouched: boolean;  // true when the input already fit and was passed through
}

reachedTarget is the one to check before promising a user a size. When no combination within your bounds meets the budget, the smallest result found is still returned so you can show something — but the flag will be false.

isFormatSupported(mime): Promise<boolean>

Whether this browser can really encode that MIME type, verified by encoding rather than by sniffing a string.

supportedLossyFormats(): Promise<LossyMime[]>

Every lossy format available here, in ["image/jpeg", "image/webp", "image/avif"] order.

Recipes

Respect a hard upload limit and tell the user when it cannot be met

const r = await compressToSize(file, { targetKB: 50 });
if (!r.reachedTarget) {
  status.textContent =
    `Could not get below 50 KB. Smallest possible was ${Math.round(r.bytes / 1024)} KB.`;
}

Keep resolution, refuse to downscale

await compressToSize(file, { targetKB: 200, allowDownscale: false });

Trade resolution for fewer artifacts

The search maximises resolution first, so a very tight budget can land on a large image at very low quality — measured in Chrome, a 4000×3000 photo given a 25 KB budget comes back as 2050×1537 at quality 0.05, which is visibly blocky. If a smaller, cleaner image suits you better, raise the floor:

await compressToSize(file, { targetKB: 25, minQuality: 0.5 });
// → a smaller image, but no quality below 0.5

minQuality is the knob for this trade-off. There is no universally right answer, which is why the library takes a predictable position rather than guessing: it keeps pixels unless you tell it what they are worth.

Inside a Web Worker — no DOM needed, OffscreenCanvas is used automatically:

self.onmessage = async ({ data }) => {
  const r = await compressToSize(data.file, { targetKB: 100 });
  self.postMessage({ blob: r.blob, reachedTarget: r.reachedTarget });
};

Show progress on a slow, large image

await compressToSize(file, {
  targetKB: 100,
  onProgress: ({ encodes, bytes }) => {
    bar.textContent = `try ${encodes}: ${Math.round(bytes / 1024)} KB`;
  },
});

Cancel when the user picks a different file

const controller = new AbortController();
input.onchange = () => controller.abort();
await compressToSize(file, { targetKB: 100, signal: controller.signal });

How many encodes it costs

The search is deliberately cheap where cheapness is free. Probing a candidate resolution only asks "is the budget reachable here at all?", which one encode at minimum quality answers. Only once the largest reachable resolution is known does the quality search run, spending its encodes where they change what you see.

  • Budget reachable at full size: 1 + up to 7 encodes.
  • Budget needs downscaling: 1 + up to 6 + up to 7 encodes.
  • Never more than maxEncodes (default 24), which bounds the worst case.

Format notes

Output is always a lossy format, because a size budget and lossless encoding are not compatible goals — a lossless encoder has no knob to turn once the pixels are fixed. PNG input is accepted and handled; the output will be JPEG, WebP, or AVIF.

If the source is already a lossy format this browser can encode, that format is kept, so a JPEG in gives a JPEG out. Pass format to override.

Browser support

Needs canvas.toBlob (or OffscreenCanvas.convertToBlob), which is everywhere current. createImageBitmap is used when present and falls back to an <img> decode when it is not — so the DOM path works on older Safari, and the worker path needs a browser with OffscreenCanvas.

AVIF encoding is not universal. It is only chosen automatically when the probe confirms it, and requesting it explicitly on a browser without it throws rather than silently producing a PNG.

Where this came from

Extracted from the compression engine behind Image Machine, a set of browser-side image tools where nothing is uploaded — including its compress-to-a-target-size tools. The two-axis search and the measurement that motivated it were developed there and pulled out as a standalone library.

License

MIT