Introduction – The hidden price of “do it in the browser”
Modern front‑ends often tout “let the browser resize images” as a quick fix for responsive layouts. The idea sounds cheap: ship a single high‑resolution asset, let JavaScript canvas or the object-fit CSS property shrink it on the fly, and you’re done. In practice this approach inflates page weight, burns battery, and introduces jitter that hurts Core Web Vitals. This article explains why you should avoid client‑side image resizing altogether and walks you through a production‑ready build‑time pipeline that generates a full srcset suite with Sharp.
What actually happens when the browser resizes an image?
When a large JPEG or WebP is downloaded, the browser decodes the full bitmap into memory before applying CSS scaling. For a 4 MB photograph on a 1080p phone, the decode step can consume 8–12 MB of RAM and a non‑trivial amount of CPU cycles. Mobile devices will throttle the script, leading to visible “jank” during scrolling. Moreover, the network payload remains unchanged – you still pay for the full‑size file.
// Example of a naive client‑side resize using canvas
const img = new Image();
img.src = '/assets/photo.jpg';
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const maxWidth = 800;
const scale = Math.min(1, maxWidth / img.width);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/webp', 0.85);
document.getElementById('preview').src = dataUrl;
};
The snippet above illustrates the hidden work: the full image is transferred, decoded, drawn, and re‑encoded in JavaScript before it ever reaches the DOM. The cost is paid on every page view, and the resulting data URL is often larger than a carefully prepared server‑side WebP version.
Build‑time solution – Generate a srcset with Sharp
By moving the heavy lifting to a CI step, you gain deterministic output, cache‑friendly assets, and the ability to serve the optimal size to every device. Below is a minimal Node.js script that scans a directory, creates three width variants (480 px, 768 px, 1200 px), converts them to WebP, and writes a JSON manifest that can be consumed by your static site generator.
// scripts/optimize-images.js
const fs = require('fs');
const path = require('path');
const sharp = require('sharp');
const INPUT_DIR = path.resolve(__dirname, '../src/assets/images');
const OUTPUT_DIR = path.resolve(__dirname, '../public/images');
const SIZES = [480, 768, 1200]; // widths in pixels
const MANIFEST = {};
async function processImage(file) {
const name = path.parse(file).name;
const ext = path.parse(file).ext;
const srcPath = path.join(INPUT_DIR, file);
MANIFEST[name] = [];
for (const width of SIZES) {
const outFile = `${name}-${width}.webp`;
const outPath = path.join(OUTPUT_DIR, outFile);
await sharp(srcPath)
.resize({ width })
.webp({ quality: 80 })
.toFile(outPath);
MANIFEST[name].push({
src: `/images/${outFile}`,
width,
});
}
}
async function run() {
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const files = fs.readdirSync(INPUT_DIR).filter(f => /\.(jpe?g|png)$/i.test(f));
for (const file of files) {
await processImage(file);
}
fs.writeFileSync(
path.join(OUTPUT_DIR, 'manifest.json'),
JSON.stringify(MANIFEST, null, 2)
);
console.log('Image optimization complete.');
}
run().catch(err => {
console.error(err);
process.exit(1);
});
The script does three things that client‑side code cannot replicate efficiently:
- Parallel processing: Sharp uses libvips under the hood, taking advantage of all CPU cores.
- Lossless format selection: WebP delivers 25‑30 % size reduction versus JPEG at comparable visual quality.
- Cache‑friendly output: The generated files have deterministic names, making CDN edge caching trivial.
Integrating the manifest into a React component
With the manifest in place, you can write a tiny helper that builds a <picture> element automatically. This keeps the component declarative and removes any runtime image‑processing logic.
// src/components/ResponsiveImage.jsx
import React from 'react';
import manifest from '../../public/images/manifest.json';
export default function ResponsiveImage({ name, alt, className }) {
const sources = manifest[name];
if (!sources) {
console.warn(`No image variants found for ${name}`);
return null;
}
const srcSet = sources.map(s => `${s.src} ${s.width}w`).join(', ');
const sizes = '(max-width: 600px) 480px, (max-width: 960px) 768px, 1200px';
// Use the largest variant as the fallback src
const fallback = sources[sources.length - 1].src;
return (
<picture className={className}>
<source type="image/webp" srcSet={srcSet} sizes={sizes} />
<img src={fallback} alt={alt} loading="lazy" decoding="async" />
</picture>
);
}
The component emits a srcset that lets the browser pick the smallest file that satisfies the current viewport width. The loading="lazy" attribute defers off‑screen images, further improving First Contentful Paint.
Automating the pipeline with npm scripts and Git hooks
To guarantee that every commit ships optimized assets, bind the image script to the pre‑push hook. This way developers cannot accidentally push un‑optimized images.
// package.json (excerpt)
{
"scripts": {
"optimize:images": "node scripts/optimize-images.js",
"build": "npm run optimize:images && next build"
},
"husky": {
"hooks": {
"pre-push": "npm run optimize:images"
}
}
}
The husky configuration runs the optimizer before any push reaches the remote repository. If the script fails (e.g., a corrupted source file), the push is aborted, keeping the repository clean.
Security and Best Practices
Even though the pipeline runs on trusted CI machines, it’s prudent to treat image files as untrusted input. Sharp automatically sanitizes metadata, but you should still strip EXIF data that could contain GPS coordinates or other privacy‑sensitive information.
// Extend the optimizer to strip metadata
await sharp(srcPath)
.resize({ width })
.withMetadata({ exif: false })
.webp({ quality: 80 })
.toFile(outPath);
Additionally, enforce a maximum file size (e.g., 5 MB) before processing to prevent denial‑of‑service attacks on the build server.
“If you let the browser do the heavy lifting, you pay for it on every device. Move the work to the build step and let the CDN serve the right size.” – Senior Front‑End Engineer, 2026
Conclusion – The pragmatic path forward
Client‑side image resizing may appear convenient, but it silently erodes performance, drains battery, and inflates bandwidth usage. By generating a full srcset suite at build time with a lightweight Sharp pipeline, you gain deterministic asset sizes, better caching, and a measurable boost to Core Web Vitals. The code snippets above form a complete, production‑ready workflow that can be dropped into any static‑site generator or modern React‑based stack.
Adopt the build‑time approach today, and you’ll see faster page loads, lower data costs, and a happier user base—all without sacrificing visual fidelity.