Jamstack architecture has revolutionized web development with its promise of speed, security, and scalability. But when it comes to image optimization, developers face a crucial decision: should you optimize images at build time (static) or runtime (dynamic)?
The choice impacts everything from build performance to user experience, and there's no one-size-fits-all answer. Let's explore both approaches, their trade-offs, and how to choose the right strategy for your Jamstack application.
Understanding the Jamstack Image Challenge
Traditional server-side applications can optimize images on-demand, but Jamstack's pre-built nature creates unique constraints and opportunities:
// The Jamstack image optimization spectrum
const imageOptimizationApproaches = {
static: {
when: "Build time",
where: "CI/CD pipeline",
pros: ["Fast runtime", "Predictable performance", "No server load"],
cons: ["Slow builds", "Storage overhead", "Limited personalization"]
},
dynamic: {
when: "Runtime/Request time",
where: "Edge functions/CDN",
pros: ["Fast builds", "Personalization", "Storage efficient"],
cons: ["Runtime latency", "Processing costs", "Complexity"]
},
hybrid: {
when: "Build + Runtime",
where: "Pipeline + Edge",
pros: ["Best of both worlds", "Flexible optimization"],
cons: ["Increased complexity", "Harder debugging"]
}
};
Static Optimization: Build-Time Processing
Static optimization pre-processes all images during the build step, generating optimized variants that are deployed as static assets.
Implementation with Next.js
// next.config.js - Static optimization configuration
const nextConfig = {
images: {
// Disable default optimization for static export
unoptimized: true,
// Define image sizes for static generation
deviceSizes: [375, 768, 1024, 1440, 1920],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
// Enable static export
output: 'export',
trailingSlash: true,
};
module.exports = nextConfig;
// Custom build script for static image optimization
const sharp = require('sharp');
const glob = require('glob');
const path = require('path');
const fs = require('fs').promises;
class StaticImageOptimizer {
constructor(options = {}) {
this.options = {
inputDir: './public/images',
outputDir: './out/images',
formats: ['webp', 'avif', 'jpg'],
sizes: [375, 768, 1024, 1440, 1920],
quality: { jpg: 85, webp: 80, avif: 65 },
...options
};
}
async optimizeAll() {
const images = glob.sync(`${this.options.inputDir}/**/*.{jpg,jpeg,png}`);
const startTime = Date.now();
console.log(`Starting static optimization of ${images.length} images...`);
// Process images in parallel batches to avoid memory issues
const batchSize = 5;
for (let i = 0; i < images.length; i += batchSize) {
const batch = images.slice(i, i + batchSize);
await Promise.all(batch.map(imagePath => this.optimizeImage(imagePath)));
console.log(`Processed ${Math.min(i + batchSize, images.length)}/${images.length} images`);
}
const duration = (Date.now() - startTime) / 1000;
console.log(`Static optimization completed in ${duration}s`);
return this.generateManifest();
}
async optimizeImage(imagePath) {
const relativePath = path.relative(this.options.inputDir, imagePath);
const parsedPath = path.parse(relativePath);
const outputBase = path.join(this.options.outputDir, parsedPath.dir, parsedPath.name);
// Ensure output directory exists
await fs.mkdir(path.dirname(outputBase), { recursive: true });
const input = sharp(imagePath);
const metadata = await input.metadata();
const variants = [];
// Generate responsive sizes for each format
for (const format of this.options.formats) {
for (const size of this.options.sizes) {
// Skip if original is smaller than target size
if (metadata.width < size) continue;
const outputPath = `${outputBase}-${size}.${format}`;
try {
let pipeline = input.clone().resize(size, null, {
withoutEnlargement: true,
kernel: sharp.kernel.lanczos3
});
// Apply format-specific optimization
switch (format) {
case 'avif':
pipeline = pipeline.avif({
quality: this.options.quality.avif,
effort: 4
});
break;
case 'webp':
pipeline = pipeline.webp({
quality: this.options.quality.webp,
effort: 4
});
break;
case 'jpg':
pipeline = pipeline.jpeg({
quality: this.options.quality.jpg,
progressive: true,
mozjpeg: true
});
break;
}
await pipeline.toFile(outputPath);
const stats = await fs.stat(outputPath);
variants.push({
path: outputPath.replace(this.options.outputDir, ''),
format,
width: size,
size: stats.size
});
} catch (error) {
console.warn(`Failed to generate ${outputPath}:`, error.message);
}
}
}
return {
original: relativePath,
variants
};
}
async generateManifest() {
// Create a manifest for runtime image selection
const manifestPath = path.join(this.options.outputDir, 'image-manifest.json');
const images = glob.sync(`${this.options.outputDir}/**/*.{jpg,webp,avif}`);
const manifest = {};
images.forEach(imagePath => {
const relativePath = path.relative(this.options.outputDir, imagePath);
const match = relativePath.match(/(.+)-(\d+)\.(jpg|webp|avif)$/);
if (match) {
const [, baseName, width, format] = match;
if (!manifest[baseName]) {
manifest[baseName] = {};
}
if (!manifest[baseName][format]) {
manifest[baseName][format] = [];
}
manifest[baseName][format].push({
width: parseInt(width),
path: `/${relativePath}`
});
}
});
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
return manifest;
}
}
// Build script integration
async function buildWithImageOptimization() {
const optimizer = new StaticImageOptimizer();
await optimizer.optimizeAll();
// Continue with regular build
const { spawn } = require('child_process');
return new Promise((resolve, reject) => {
const build = spawn('npm', ['run', 'build:next'], { stdio: 'inherit' });
build.on('close', code => code === 0 ? resolve() : reject());
});
}
if (require.main === module) {
buildWithImageOptimization().catch(console.error);
}
Gatsby Static Image Processing
// gatsby-config.js - Comprehensive static image setup
module.exports = {
plugins: [
{
resolve: `gatsby-plugin-image`,
options: {
// Global image processing defaults
defaults: {
formats: [`auto`, `webp`, `avif`],
placeholder: `blurred`,
quality: 80,
breakpoints: [375, 768, 1024, 1440, 1920],
backgroundColor: `transparent`,
}
}
},
{
resolve: `gatsby-plugin-sharp`,
options: {
defaults: {
formats: [`auto`, `webp`, `avif`],
quality: 80,
placeholder: `blurred`,
}
}
},
{
resolve: `gatsby-transformer-sharp`,
options: {
checkSupportedExtensions: false,
}
}
]
};
// Static image component with Gatsby
import React from 'react';
import { StaticImage, GatsbyImage, getImage } from 'gatsby-plugin-image';
// For static images known at build time
const HeroSection = () => (
<section className="hero">
<StaticImage
src="../images/hero.jpg"
alt="Hero image"
placeholder="blurred"
formats={["auto", "webp", "avif"]}
quality={90}
width={1920}
height={1080}
transformOptions={{
fit: "cover",
cropFocus: "center"
}}
loading="eager" // For above-fold content
/>
</section>
);
// For dynamic images from GraphQL
const ProductGrid = ({ products }) => (
<div className="product-grid">
{products.map(product => {
const image = getImage(product.featuredImage);
return (
<div key={product.id} className="product-card">
<GatsbyImage
image={image}
alt={product.name}
formats={["auto", "webp", "avif"]}
aspectRatio={4/3}
transformOptions={{
fit: "cover"
}}
/>
</div>
);
})}
</div>
);
Nuxt 3 Static Generation
// nuxt.config.ts - Static image optimization
export default defineNuxtConfig({
nitro: {
prerender: {
routes: ['/sitemap.xml']
}
},
image: {
// Static generation settings
provider: 'static',
dir: 'assets/images',
// Pre-generate these sizes
screens: {
xs: 375,
sm: 768,
md: 1024,
lg: 1440,
xl: 1920
},
// Build-time format generation
formats: ['webp', 'avif'],
// Quality settings per format
quality: 80,
// Enable static generation
staticFilename: '[publicPath]/images/[name]-[hash][ext]'
},
hooks: {
// Custom build hook for additional processing
'build:before': async () => {
console.log('Pre-processing images for static generation...');
await generateStaticImages();
}
}
});
// Custom static image generation
async function generateStaticImages() {
const optimizer = new StaticImageOptimizer({
inputDir: './assets/images',
outputDir: './.nuxt/dist/images'
});
await optimizer.optimizeAll();
}
Static Optimization Pros and Cons
Advantages:
- Blazing fast runtime performance - no processing overhead
- Predictable CDN caching - all variants pre-generated
- No server costs - purely static assets
- Offline-friendly - works without network connectivity
- SEO optimized - all images discoverable at build time
Limitations:
- Massive build times - can take 10-30+ minutes for large sites
- Storage explosion - 5-20x more files to store and deploy
- No personalization - can't adapt to user preferences
- Memory constraints - build environments may run out of RAM
- Limited CMS flexibility - requires rebuild for new images
Dynamic Optimization: Runtime Processing
Dynamic optimization processes images on-demand using edge functions, serverless, or CDN-based transformation.
Next.js with Vercel Edge Functions
// pages/api/images/[...params].js - Dynamic image optimization
import sharp from 'sharp';
export default async function handler(req, res) {
const { params } = req.query;
const [filename, width, quality, format] = params;
try {
// Get original image
const originalImage = await fetchOriginalImage(filename);
// Apply dynamic optimizations
let pipeline = sharp(originalImage)
.resize(parseInt(width), null, {
withoutEnlargement: true,
kernel: sharp.kernel.lanczos3
});
// Apply format-specific settings
const formatOptions = getFormatOptions(format, parseInt(quality));
pipeline = applyFormat(pipeline, format, formatOptions);
const optimizedBuffer = await pipeline.toBuffer();
// Set appropriate headers
res.setHeader('Content-Type', `image/${format}`);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.setHeader('Vary', 'Accept');
res.send(optimizedBuffer);
} catch (error) {
console.error('Image optimization failed:', error);
res.status(500).json({ error: 'Image optimization failed' });
}
}
function getFormatOptions(format, quality) {
const options = {
webp: { quality, effort: 4 },
avif: { quality: Math.max(quality - 15, 50), effort: 4 },
jpeg: { quality, progressive: true, mozjpeg: true },
jpg: { quality, progressive: true, mozjpeg: true }
};
return options[format] || options.jpeg;
}
function applyFormat(pipeline, format, options) {
switch (format) {
case 'webp':
return pipeline.webp(options);
case 'avif':
return pipeline.avif(options);
case 'jpeg':
case 'jpg':
return pipeline.jpeg(options);
default:
return pipeline.jpeg(options);
}
}
async function fetchOriginalImage(filename) {
// Fetch from your storage (S3, Cloudinary, etc.)
const response = await fetch(`${process.env.IMAGE_STORAGE_URL}/${filename}`);
return response.buffer();
}
// Dynamic image component
import { useState, useEffect } from 'react';
const DynamicImage = ({
src,
alt,
width,
height,
quality = 80,
formats = ['avif', 'webp', 'jpg']
}) => {
const [supportedFormat, setSupportedFormat] = useState('jpg');
const [sizes, setSizes] = useState([]);
useEffect(() => {
// Detect format support
detectBestFormat(formats).then(setSupportedFormat);
// Generate responsive sizes
const responsiveSizes = generateSizes(width);
setSizes(responsiveSizes);
}, []);
const generateImageUrl = (size, format) => {
return `/api/images/${encodeURIComponent(src)}/${size}/${quality}/${format}`;
};
const generateSrcSet = () => {
return sizes
.map(size => `${generateImageUrl(size, supportedFormat)} ${size}w`)
.join(', ');
};
return (
<img
src={generateImageUrl(width, supportedFormat)}
srcSet={generateSrcSet()}
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt={alt}
width={width}
height={height}
loading="lazy"
/>
);
};
async function detectBestFormat(formats) {
for (const format of formats) {
if (await supportsFormat(format)) {
return format;
}
}
return 'jpg';
}
function supportsFormat(format) {
return new Promise(resolve => {
const testImages = {
webp: 'data:image/webp;base64,UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==',
avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEDQgMgkQAAAAB8dSLfI='
};
if (!testImages[format]) {
resolve(false);
return;
}
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => resolve(false);
img.src = testImages[format];
});
}
function generateSizes(maxWidth) {
const breakpoints = [375, 768, 1024, 1440];
return breakpoints.filter(bp => bp <= maxWidth);
}
Cloudflare Workers for Edge Optimization
// cloudflare-worker.js - Edge-based dynamic optimization
export default {
async fetch(request, env) {
const url = new URL(request.url);
const cache = caches.default;
// Parse image parameters from URL
const params = parseImageParams(url.pathname);
if (!params) {
return new Response('Invalid image URL', { status: 400 });
}
// Check cache first
const cacheKey = new Request(url.toString(), request);
let response = await cache.match(cacheKey);
if (response) {
return response;
}
try {
// Fetch original image
const originalResponse = await fetch(params.originalUrl);
const originalBuffer = await originalResponse.arrayBuffer();
// Apply optimizations
const optimizedBuffer = await optimizeImage(originalBuffer, params);
// Create response with appropriate headers
response = new Response(optimizedBuffer, {
headers: {
'Content-Type': `image/${params.format}`,
'Cache-Control': 'public, max-age=31536000',
'Vary': 'Accept',
'X-Image-Optimized': 'true'
}
});
// Cache the response
await cache.put(cacheKey, response.clone());
return response;
} catch (error) {
console.error('Edge optimization failed:', error);
return new Response('Optimization failed', { status: 500 });
}
}
};
function parseImageParams(pathname) {
// Parse URL pattern: /images/w_800,q_80,f_webp/image-name.jpg
const match = pathname.match(/\/images\/w_(\d+),q_(\d+),f_(\w+)\/(.+)/);
if (!match) return null;
const [, width, quality, format, filename] = match;
return {
width: parseInt(width),
quality: parseInt(quality),
format,
filename,
originalUrl: `${ORIGIN_URL}/${filename}`
};
}
async function optimizeImage(buffer, params) {
// Use WebAssembly-based image processing
const { optimize } = await import('./image-optimizer.wasm');
return optimize(buffer, {
width: params.width,
quality: params.quality,
format: params.format
});
}
Hybrid Approach: Best of Both Worlds
Many successful Jamstack applications use a hybrid approach, combining static and dynamic optimization strategies.
// Hybrid optimization strategy
class HybridImageOptimizer {
constructor(config) {
this.config = {
// Static optimization for critical images
staticImages: [
'hero/*',
'landing-pages/*',
'logos/*'
],
// Dynamic optimization for content images
dynamicImages: [
'blog/*',
'products/*',
'user-content/*'
],
// Build-time generation for common sizes
staticSizes: [375, 768, 1024],
// Runtime generation for custom sizes
dynamicSizes: true,
...config
};
}
async processImage(imagePath, targetSize, format) {
// Determine if image should be static or dynamic
const isStatic = this.shouldOptimizeStatically(imagePath, targetSize);
if (isStatic) {
return this.getStaticImage(imagePath, targetSize, format);
} else {
return this.getDynamicImage(imagePath, targetSize, format);
}
}
shouldOptimizeStatically(imagePath, targetSize) {
// Check if image matches static patterns
const isStaticPattern = this.config.staticImages.some(pattern =>
minimatch(imagePath, pattern)
);
// Check if size is in static size list
const isStaticSize = this.config.staticSizes.includes(targetSize);
return isStaticPattern && isStaticSize;
}
getStaticImage(imagePath, targetSize, format) {
// Return pre-built static asset path
const staticPath = this.generateStaticPath(imagePath, targetSize, format);
return {
url: staticPath,
type: 'static',
cached: true
};
}
async getDynamicImage(imagePath, targetSize, format) {
// Generate dynamic optimization URL
const dynamicUrl = this.generateDynamicUrl(imagePath, targetSize, format);
return {
url: dynamicUrl,
type: 'dynamic',
cached: false
};
}
generateStaticPath(imagePath, size, format) {
const baseName = path.parse(imagePath).name;
return `/images/static/${baseName}-${size}.${format}`;
}
generateDynamicUrl(imagePath, size, format) {
return `/api/images/${encodeURIComponent(imagePath)}?w=${size}&f=${format}`;
}
}
Framework-Specific Implementations
Astro with Hybrid Optimization
---
// src/components/OptimizedImage.astro
import { getImage } from 'astro:assets';
export interface Props {
src: string;
alt: string;
width: number;
height: number;
loading?: 'lazy' | 'eager';
critical?: boolean;
}
const { src, alt, width, height, loading = 'lazy', critical = false } = Astro.props;
// Use static optimization for critical images
const useStatic = critical || loading === 'eager';
let optimizedImage;
if (useStatic) {
// Static optimization at build time
optimizedImage = await getImage({
src: import(/* @vite-ignore */ src),
width,
height,
format: ['avif', 'webp', 'jpeg'],
quality: critical ? 90 : 80
});
} else {
// Dynamic optimization URL
optimizedImage = {
src: `/api/images/${encodeURIComponent(src)}?w=${width}&h=${height}`,
srcSet: generateDynamicSrcSet(src, width)
};
}
function generateDynamicSrcSet(src, maxWidth) {
const sizes = [375, 768, 1024, 1440].filter(s => s <= maxWidth);
return sizes.map(size =>
`/api/images/${encodeURIComponent(src)}?w=${size} ${size}w`
).join(', ');
}
---
<img
src={optimizedImage.src}
srcset={optimizedImage.srcSet}
alt={alt}
width={width}
height={height}
loading={loading}
fetchpriority={critical ? 'high' : 'auto'}
/>
SvelteKit Dynamic Images
// src/lib/image-optimizer.js
export class SvelteKitImageOptimizer {
constructor() {
this.cache = new Map();
}
generateImageUrl(src, options = {}) {
const {
width = 800,
height,
quality = 80,
format = 'auto'
} = options;
const params = new URLSearchParams({
w: width.toString(),
q: quality.toString(),
f: format
});
if (height) {
params.set('h', height.toString());
}
return `/api/images/${encodeURIComponent(src)}?${params}`;
}
generateSrcSet(src, sizes, options = {}) {
return sizes
.map(size => `${this.generateImageUrl(src, { ...options, width: size })} ${size}w`)
.join(', ');
}
}
<!-- src/lib/components/DynamicImage.svelte -->
<script>
import { SvelteKitImageOptimizer } from '$lib/image-optimizer.js';
import { onMount } from 'svelte';
export let src;
export let alt;
export let width = 800;
export let height;
export let sizes = '100vw';
export let loading = 'lazy';
export let quality = 80;
const optimizer = new SvelteKitImageOptimizer();
let supportedFormat = 'jpg';
let responsiveSizes = [375, 768, 1024, 1440];
onMount(async () => {
// Detect best format support
supportedFormat = await detectBestFormat(['avif', 'webp', 'jpg']);
// Filter sizes based on target width
responsiveSizes = responsiveSizes.filter(size => size <= width);
});
$: imageUrl = optimizer.generateImageUrl(src, {
width,
height,
quality,
format: supportedFormat
});
$: srcSet = optimizer.generateSrcSet(src, responsiveSizes, {
height,
quality,
format: supportedFormat
});
async function detectBestFormat(formats) {
for (const format of formats) {
if (await supportsFormat(format)) {
return format;
}
}
return 'jpg';
}
function supportsFormat(format) {
// Implementation similar to previous examples
return Promise.resolve(format === 'webp'); // Simplified
}
</script>
<img
{src}={imageUrl}
srcset={srcSet}
{sizes}
{alt}
{width}
{height}
{loading}
/>
Performance Comparison and Decision Framework
Build Time Impact
// Performance benchmarking results
const performanceComparison = {
static: {
buildTime: {
small: "2-5 minutes", // <100 images
medium: "10-20 minutes", // 100-500 images
large: "30-60 minutes" // 500+ images
},
runtimePerformance: "Excellent (0ms processing)",
storageMultiplier: "5-15x original size",
cachingEfficiency: "Perfect (static assets)"
},
dynamic: {
buildTime: {
any: "30 seconds - 2 minutes" // Regardless of image count
},
runtimePerformance: "Good (50-200ms processing)",
storageMultiplier: "1x (original images only)",
cachingEfficiency: "Very good (edge/CDN caching)"
}
};
Decision Matrix
When testing and comparing different optimization approaches, I often use tools like ConverterToolsKit to quickly generate sample images in various formats and sizes. This helps validate the optimization pipeline before implementing either static or dynamic approaches in production.
// Decision framework for choosing optimization strategy
function chooseOptimizationStrategy(projectRequirements) {
const {
imageCount,
buildTimeConstraints,
contentUpdateFrequency,
trafficVolume,
personalizationNeeds,
teamSize,
budget
} = projectRequirements;
let score = {
static: 0,
dynamic: 0,
hybrid: 0
};
// Image count impact
if (imageCount < 100) {
score.static += 3;
score.hybrid += 2;
} else if (imageCount < 500) {
score.hybrid += 3;
score.dynamic += 2;
} else {
score.dynamic += 3;
score.hybrid += 1;
}
// Build time constraints
if (buildTimeConstraints === 'strict') {
score.dynamic += 3;
score.hybrid += 1;
} else {
score.static += 2;
score.hybrid += 2;
}
// Content update frequency
if (contentUpdateFrequency === 'high') {
score.dynamic += 2;
score.hybrid += 1;
} else {
score.static += 2;
}
// Traffic volume
if (trafficVolume === 'high') {
score.static += 2;
score.hybrid += 3;
} else {
score.dynamic += 1;
}
// Personalization needs
if (personalizationNeeds === 'high') {
score.dynamic += 3;
score.hybrid += 2;
} else {
score.static += 1;
}
// Find the highest scoring approach
const recommendation = Object.entries(score).reduce((a, b) =>
score[a[0]] > score[b[0]] ? a : b
)[0];
return {
recommendation,
scores: score,
reasoning: generateReasoning(recommendation, projectRequirements)
};
}
function generateReasoning(approach, requirements) {
const reasoning = {
static: [
"Excellent runtime performance for high-traffic sites",
"Perfect for content that doesn't change frequently",
"Best SEO optimization with pre-generated assets"
],
dynamic: [
"Handles large image volumes without build time issues",
"Enables real-time personalization and A/B testing",
"Efficient storage usage and flexible optimization"
],
hybrid: [
"Optimizes critical images statically for best performance",
"Handles non-critical images dynamically for flexibility",
"Balances build time with runtime performance"
]
};
return reasoning[approach];
}
Real-World Case Studies
Case Study 1: E-commerce with 10,000+ Product Images
Challenge: Large product catalog with frequent inventory updates and multiple image variants per product.
Initial Approach: Static optimization
- Build time: 45 minutes
- Storage cost: $200/month for image variants
- Update cycle: 3 builds per day = 2.25 hours of build time daily
Solution: Hybrid approach with dynamic fallback
// E-commerce hybrid image strategy
class EcommerceImageStrategy {
constructor() {
this.staticCategories = [
'banners/*',
'landing-pages/*',
'category-headers/*'
];
this.dynamicCategories = [
'products/*',
'user-generated/*',
'reviews/*'
];
}
async getProductImage(productId, variant, size) {
// Check if this is a bestseller (static optimization)
const isBestseller = await this.checkBestsellerStatus(productId);
if (isBestseller && this.isCommonSize(size)) {
return this.getStaticProductImage(productId, variant, size);
}
// Use dynamic optimization for long-tail products
return this.getDynamicProductImage(productId, variant, size);
}
async checkBestsellerStatus(productId) {
// Check if product is in top 20% by sales volume
const salesData = await this.getSalesData(productId);
return salesData.rank <= 0.2;
}
isCommonSize(size) {
const commonSizes = [200, 400, 800]; // Thumbnail, card, hero
return commonSizes.includes(size);
}
getStaticProductImage(productId, variant, size) {
return `/images/products/static/${productId}-${variant}-${size}.webp`;
}
getDynamicProductImage(productId, variant, size) {
return `/api/images/products/${productId}/${variant}?w=${size}&f=auto`;
}
}
Results:
- Build time: 8 minutes (83% reduction)
- Storage cost: $45/month (78% reduction)
- Performance: No noticeable difference in load times
- Flexibility: Real-time product updates without rebuilds
Case Study 2: News Website with Breaking Content
Challenge: Rapid content publication with images from various sources and unpredictable traffic spikes.
Solution: Dynamic-first with intelligent caching
// News site dynamic optimization with edge caching
class NewsImageOptimizer {
constructor() {
this.urgencyLevels = {
breaking: { maxProcessingTime: 100, quality: 75 },
standard: { maxProcessingTime: 500, quality: 85 },
evergreen: { maxProcessingTime: 1000, quality: 90 }
};
}
async optimizeNewsImage(imageUrl, urgency = 'standard') {
const config = this.urgencyLevels[urgency];
// Use aggressive caching for evergreen content
if (urgency === 'evergreen') {
return this.optimizeWithLongCache(imageUrl, config);
}
// Fast processing for breaking news
return this.optimizeWithFastProcessing(imageUrl, config);
}
async optimizeWithFastProcessing(imageUrl, config) {
const startTime = Date.now();
try {
// Parallel processing: multiple sizes simultaneously
const sizes = [375, 768, 1024];
const promises = sizes.map(size =>
this.processImageSize(imageUrl, size, config.quality)
);
const results = await Promise.all(promises);
const processingTime = Date.now() - startTime;
console.log(`News image optimized in ${processingTime}ms`);
return results;
} catch (error) {
// Fallback to original image for breaking news
console.warn('Fast optimization failed, using original:', error);
return [{ url: imageUrl, size: 'original' }];
}
}
async optimizeWithLongCache(imageUrl, config) {
// More aggressive optimization for evergreen content
const formats = ['avif', 'webp', 'jpg'];
const sizes = [375, 768, 1024, 1440];
const variants = [];
for (const format of formats) {
for (const size of sizes) {
const variant = await this.processImageVariant(
imageUrl,
size,
format,
config.quality
);
variants.push(variant);
}
}
return variants;
}
}
Results:
- Article publication speed: No impact (dynamic processing)
- Image load times: 40% improvement with edge caching
- Storage efficiency: 90% reduction vs static approach
- Scalability: Handles traffic spikes without pre-planning
Case Study 3: Portfolio Site Migration
Challenge: Designer portfolio with high-quality images requiring fast builds and excellent visual quality.
Solution: Static optimization with smart build caching
// Portfolio static optimization with incremental builds
class PortfolioImageBuilder {
constructor() {
this.cache = new Map();
this.hashAlgorithm = 'sha256';
}
async buildPortfolioImages() {
const images = await this.discoverImages();
const changedImages = await this.detectChanges(images);
console.log(`Processing ${changedImages.length} changed images...`);
// Only process changed images
for (const image of changedImages) {
await this.optimizePortfolioImage(image);
}
await this.updateManifest();
}
async detectChanges(images) {
const changedImages = [];
for (const image of images) {
const currentHash = await this.calculateHash(image.path);
const cachedHash = this.cache.get(image.path);
if (currentHash !== cachedHash) {
changedImages.push(image);
this.cache.set(image.path, currentHash);
}
}
return changedImages;
}
async optimizePortfolioImage(image) {
// High-quality optimization for portfolio images
const variants = [
{ width: 400, quality: 90, format: 'webp' }, // Thumbnail
{ width: 800, quality: 95, format: 'webp' }, // Grid view
{ width: 1400, quality: 98, format: 'webp' }, // Lightbox
{ width: 2000, quality: 98, format: 'webp' }, // Full size
// AVIF versions for modern browsers
{ width: 400, quality: 85, format: 'avif' },
{ width: 800, quality: 90, format: 'avif' },
{ width: 1400, quality: 95, format: 'avif' },
{ width: 2000, quality: 95, format: 'avif' },
];
const results = await Promise.all(
variants.map(variant => this.generateVariant(image, variant))
);
return results;
}
async generateVariant(image, { width, quality, format }) {
const outputPath = this.getVariantPath(image.path, width, format);
// Skip if variant already exists and is newer than source
if (await this.isVariantCurrent(image.path, outputPath)) {
return { skipped: true, path: outputPath };
}
const pipeline = sharp(image.path)
.resize(width, null, {
withoutEnlargement: true,
kernel: sharp.kernel.lanczos3
});
if (format === 'webp') {
pipeline.webp({ quality, effort: 6 });
} else if (format === 'avif') {
pipeline.avif({ quality, effort: 6 });
}
await pipeline.toFile(outputPath);
return {
generated: true,
path: outputPath,
originalSize: image.size,
optimizedSize: (await fs.stat(outputPath)).size
};
}
}
Results:
- Initial build: 12 minutes for 200 high-res images
- Incremental builds: 30 seconds average
- Image quality: Visually lossless with 60% size reduction
- Developer experience: Fast iteration cycles
Advanced Optimization Strategies
Intelligent Format Selection
// Advanced format selection based on image content analysis
class IntelligentFormatSelector {
constructor() {
this.formatStrengths = {
jpeg: ['photos', 'complex-scenes', 'many-colors'],
webp: ['mixed-content', 'transparency', 'animation'],
avif: ['modern-browsers', 'maximum-compression'],
png: ['simple-graphics', 'transparency', 'text']
};
}
async analyzeAndSelectFormat(imagePath) {
const analysis = await this.analyzeImageContent(imagePath);
const formatScores = {};
for (const [format, strengths] of Object.entries(this.formatStrengths)) {
formatScores[format] = this.calculateFormatScore(analysis, strengths);
}
// Factor in browser support
const supportWeights = {
jpeg: 1.0, // Universal support
webp: 0.96, // 96% support
avif: 0.85, // 85% support
png: 1.0 // Universal support
};
for (const format of Object.keys(formatScores)) {
formatScores[format] *= supportWeights[format];
}
const optimalFormat = Object.entries(formatScores)
.sort(([,a], [,b]) => b - a)[0][0];
return {
format: optimalFormat,
confidence: formatScores[optimalFormat],
alternatives: this.getAlternatives(formatScores)
};
}
async analyzeImageContent(imagePath) {
const image = sharp(imagePath);
const { width, height, channels, density } = await image.metadata();
const stats = await image.stats();
// Analyze color complexity
const colorComplexity = this.calculateColorComplexity(stats);
// Detect transparency
const hasTransparency = channels === 4;
// Estimate compression efficiency
const compressionPotential = await this.estimateCompressionPotential(image);
return {
dimensions: { width, height },
colorComplexity,
hasTransparency,
compressionPotential,
aspectRatio: width / height
};
}
calculateColorComplexity(stats) {
// Analyze color distribution to determine complexity
const entropy = stats.entropy;
const isGrayscale = stats.isOpaque;
if (entropy > 7.5) return 'high';
if (entropy > 6.0) return 'medium';
return 'low';
}
calculateFormatScore(analysis, strengths) {
let score = 0;
// Score based on image characteristics
if (strengths.includes('photos') && analysis.colorComplexity === 'high') {
score += 3;
}
if (strengths.includes('transparency') && analysis.hasTransparency) {
score += 4;
}
if (strengths.includes('maximum-compression') && analysis.compressionPotential > 0.5) {
score += 2;
}
return score;
}
}
Progressive Enhancement with Service Workers
// Service worker for progressive image enhancement
class ImageProgressiveEnhancement {
constructor() {
this.formatSupport = {};
this.networkInfo = {};
this.init();
}
async init() {
await this.detectFormatSupport();
this.observeNetworkChanges();
this.setupCacheStrategy();
}
async detectFormatSupport() {
const formats = ['avif', 'webp'];
for (const format of formats) {
this.formatSupport[format] = await this.testFormat(format);
}
console.log('Format support detected:', this.formatSupport);
}
setupCacheStrategy() {
self.addEventListener('fetch', event => {
if (this.isImageRequest(event.request)) {
event.respondWith(this.handleImageRequest(event.request));
}
});
}
async handleImageRequest(request) {
const url = new URL(request.url);
// Try to serve optimized version
const optimizedRequest = this.createOptimizedRequest(request);
try {
// Check cache first
const cachedResponse = await caches.match(optimizedRequest);
if (cachedResponse) {
return cachedResponse;
}
// Fetch optimized version
const response = await fetch(optimizedRequest);
if (response.ok) {
// Cache successful response
const cache = await caches.open('optimized-images');
cache.put(optimizedRequest, response.clone());
return response;
}
// Fallback to original
return fetch(request);
} catch (error) {
console.warn('Optimized image failed, using fallback:', error);
return fetch(request);
}
}
createOptimizedRequest(originalRequest) {
const url = new URL(originalRequest.url);
// Determine optimal format
let format = 'jpg';
if (this.formatSupport.avif) {
format = 'avif';
} else if (this.formatSupport.webp) {
format = 'webp';
}
// Adjust quality based on network
const quality = this.getOptimalQuality();
// Build optimized URL
url.searchParams.set('f', format);
url.searchParams.set('q', quality);
return new Request(url.toString(), originalRequest);
}
getOptimalQuality() {
const connection = navigator.connection;
if (!connection) return 80;
if (connection.saveData) return 60;
switch (connection.effectiveType) {
case 'slow-2g': return 50;
case '2g': return 60;
case '3g': return 75;
case '4g': return 85;
default: return 80;
}
}
}
// Initialize in service worker
if (typeof self !== 'undefined' && self.registration) {
new ImageProgressiveEnhancement();
}
Performance Monitoring and Analytics
Build Performance Tracking
// Monitor build performance for optimization decisions
class BuildPerformanceTracker {
constructor() {
this.metrics = {
imageProcessing: [],
buildTimes: [],
outputSizes: []
};
}
startImageProcessing(imagePath) {
const startTime = Date.now();
const startMemory = process.memoryUsage();
return {
imagePath,
startTime,
startMemory,
end: (outputPaths) => {
const endTime = Date.now();
const endMemory = process.memoryUsage();
const metric = {
imagePath,
processingTime: endTime - startTime,
memoryDelta: endMemory.heapUsed - startMemory.heapUsed,
outputPaths,
timestamp: new Date().toISOString()
};
this.metrics.imageProcessing.push(metric);
return metric;
}
};
}
generateBuildReport() {
const totalProcessingTime = this.metrics.imageProcessing
.reduce((sum, m) => sum + m.processingTime, 0);
const averageProcessingTime = totalProcessingTime / this.metrics.imageProcessing.length;
const slowestImages = this.metrics.imageProcessing
.sort((a, b) => b.processingTime - a.processingTime)
.slice(0, 10);
const memoryPeaks = this.metrics.imageProcessing
.filter(m => m.memoryDelta > 100 * 1024 * 1024) // >100MB
.sort((a, b) => b.memoryDelta - a.memoryDelta);
return {
summary: {
totalImages: this.metrics.imageProcessing.length,
totalProcessingTime: totalProcessingTime / 1000, // seconds
averageProcessingTime: averageProcessingTime / 1000,
buildRecommendation: this.getBuildRecommendation(totalProcessingTime)
},
slowestImages: slowestImages.map(img => ({
path: img.imagePath,
time: img.processingTime / 1000,
memory: img.memoryDelta / 1024 / 1024 // MB
})),
memoryPeaks
};
}
getBuildRecommendation(totalTime) {
if (totalTime > 20 * 60 * 1000) { // >20 minutes
return 'Consider switching to dynamic optimization or hybrid approach';
} else if (totalTime > 10 * 60 * 1000) { // >10 minutes
return 'Consider optimizing largest images or using incremental builds';
} else {
return 'Current build time is acceptable for static optimization';
}
}
}
// Usage in build script
const tracker = new BuildPerformanceTracker();
async function buildWithTracking() {
const images = glob.sync('./src/images/**/*.{jpg,png}');
for (const imagePath of images) {
const processing = tracker.startImageProcessing(imagePath);
try {
const outputPaths = await optimizeImage(imagePath);
processing.end(outputPaths);
} catch (error) {
console.error(`Failed to process ${imagePath}:`, error);
processing.end([]);
}
}
const report = tracker.generateBuildReport();
console.log('Build Performance Report:', report);
// Save report for trend analysis
await fs.writeFile('./build-reports/images-' + Date.now() + '.json',
JSON.stringify(report, null, 2));
}
Runtime Performance Monitoring
// Monitor runtime image performance
class RuntimeImageMonitor {
constructor() {
this.metrics = new Map();
this.init();
}
init() {
this.observeImageLoading();
this.observeLCP();
this.trackCacheHitRate();
}
observeImageLoading() {
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (entry.initiatorType === 'img') {
this.recordImageMetric(entry);
}
}
}).observe({ entryTypes: ['resource'] });
}
recordImageMetric(entry) {
const url = new URL(entry.name);
const isOptimized = url.pathname.includes('/api/images/') ||
url.searchParams.has('w') ||
url.searchParams.has('f');
const metric = {
url: entry.name,
duration: entry.duration,
size: entry.transferSize,
isOptimized,
format: this.detectFormat(entry.name),
cacheHit: entry.transferSize === 0,
timestamp: Date.now()
};
this.metrics.set(entry.name, metric);
// Alert on slow images
if (entry.duration > 2000) {
console.warn('Slow image detected:', metric);
}
}
detectFormat(url) {
const formatMatch = url.match(/\.(jpg|jpeg|png|webp|avif)/i);
return formatMatch ? formatMatch[1].toLowerCase() : 'unknown';
}
generatePerformanceReport() {
const allMetrics = Array.from(this.metrics.values());
const optimizedMetrics = allMetrics.filter(m => m.isOptimized);
const unoptimizedMetrics = allMetrics.filter(m => !m.isOptimized);
const formatBreakdown = this.groupBy(allMetrics, 'format');
const cacheHitRate = allMetrics.filter(m => m.cacheHit).length / allMetrics.length;
return {
summary: {
totalImages: allMetrics.length,
optimizedImages: optimizedMetrics.length,
optimizationRate: optimizedMetrics.length / allMetrics.length,
averageLoadTime: this.average(allMetrics, 'duration'),
averageSize: this.average(allMetrics, 'size'),
cacheHitRate
},
formatBreakdown: Object.entries(formatBreakdown).map(([format, images]) => ({
format,
count: images.length,
averageSize: this.average(images, 'size'),
averageLoadTime: this.average(images, 'duration')
})),
recommendations: this.generateRecommendations(allMetrics)
};
}
generateRecommendations(metrics) {
const recommendations = [];
const unoptimizedCount = metrics.filter(m => !m.isOptimized).length;
if (unoptimizedCount > 0) {
recommendations.push(
`${unoptimizedCount} images are not optimized - consider implementing dynamic optimization`
);
}
const slowImages = metrics.filter(m => m.duration > 1000);
if (slowImages.length > 0) {
recommendations.push(
`${slowImages.length} images are loading slowly (>1s) - check image sizes and formats`
);
}
const largeImages = metrics.filter(m => m.size > 500000); // >500KB
if (largeImages.length > 0) {
recommendations.push(
`${largeImages.length} images are large (>500KB) - consider better compression or responsive images`
);
}
return recommendations;
}
groupBy(array, key) {
return array.reduce((groups, item) => {
const group = groups[item[key]] || [];
group.push(item);
groups[item[key]] = group;
return groups;
}, {});
}
average(array, key) {
return array.reduce((sum, item) => sum + item[key], 0) / array.length;
}
}
// Initialize monitoring
const runtimeMonitor = new RuntimeImageMonitor();
// Generate reports periodically
setInterval(() => {
const report = runtimeMonitor.generatePerformanceReport();
console.log('Runtime Performance Report:', report);
}, 60000); // Every minute
Conclusion
The choice between static and dynamic image optimization in Jamstack applications isn't binary—it's about finding the right balance for your specific requirements. Here's how to make the decision:
Choose Static When:
- You have <500 images total
- Content updates are infrequent (weekly or less)
- Performance is absolutely critical
- Build time constraints are flexible
- You want the simplest deployment model
Choose Dynamic When:
- You have >1000 images or frequent content updates
- You need personalization or A/B testing
- Build time must be <5 minutes
- Storage costs are a concern
- You're comfortable with edge/serverless infrastructure
Choose Hybrid When:
- You want the best of both worlds
- You can identify critical vs non-critical images
- You have the development resources for complexity
- You need both performance and flexibility
The modern Jamstack ecosystem provides excellent tools for both approaches. Start with the simpler option that meets your current needs, then evolve as your requirements grow. Remember: premature optimization is still the root of all evil—choose the approach that delivers value to your users while maintaining developer productivity.
Key takeaways:
- Static optimization delivers unmatched runtime performance but scales poorly
- Dynamic optimization provides maximum flexibility at the cost of runtime complexity
- Hybrid approaches can capture the benefits of both with careful implementation
- Monitor your metrics to validate your optimization strategy over time
- The right choice depends on your specific constraints and requirements
The image optimization landscape continues to evolve rapidly. Stay flexible, measure real-world performance, and be ready to adapt your strategy as new tools and techniques emerge.
What optimization approach have you chosen for your Jamstack projects? Have you experienced the trade-offs described here, or found other factors that influenced your decision? Share your experiences and insights in the comments!
Top comments (2)
I’ve tried both static and dynamic but hit huge build time issues as sites grew, so hybrid with metrics tracking ended up working best for me.
Curious - how do you handle rebuilds when content updates mid-day, especially for image-heavy sites?
Growth like this is always nice to see. Kinda makes me wonder - what keeps stuff going long-term? Like, beyond just the early hype?