lessbytes
v1.2.2
Published
Compress images in the browser or CLI with no quality guessing. SSIM-guided binary search finds the smallest file that still looks identical to the original. Zero dependencies in the browser. Sharp-powered CLI for Node.
Downloads
1,052
Maintainers
Readme
Lessbytes
Compress images without guessing quality. Get the smallest file that still looks identical.
Most image compressors ask you to pick a quality number and hope for the best. The right number is different for every image — a screenshot compresses differently than a photo. Hardcoding quality: 80 works until it doesn't.
Lessbytes treats quality as a search, not a setting. You set a perceptual target. It binary-searches the quality range, encodes the image, decodes it, measures SSIM against the original, and finds the smallest file that still looks the same — in seven iterations.
How it compares
| | Fixed-quality compression | Lessbytes | |---|---|---| | Quality decision | You set it upfront | Per-image binary search | | Accuracy | None — you eyeball it | SSIM score vs. original | | Format | Whatever you pick | AVIF, WebP, JPEG competed; smallest wins | | Photos | May look degraded | Holds quality where it counts | | Flat graphics | Wastes bytes | Strips what the eye can't see | | Typical result | Smaller, sometimes ugly | ~84% smaller, looks the same |
Real example from the test suite:
235 KB JPEG → 37 KB WebPat SSIM 0.992.
Supported formats
JPEG · PNG · WebP · AVIF — with runtime detection so AVIF is only used when the encoder is available.
How it works
Five steps. Step four is where the magic is.
The search. Each iteration: encode at a candidate quality → decode back to pixels → compute SSIM against the original. If it passes the threshold, try lower quality (smaller file). If not, back off. Seven iterations converge within 1% quality resolution.
Downscaling. Instead of one bilinear pass (which blurs fine detail), Lessbytes steps down in stages. Slower, but the output looks better at small dimensions.
Format competition. In auto mode, all supported formats are encoded in parallel. The smallest one that meets the SSIM target is used. You don't decide; the numbers do.
SSIM score reference:
| Score | What it means | |:---:|:---| | 1.000 | Identical pixels | | ≥ 0.998 | Below the threshold of human vision | | ~0.970 | Visible only in a direct side-by-side comparison | | < 0.950 | Clearly degraded |
The default target is 0.992 — chosen by testing against many different images. It's the lowest value where no visible degradation appears without a diff view.
Install
npm install lessbytesOr in the browser — no build step needed:
<script src="https://unpkg.com/lessbytes"></script>
<script>
lessbytes.compress(file).then(r => console.log(`${(r.ratio * 100).toFixed(1)}% smaller`));
</script>API
// ESM / bundlers (Vite, webpack, Rollup, esbuild)
import { compress, compressBatch, computeSSIM, isFormatSupported } from 'lessbytes';
// CommonJS
const { compress } = require('lessbytes');
// Browser, no build step — exposes a global `lessbytes`
// <script src="https://unpkg.com/lessbytes"></script>compress(input, options?) → Promise<CompressResult>
input: File | Blob | HTMLImageElement | URL string.
const result = await compress(file, {
format: 'webp', // 'webp' | 'auto' | 'jpeg' | 'png' | 'avif'
targetSSIM: 0.992, // raise for more quality; lower to push smaller
maxWidth: 1920, // aspect ratio preserved, never upscales
maxHeight: null,
targetSize: 100 * 1024, // hard ceiling in bytes — useful for upload limits
background: '#ffffff', // fill used when flattening alpha into a lossy format
keepSmallest: true // return original if compression makes it larger
});
result.blob // Blob — the output
result.format // 'webp'
result.ssim // 0.9924
result.ratio // 0.84 (84% smaller)
result.quality // 0.93 (quality the search settled on)
result.toFile('upload.webp') // wraps blob as a FileA typical upload handler:
input.addEventListener('change', async () => {
const { blob, toFile } = await compress(input.files[0], { maxWidth: 1920 });
preview.src = URL.createObjectURL(blob);
formData.append('photo', toFile('photo.webp'));
});compressBatch(inputs, options?) → Promise<CompressResult[]>
Process multiple images with a concurrency limit and a progress callback.
const results = await compressBatch(files, {
concurrency: 3,
maxWidth: 1600,
onProgress: (done, total, last) => {
console.log(`${done}/${total} — last: ${(last.ratio * 100).toFixed(0)}% smaller`);
}
});computeSSIM(a: ImageData, b: ImageData) → number
The same SSIM function the search loop uses, exposed directly. Both buffers must be the same size. Useful for validating compression quality in your own pipeline.
isFormatSupported(mime: string) → boolean
Checks whether the browser can encode a given mime type. Lessbytes uses this internally before trying AVIF. You can use it to show or hide format options in your UI.
CLI
The CLI uses sharp (libvips) for fast Node-side encoding. It's an optional dependency — the browser bundle doesn't know about it.
Installation
Install globally to get the lessbytes command. Running via npx is blocked.
npm install -g lessbytes
lessbytes <files...|dir> [options]If sharp fails to install, the CLI tells you exactly how to add it:
npm install -g sharp # if lessbytes is installed globally
npm install sharp # for a local projectInteractive mode
Run lessbytes with no arguments to launch the step-by-step interface:
lessbytes Image file or folder › photo.jpg
Output format
1 Auto — compete AVIF / WebP / JPEG, keep the smallest (default)
2 WebP
3 AVIF
4 JPEG
5 PNG
› 1
Compression mode
1 Smart — visually lossless (SSIM-guided search) (default)
2 Target file size
3 Fixed quality
› 1
Max width in px (blank = no limit) › 1920
Output path (blank = beside source) › build/img| Interactive mode | Equivalent flag | What it does |
|---|---|---|
| Smart | (default) | SSIM-guided search for the smallest visually-lossless file |
| Target file size | --max-size | Searches quality, then downscales to hit the byte budget |
| Fixed quality | --quality | Encodes once at the quality you set — no search |
Use -i / --interactive to force the interface in a non-interactive shell.
Direct usage
lessbytes photo.jpg # → photo.min.webp, beside the source
lessbytes *.png -o out/ # batch a glob into a folder
lessbytes hero.png --format webp --quality 80
lessbytes banner.jpg --max-size 100kb # hit a hard size budget
lessbytes huge.jpg --max-width 1920 # cap dimensions, keep aspect ratio
lessbytes ./assets -r -o build/img # recurse a directory tree
lessbytes shot.png --format avif --ssim 0.995
lessbytes icon.png --keep-larger # always write, even if biggerAll flags
| Flag | Description | Default |
|---|---|---|
| -o, --output <path> | Output file or directory | *.min.* beside source |
| -f, --format <fmt> | auto · jpeg · webp · png · avif | webp |
| -q, --quality <1-100> | Force a fixed quality — skips the SSIM search | — |
| --max-size <size> | Hard size ceiling, e.g. 100kb, 1.5mb, 512b | — |
| --max-width <px> | Cap output width, aspect ratio preserved | — |
| --max-height <px> | Cap output height, aspect ratio preserved | — |
| --ssim <0-1> | Perceptual target for the quality search | 0.992 |
| -r, --recursive | Recurse into subdirectories | off |
| --suffix <str> | Suffix appended for in-place output | .min |
| --keep-larger | Write output even if it ends up bigger than the source | off |
| -s, --silent | Print errors only | off |
| -i, --interactive | Force the interactive interface | off |
| --logo | Print the logo banner and exit | — |
| -h, --help | Show help | — |
| -v, --version | Print the version | — |
Format selection
webp(default) — encodes to WebP.auto— opaque images compete WebP, AVIF, and JPEG; transparent images compete WebP, AVIF, and PNG. The smallest result wins. AVIF is skipped if yoursharpbuild can't encode it.- Explicit format — only that format is tried. JPEG flattens transparency onto the background color (white by default).
How --max-size works
It's a true ceiling. First Lessbytes searches quality. If even the lowest quality is still too large, it progressively scales down dimensions (80% steps) and searches again. If a target is impossible (e.g. --max-size 1b), it writes the smallest it can and marks the row with ⚠ over target.
Reading the output
lessbytes v1.2.0 compressing 1 image
✓ photo.jpg 235.8 KB → 36.9 KB -84% webp q93 ssim 0.992
Done. 235.8 KB → 36.9 KB (84.4% smaller)Each row shows: file name, original → output size, percent saved, format used, quality the search settled on, and the measured SSIM. Downscaled results show e.g. 60% scale. If the original was smaller, it shows kept original.
Exit codes
| Code | Meaning |
|---|---|
| 0 | All images processed (or nothing to do) |
| 1 | Bad arguments, no images found, sharp missing, or every image failed |
A run where some images succeed and some fail exits 0. Failures are shown per row.
Browser compatibility
Works in any browser that supports Canvas and toBlob — which covers everything released in the last several years. AVIF encoding is checked at runtime via isFormatSupported and falls back to WebP. JPEG is the universal fallback. WebP encoding works in ~97% of active browser installs.
Node: 16+ for the CLI. The browser library has no Node dependency.
Bundlers: ships ESM, CommonJS, and UMD. Tree-shakeable. Works with Vite, webpack, Rollup, esbuild, and plain <script> without any config.
Design notes
Why binary search and not a perceptual codec? Libraries like squoosh use encoder-side perceptual tuning (e.g. butteraugli). That's more precise but needs WebAssembly and a larger bundle. Lessbytes's SSIM search is a close approximation that runs on Canvas only — no Wasm, no build dependencies.
Why 0.992 as the default? It was tested against a wide range of photos and illustrations. At 0.992, no visible degradation appeared in A/B comparisons. Going lower occasionally shows subtle banding on portraits. Going higher uses more bytes for no visible gain.
Why return the original when compression would make it larger? Because the right behavior for an upload handler is to fail gracefully. A 9 KB PNG doesn't benefit from WebP encoding — Lessbytes returns it unchanged instead of making you handle that case yourself.
License
Built by Dev
Compression should be measured, not guessed.
