
Build an Automated Image Compression Script with Sharp and SVGO
Got a project?
Let's discuss your project
Introduction
Images are usually the heaviest assets on any website. A single uncompressed photo can push several megabytes down the wire, and a handful of them can tank your Largest Contentful Paint (LCP), inflate your Core Web Vitals scores, and slow down first load for visitors on slower connections.
Without a good workflow, compression becomes a manual, forgetful chore designers export, developers upload, and nobody remembers to shrink anything until Lighthouse complains.
In this post, we share the exact workflow we use in our web application development projects: a terminal command that scans every image in the project, finds the heavy ones (over 100 KB), and compresses them automatically using two battle-tested libraries. Sharp for raster images (WebP, PNG, JPEG) and SVGO for SVG files. The best part? It runs in two safe steps so we can preview exactly what will change before touching any file.

Why Image Weight Matters
Before we get to commands, it's worth understanding what's at stake.
- Largest Contentful Paint (LCP): Large hero images are a common cause of slow LCP. If the biggest above-the-fold image is a giant PNG, the browser can't paint until most of it downloads.
- Bandwidth & cost: Every byte served is bandwidth your visitors pay for - on mobile data, that cost is very real.
- SEO & Core Web Vitals: Google uses real-world performance (field data) as a ranking factor. Slow pages lose visibility.
- Repository bloat: Oversized assets slow down clones, builds, and deployments.
Compressing images is one of the cheapest, highest-impact website performance wins available and unlike a lot of “performance work,” it takes minutes, not sprints.
The Tools Behind the Command
Our compression script uses two open-source libraries:
- Sharp: a high-performance Node.js image processing library that can resize, re-encode, and convert images with near-native speed. We use it for WebP, PNG, and JPEG.
- SVGO (SVG Optimizer): a tool that strips unnecessary metadata, comments, and redundant path data from SVG files, making them dramatically smaller without changing how they render.
Both are installed as dev dependencies in package.json, so they never ship to production they only run at build/dev time.
The Two-Step Safe Workflow
The whole workflow is designed to be non-destructive by default. We never write over an image until we've seen exactly what will happen.
Step 1 - Dry run (preview only)
npm run compress:images
This scans every image, calculates what it would compress, how much it would save, and prints the results. Nothing is written to disk.

Step 2 - Apply
npm run compress:images:apply
Only when we're happy with the preview do we run the apply command, which actually overwrites the files with their optimized versions.

Note: The screenshots demonstrate the complete workflow. We first ran the script in DRY RUN mode to safely identify optimization candidates without modifying any files. Once verified, we ran it in APPLY mode to perform the actual compression and reduce image sizes.
This two-step flow prevents surprise changes and lets us review the impact before committing.
How the Script Decides What to Compress
The script (scripts/compress-images.mjs) follows a simple, principled set of rules:
- 100 KB threshold: Only files larger than 100 KB are processed. Small assets are left alone compressing them wastes time and can even increase size.
- Max width of 1920px: Any image wider than 1920px is resized down. This covers virtually all real-world display sizes while avoiding pointless giant dimensions.
- A quality loop: Fresh images often start at quality 82, and the script progressively lowers quality (down to a floor of 55) until the file falls under the threshold. This “try until it fits” approach squeezes out maximum savings while keeping quality acceptable.
- SVG optimization: For SVG files, SVGO runs in multipass mode with the default preset to strip redundant data.
A quick note on that quality floor: dropping to 55 sounds aggressive, but for web-displayed raster images especially ones rendered at reduced widths in a responsive layout the visible difference is negligible. The eye rarely notices the drop; the network tab absolutely does.
Let's Build Something Great Together
Ready to transform your idea into a powerful software solution? Talk to our experts and get a free consultation.
Contact UsThese rules are configurable, so they scale to any project's needs.

