The Intelligence Layer.

Expert movements in image optimization, web performance, and the technical decisions that drive high-conversion digital experiences.

Batch Image Optimization: How to Process Hundreds of Images Without Breaking Quality or Workflow
Automation & Workflows

Batch Image Optimization: How to Process Hundreds of Images Without Breaking Quality or Workflow

Batch optimization isn't just running a script on a folder of images. It's a system design problem: how to apply different settings per image type, validate output quality, and handle edge cases at scale—without losing a single original.

Priyank

Lead Architect

October 8, 2025

Published

7 min

Read time

Topics

batch optimizationworkflow automationbulk processinglarge websitesimage optimization

Table of Contents

Batch Image Optimization: How to Process Hundreds of Images Without Breaking Quality or Workflow

Most batch optimization guides treat the problem as a simple script: find all the images, compress them, done. What they miss is the failure mode that makes developers hesitant to run optimization at scale in the first place: inadvertently destroying originals, applying compression settings that are wrong for specific image types, and ending up with a catalog of degraded images that can't be recovered.

This guide covers how to build a batch workflow that's actually safe—one where the data risk is managed, quality is validated by image type, and the process scales to thousands of images without manual review.


The Cardinal Rule: Never Modify Originals

Before any processing decisions: establish a source file preservation system.

Every batch optimization workflow should operate in one of two modes:

Mode 1: Generate adjacent compressed files. Keep the original at product.jpg and generate product.avif and product.webp as new files. Your HTML references the new files. The original is untouched.

Mode 2: Process into an output directory. Keep your full source directory intact. Process into a parallel dist/ or optimized/ directory that mirrors the source structure. Your deployment pipeline uses the output directory, not the source.

Never apply destructive in-place compression. sharp('image.jpg').jpeg({ quality: 80 }).toFile('image.jpg') is a trap: if quality settings turn out to be wrong for that image type, the original is gone.


The Categorization Problem

The reason "apply 85% compression to everything" fails: different image types have radically different quality floors.

A product photo on a white background is a high-attention image that customers scrutinize. A lifestyle editorial shot used for ambiance tolerates more compression. A thumbnail shown at 150px is never examined closely. Applying the same compression to all three produces either:

  • Unnecessarily large thumbnails (over-quality)
  • Visibly degraded hero images (under-quality)

Practical category definitions for e-commerce:

Category Identifying Characteristics AVIF Quality WebP Quality
Hero / primary product Largest image per page, full-width q=20–28 q=84–90
Gallery angles Multiple per product, same subject q=25–32 q=80–86
Lifestyle / editorial People in context, environments q=30–38 q=78–84
Category / listing thumbnails displayed at 150–250px q=42–52 q=70–78
Zoom / detail source 2x+ magnification target q=15–20 q=88–95

In a real catalog, categorization can be handled by:

  • Directory convention: images/heroes/*, images/thumbs/*, images/gallery/*
  • Filename pattern: *-hero.jpg, *-thumb.jpg, *-zoom.jpg
  • CMS metadata: Image "type" tag from your DAM or CMS

The batch script reads the category and applies the appropriate settings per category, not a single global setting.


Building a Production-Capable Batch Pipeline with Sharp

sharp is the Node.js image processing library built on libvips. It's fast, handles AVIF and WebP natively, and is mature enough for production batch processing.

// batch-optimize.js
import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';
import fs from 'fs/promises';

const CATEGORY_SETTINGS = {
  hero: { avif: { quality: 24, effort: 7 }, webp: { quality: 87 } },
  gallery: { avif: { quality: 28, effort: 6 }, webp: { quality: 83 } },
  lifestyle: { avif: { quality: 34, effort: 5 }, webp: { quality: 80 } },
  thumb: { avif: { quality: 46, effort: 5 }, webp: { quality: 73 } },
  zoom: { avif: { quality: 18, effort: 9 }, webp: { quality: 92 } },
};

function getCategory(filePath) {
  const name = path.basename(filePath);
  if (name.includes('-hero.')) return 'hero';
  if (name.includes('-thumb.')) return 'thumb';
  if (name.includes('-zoom.')) return 'zoom';
  if (filePath.includes('/lifestyle/')) return 'lifestyle';
  if (filePath.includes('/gallery/')) return 'gallery';
  return 'gallery'; // default
}

async function processImage(inputPath, outputDir, settings) {
  const basename = path.basename(inputPath, path.extname(inputPath));
  const avifPath = path.join(outputDir, `${basename}.avif`);
  const webpPath = path.join(outputDir, `${basename}.webp`);

  const image = sharp(inputPath);
  const meta = await image.metadata();

  await Promise.all([
    image.clone().avif(settings.avif).toFile(avifPath),
    image.clone().webp(settings.webp).toFile(webpPath),
  ]);

  const avifStats = await fs.stat(avifPath);
  const origStats = await fs.stat(inputPath);

  return {
    input: inputPath,
    avif: avifPath,
    webp: webpPath,
    originalBytes: origStats.size,
    avifBytes: avifStats.size,
    savings: Math.round((1 - avifStats.size / origStats.size) * 100),
    width: meta.width,
    height: meta.height,
  };
}

async function batchOptimize(sourceGlob, outputDir) {
  const files = await glob(sourceGlob, { ignore: ['**/node_modules/**'] });
  await fs.mkdir(outputDir, { recursive: true });

  const results = [];
  let processed = 0;

  // Process in concurrent batches of 6
  const CONCURRENCY = 6;
  for (let i = 0; i < files.length; i += CONCURRENCY) {
    const batch = files.slice(i, i + CONCURRENCY);
    const batchResults = await Promise.all(
      batch.map(file => {
        const category = getCategory(file);
        const settings = CATEGORY_SETTINGS[category];
        return processImage(file, outputDir, settings).catch(err => ({
          input: file,
          error: err.message,
        }));
      })
    );
    results.push(...batchResults);
    processed += batch.length;
    console.log(`Progress: ${processed}/${files.length}`);
  }

  return results;
}

// Run
const results = await batchOptimize(
  'src/images/**/*.{jpg,jpeg,png}',
  'dist/images'
);
const totalSavings = results
  .filter(r => !r.error && r.savings)
  .reduce((sum, r) => sum + r.originalBytes - r.avifBytes, 0);

