I often need to prep a batch of images for the web: shrink them, keep the originals intact, and spit out modern formats without hand-tweaking every single file.

For this I use sharp-cli and a small shell script.

I’m not chasing “perfect” compression settings here. These are just the settings I currently use, and they hold up fine for typical web images.

Installing sharp-cli

sharp-cli needs Node.js. Install it globally with npm:

npm install -g sharp-cli

Or run it without installing, via npx:

npx sharp-cli -i "*.png" -o ./output --format webp

The script below expects the sharp command to be on your PATH, so a global install is the simpler option.

The pipeline

The script grabs images from the current directory and dumps everything it generates into ./output.

For PNG files I generate:

  • optimized PNG
  • AVIF
  • WebP

For JPEG files:

  • optimized JPEG
  • AVIF
  • WebP

The resulting structure looks roughly like this:

.
├── image.jpg
├── photo.png
└── output/
    ├── image.jpg
    ├── image.avif
    ├── image.webp
    ├── photo.png
    ├── photo.avif
    └── photo.webp

Originals stay untouched, on purpose.

The script

optimize-images.sh
#!/bin/bash

if ! command -v sharp &> /dev/null; then
  echo "Error: sharp-cli is not installed. Please install it using 'npm install -g sharp-cli'."
  exit 1
fi

INPUT_DIR="."
OUTPUT_DIR="./output"

mkdir -p "$OUTPUT_DIR"

# PNG lossless
sharp -i "$INPUT_DIR/*.png" -o "$OUTPUT_DIR" \
--format png --lossless --effort 6

# JPEG
sharp -i "$INPUT_DIR/*.{jpg,jpeg}" -o "$OUTPUT_DIR" \
--format jpeg \
--quality 90 \
--trellisQuantisation \
--chromaSubsampling 4:4:4 \
--progressive

# JPEG -> AVIF + WebP
sharp -i "$OUTPUT_DIR/*.{jpg,jpeg}" -o "$OUTPUT_DIR" \
--format avif --quality 75 --effort 6 --chromaSubsampling 4:4:4

sharp -i "$OUTPUT_DIR/*.{jpg,jpeg}" -o "$OUTPUT_DIR" \
--format webp --quality 80 --smartSubsample --nearLossless=70

# PNG -> AVIF + WebP
sharp -i "$INPUT_DIR/*.png" -o "$OUTPUT_DIR" \
--format avif --quality 75 --effort 6 --chromaSubsampling 4:4:4

sharp -i "$INPUT_DIR/*.png" -o "$OUTPUT_DIR" \
--format webp --quality 80 --smartSubsample

echo "✓ All images processed successfully."

Why generate multiple formats?

No single image format wins in every situation.

JPEG still matters for compatibility and simplicity. WebP is widely supported and usually cuts size noticeably compared to JPEG. AVIF often shrinks things further, especially for photographs.

So instead of picking one format and committing to it, I generate several and let the browser sort it out.

For example:

<picture>
  <source srcset="/images/photo.avif" type="image/avif">
  <source srcset="/images/photo.webp" type="image/webp">
  <img src="/images/photo.jpg" alt="...">
</picture>

The browser picks whatever it supports best.

PNG

PNG is a different animal from JPEG — it’s what you reach for when lossless compression actually matters: screenshots, UI assets, graphics, anything with transparency.

For those I keep a lossless PNG version:

sharp -i "$INPUT_DIR/*.png" -o "$OUTPUT_DIR" \
  --format png --lossless --effort 6

effort controls how hard the encoder works to shrink the file. Higher effort doesn’t touch the target quality — it just burns more CPU time hunting for a better encoding.

I use 6 as a practical middle ground.

JPEG

For JPEG I don’t just copy the original. I re-encode it:

sharp -i "$INPUT_DIR/*.{jpg,jpeg}" -o "$OUTPUT_DIR" \
  --format jpeg \
  --quality 90 \
  --trellisQuantisation \
  --chromaSubsampling 4:4:4 \
  --progressive

Quality 90

Intentionally conservative. I’m not after maximum compression — I want to trim unnecessary size while keeping the JPEG visually close to the source.

The right value depends heavily on the image, so don’t treat 90 as a universal number.

Progressive JPEG

The progressive option produces a progressive JPEG. Instead of loading in one scan, the browser can show progressively sharper versions as data arrives.

For large images this can make loading feel better even if the final file size ends up about the same.

Chroma subsampling

I explicitly use:

4:4:4

because chroma subsampling can mess with fine color detail, especially around text and sharp edges.

This is one place where I’d rather have predictable quality than squeeze out the last few percent of file size.

AVIF

For both JPEG and PNG sources I generate AVIF:

--format avif --quality 75 --effort 6

AVIF usually gives very good compression on photographic images.

One thing worth knowing: the quality number isn’t comparable across formats. quality 75 in AVIF does not mean the same visual quality as WebP quality 75 or JPEG quality 75 — these are encoder-specific scales.

That’s why I judge settings by looking at the actual output and file sizes, not by trying to match the numbers.

WebP

For JPEG:

--format webp \
--quality 80 \
--smartSubsample \
--nearLossless=70

For PNG:

--format webp \
--quality 80 \
--smartSubsample

Again, none of this is universal — it’s just what works for the kinds of images I usually process.

The important part: compare the result

Image compression isn’t a math problem with one correct answer. A useful test: take the same image and compare original, JPEG, WebP, and AVIF side by side, looking at:

  • file size
  • text and UI details
  • gradients
  • sharp edges
  • noise
  • skin tones
  • transparency where applicable

For a photograph, AVIF might cut size dramatically. For a screenshot full of text and sharp UI elements, the result can look different. There’s no reason to convert everything to AVIF blindly just because it usually wins on size.

When the script isn’t enough

This pipeline is fine for batch processing, but sometimes a specific image needs more attention than a fixed set of flags can give it — a tricky gradient banding in AVIF, a logo losing sharp edges, an image where the automated settings just don’t look right.

For those cases I drop the script and optimize the image by hand in Squoosh. It lets you compare formats and quality levels side by side in the browser, with a live before/after preview, so you can tune settings per image instead of guessing.

What I actually use

The goal is simple:

source image
     │
     ├── JPEG ──→ optimized JPEG
     │       ├──→ WebP
     │       └──→ AVIF
     │
     └── PNG ──→ optimized PNG
             ├──→ WebP
             └──→ AVIF

I keep the source and generate every web variant into a separate directory. That makes the process repeatable, and it means I stop opening an image editor every time I need a web-ready image.

The exact quality values will probably drift over time. The pipeline stays the same: one source image, reproducible processing, several output formats.