A simplified look at the core logic
You don't need the full script to understand the idea here's the shape of the quality loop that does the heavy lifting for raster images:
import sharp from "sharp";const MAX_WIDTH = 1920;const SIZE_THRESHOLD = 100 * 1024; // 100 KBconst QUALITY_START = 82;const QUALITY_FLOOR = 55;async function compressRaster(inputBuffer, format) {let quality = QUALITY_START;let output = inputBuffer;while (quality >= QUALITY_FLOOR) {output = await sharp(inputBuffer).resize({ width: MAX_WIDTH, withoutEnlargement: true }).toFormat(format, { quality }).toBuffer();if (output.length <= SIZE_THRESHOLD) break;quality -= 5;}return output;}
The real script wraps this with file-walking, before/after size comparisons, the dry-run/apply flag, and SVGO handling for .svg files but this is the piece doing the actual work.
Understanding the Output
When you run the dry-run command, you get a clean summary:
=== compress-images (DRY RUN) ===Threshold: >100 KB only | Scanned: 350 imagescompressed (✓) 120.2 KB → 44.8 KB images/blog/hero.webpcompressed (✓) 210.5 KB → 71.3 KB images/team/office.jpgskipped (still large) 340.0 KB → 210.0 KB images/hero/banner.png--- Summary ---Candidates (>100 KB): 12Optimized: 9Still >100 KB after compress: 3Saved: 2.35 MB
Each line shows the file path, its size before and after, and whether it's now under 100 KB. The summary tells you how many files qualify, how many got optimized, and critically how many are still over 100 KB, so you know which need a human decision (like a redesign, cropping, or manual optimization).
That “still >100 KB” bucket matters. It's usually images that are already near the quality floor and can't shrink further without visibly degrading a signal to resize the source asset itself, not just re-run the script.
Wiring It Into the Project
Rather than running a long node command every time, we add it as an npm script:
"scripts": {"compress:images": "node scripts/compress-images.mjs","compress:images:apply": "node scripts/compress-images.mjs --apply"}
This makes the workflow a single, memorable command.
We also hook a separate validation script into the prebuild step, so broken or missing image references are caught before a production build:
"prebuild": "npm run validate:images"
validate:images (scripts/validate-images.mjs) is a lightweight companion script it doesn't compress anything, it just walks the codebase for <img> tags, next/image calls, and CSS background-image references, then confirms the file each one points to actually exists on disk. It catches typos, renamed files, and stale references before they turn into a broken-image icon in production.
Catching image problems early keeps deployments clean and predictable.
When to Run It
We recommend making compression part of your routine, not an afterthought:
- After adding new images - before committing.
- Before every release/build - as part of a quick sanity pass.
- On a schedule - old repos accumulate heavy assets over time; a periodic run keeps them lean.
You can even wire the apply command into your CI pipeline so every merge automatically shrinks any newly added heavy images for example, as a step that runs compress:images:apply and fails the build if it finds files it can't get under the threshold, forcing a manual look before merge.
Final Words
Fast websites don't happen by accident they happen because the team has a repeatable, low-effort workflow. A single terminal command that finds and compresses heavy images is one of the simplest ways to keep your site fast, your visitors happy, and your performance scores green.
Automate it, run it often, and let your images work for your performance not against it.
If you want to try this on your own project, the pattern above (Sharp for raster + SVGO for SVGs, gated behind a dry-run) drops into almost any Node.js codebase with minimal setup no build tool lock-in required.

How to Add LiveKit Video Calling to a Next.js App
Add embedded video & audio calling to Next.js with LiveKit Cloud. Compared vs Twilio, Daily, Agora, Zoom — plus token auth, guests & recording.
Read More
We chose ECS over EKS: what we gained and what we gave up
An honest comparison of ECS vs EKS the costs, tradeoffs, and real-world reasoning behind choosing ECS for a production platform on AWS.
Read More
Upgrading Legacy Systems: From Outdated Technology to Competitive Advantage
Learn how to upgrade legacy systems through application modernization, API integration, cloud migration, security improvements, and incremental system upgrades without disrupting business operations.
Read More
Building Distributed Tracing and Observability with AWS X-Ray
A practical guide to correlating requests across a multi-tier application using correlation IDs, AWS X-Ray segments, and structured logging for faster incident debugging.
Read More
Designing Before and After AI: What Really Changed
A look at how AI has transformed UI/UX design from manual wireframes and slow research to AI-assisted prototyping, design-to-code, and personalization at scale.
Read More
Beyond Prompting: Managing Context and Tokens in AI Coding Tools
Ever wondered why your AI coding agent starts losing context or hits a hard limit mid-task? The answer lies in tokens and the context window. Good AI coding is not about giving the model the most information. It is about giving it the right information at the right time.
Read More
What Is llms.txt? How It Helps Google, AI Search, and Agentic Browsing Find Your Website
Learn what llms.txt is, how it differs from sitemap.xml and robots.txt, and how it can help your site get found by Google, AI search tools, and AI agents.
Read More
The Right Way to Migrate from MySQL to AWS Aurora DSQL
Migrating a production database is one of the highest-risk changes you can make to an application. Moving from MySQL to AWS Aurora DSQL raises the stakes further...
Read More
From Memory Nightmare to Serverless: Bundling Files into a ZIP with AWS Lambda
A straightforward 'download all these files as one ZIP' request that worked perfectly on my laptop and... fell over the first day it met real production load. Here's the debugging story, the scaling options I ruled out, and why AWS Lambda was the right answer.
Read More