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-cliOr run it without installing, via npx:
npx sharp-cli -i "*.png" -o ./output --format webpThe 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.webpOriginals stay untouched, on purpose.
The script
#!/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 6effort 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 \
--progressiveQuality 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:4because 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 6AVIF 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=70For PNG:
--format webp \
--quality 80 \
--smartSubsampleAgain, 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
└──→ AVIFI 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.