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

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

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 WebP at 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 lessbytes

Or 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 File

A 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 project

Interactive 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 bigger

All 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 your sharp build 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

MIT © Dev


Built by Dev

Compression should be measured, not guessed.