console.log(`\nBatch complete:`);
console.log(`Processed: ${results.length} images`);
console.log(`Errors: ${results.filter(r => r.error).length}`);
console.log(`Total savings: ${Math.round(totalSavings / 1024 / 1024)}MB`);

This script is production-ready: concurrent processing without overwhelming the CPU, error handling per file (a corrupt image doesn't abort the batch), and a savings report.


Quality Validation

The risk with batch processing at scale: you set compression a notch too aggressive and end up with 500 substandard images that go to production unnoticed.

Automatic validation approach: After generating variants, check that AVIF size reduction stays within expected bounds. If a hero image compressed to 12% of its original size (savings of 88%), that's a signal the quality setting was too aggressive. Most well-calibrated hero images should compress to 30–50% of their JPEG original.

function validateResult(result) {
  const savingsPct = 1 - result.avifBytes / result.originalBytes;

  // Flag suspiciously aggressive compression (likely artifacts)
  if (savingsPct > 0.8) {
    return {
      valid: false,
      reason: `Savings too high: ${Math.round(savingsPct * 100)}%`,
    };
  }

  // Flag non-improvement (AVIF larger than JPEG — unusual but can happen with tiny images)
  if (savingsPct < 0) {
    return {
      valid: false,
      reason: 'AVIF larger than original — keep original',
    };
  }

  return { valid: true };
}

Manual spot-check: After a batch run, visually review a random sample of 5% of output images in a side-by-side at 100% zoom. If artifacts appear on specific image types (gradients, dark backgrounds, skin tones), adjust quality settings for those categories and re-run.


The Catalog Migration Approach

For an existing site with hundreds of images, a one-shot batch migration is higher risk than a phased approach:

Phase 1: Process your 20 highest-traffic pages. Deploy their images. Measure LCP changes in Search Console after 4 weeks.

Phase 2: Process the rest of the catalog with validated settings from Phase 1.

Phase 3: Update the build pipeline so new images are automatically processed.

This sequence means your quality settings are validated against real traffic before being applied at scale, and LCP impact is confirmed before committing to the full catalog migration.

For ad-hoc batches outside a pipeline—designers handing over assets, content teams uploading features—TinyImage's browser encoder handles up to multiple images locally with no upload, no server, and quality preview for each output before saving.


One Script to Run for Your Current Catalog

If you want to start immediately, here's the minimum viable batch command using sharp-cli for a directory of mixed images:

# Install
npm install -g sharp-cli

# Convert all JPEGs to AVIF at quality 30, into adjacent files
find ./public/images -name "*.jpg" | xargs -P 6 -I{} sh -c \
  'sharp-cli -i "$1" -f avif --avif-quality 30 -o "${1%.jpg}.avif"' _ {}

# Convert all JPEGs to WebP as fallback
find ./public/images -name "*.jpg" | xargs -P 6 -I{} sh -c \
  'sharp-cli -i "$1" -f webp --webp-quality 83 -o "${1%.jpg}.webp"' _ {}

This processes six images in parallel (-P 6), preserves your originals, and generates AVIF and WebP variants next to each source. Update your HTML to reference the AVIF files with <picture> fallback patterns. Run a sample visual review before deploying.

Deploy Visual Excellence

Put what you've learned into practice with TinyImage.Online - the free, privacy-focused image compression tool that works entirely in your browser.

Infrastructure Optimization

Boost Page Performance Beyond Images

Optimizing image assets is crucial, but speed starts at the server level. Swap to Hostinger for blazing-fast NVMe cloud server configurations that instantly decrease TTFB delays and elevate Core Web Vitals.

Speed Up My Server
Web Performance

Master Web Performance & Core Web Vitals

Sign up to receive our weekly deep dives into speed optimization, Next.js setups, and SEO engineering secrets.

Privacy first. Zero spam. Unsubscribe at any time.

About the Author

P
Priyank
Founder & Web Performance Engineer

Priyank is a web performance engineer specializing in WebAssembly and browser performance. He founded TinyImage.Online to help developers optimize Core Web Vitals scores.

Web PerformanceImage OptimizationWebAssemblyCore Web Vitals
View full profile