Images account for 50-70% of a typical web page's weight. Optimizing them properly — choosing the right format, generating responsive sizes, automating compression, and serving from a CDN — is the single highest-impact performance optimization available. This guide covers the complete image optimization pipeline used at tanstackship.com, from development workflow to production delivery.
Image Format Comparison
| Format | Compression | Transparency | Animation | Browser Support | File Size vs PNG |
|---|---|---|---|---|---|
| JPEG | Lossy | ❌ | ❌ | 100% | 50-70% smaller |
| PNG | Lossless | ✅ | ❌ | 100% | Baseline |
| WebP | Lossy + Lossless | ✅ | ✅ | 97% | 25-35% smaller |
| AVIF | Lossy + Lossless | ✅ | ✅ | 93% | 50-60% smaller |
| JPEG XL | Lossy + Lossless | ✅ | ❌ | Growing | 40-50% smaller |
Recommendation in 2026: Serve AVIF as the primary format with WebP fallback. Most modern browsers (93%) support AVIF, and WebP covers the remaining.
Generating Optimized Images
Automated Build Pipeline
// scripts/optimize-images.ts
// Run during build process
import sharp from "sharp"
import { readdir, readFile, writeFile } from "fs/promises"
import path from "path"
interface ImageConfig {
source: string // Original image path
outputDir: string // Base output directory
formats: string[] // ['avif', 'webp', 'jpeg']
sizes: number[] // [640, 1200, 1920]
quality: number // 80
}
async function optimizeImage(config: ImageConfig) {
const inputBuffer = await readFile(config.source)
const filename = path.parse(config.source).name
for (const format of config.formats) {
for (const width of config.sizes) {
const outputPath = path.join(
config.outputDir,
format,
`${filename}-${width}w.${format}`
)
await sharp(inputBuffer)
.resize(width, undefined, { withoutEnlargement: true })
.toFormat(format as any, { quality: config.quality })
.toFile(outputPath)
console.log(`Created: ${outputPath}`)
}
}
}
Responsive Images with srcset
// components/OptimizedImage.tsx
interface OptimizedImageProps {
src: string // e.g., "/images/dashboard"
alt: string
widths?: number[]
sizes?: string
priority?: boolean
}
function OptimizedImage({
src,
alt,
widths = [640, 1200, 1920],
sizes = "(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw",
priority = false,
}: OptimizedImageProps) {
const formats = ["avif", "webp", "jpeg"]
const suffix = priority ? "fetchPriority='high'" : "loading='lazy'"
return (
<picture>
{/* Modern format first */}
{formats.map((format) => (
<source
key={format}
type={`image/${format === "jpeg" ? "jpeg" : format}`}
srcSet={widths
.map((w) => `${src}-${w}w.${format} ${w}w`)
.join(", ")
}
sizes={sizes}
/>
))}
{/* Fallback */}
<img
src={`${src}-1200w.jpeg`}
srcSet={widths
.map((w) => `${src}-${w}w.jpeg ${w}w`)
.join(", ")
}
alt={alt}
width={1200}
height={675}
decoding="async"
{...(priority ? { fetchPriority: "high" as const } : { loading: "lazy" as const })}
/>
</picture>
)
}
CDN-Based Image Optimization
Using Cloudflare Image Resizing eliminates the need for build-time generation:
// Skip build-time optimization — transform on the fly
function CdnImage({ src, width, alt, priority = false }: CdnImageProps) {
const baseUrl = "https://tanstackship.com/cdn-cgi/image"
// Generate transform URLs
const formats = [
{ format: "avif", url: `${baseUrl}/width=${width},format=avif,quality=80/${src}` },
{ format: "webp", url: `${baseUrl}/width=${width},format=webp,quality=80/${src}` },
{ format: "jpeg", url: `${baseUrl}/width=${width},format=jpeg,quality=85/${src}` },
]
return (
<picture>
{formats.map(({ format, url }) => (
<source key={format} type={`image/${format}`} srcSet={url} />
))}
<img
src={`${baseUrl}/width=${width},format=jpeg,quality=85/${src}`}
alt={alt}
width={width}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : "auto"}
/>
</picture>
)
}
Image Resizing Options
// Available CDN parameters — build helper
interface ImageTransformOptions {
width?: number // 1-4096
height?: number // 1-4096
fit?: "scale-down" | "contain" | "cover" | "crop" | "pad"
quality?: number // 1-100
format?: "webp" | "avif" | "jpeg" | "png"
sharpen?: number // 0-10
blur?: number // 0-250
brightness?: number // -100 to 100
contrast?: number // -100 to 100
gamma?: number // 0.1-9.99
}
function buildImageUrl(basePath: string, opts: ImageTransformOptions): string {
const params = Object.entries(opts)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}=${v}`)
.join(",")
return `/cdn-cgi/image/${params}/${basePath}`
}
Lazy Loading Strategy
// Intelligent lazy loading based on viewport position
function LazyImage({ src, alt, threshold = 0.1 }: LazyImageProps) {
const imgRef = useRef<HTMLImageElement>(null)
const [loaded, setLoaded] = useState(false)
const [inView, setInView] = useState(false)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true)
observer.disconnect()
}
},
{ rootMargin: "200px", threshold }
)
if (imgRef.current) observer.observe(imgRef.current)
return () => observer.disconnect()
}, [threshold])
return (
<div ref={imgRef} className="image-wrapper" style={{ minHeight: "200px" }}>
{inView ? (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
className={loaded ? "loaded" : "loading"}
loading="lazy"
/>
) : (
<div className="image-skeleton" />
)}
</div>
)
}
| Lazy Loading Method | Pros | Cons |
|---|---|---|
Native loading="lazy"
|
Zero JS, browser-native | Less control over threshold |
| Intersection Observer | Precise control | Requires JS |
| Below-fold only | Simple | Manual per-image |
| Priority hints | Works with native lazy | Limited browser support |
Automated Image Pipeline
// server/images/pipeline.ts
export const uploadImage = createServerFn({ method: "POST" }).handler(
async ({ request, context }) => {
const formData = await request.formData()
const file = formData.get("image") as File
// Validation
if (!file.type.startsWith("image/")) throw new Error("Invalid file type")
if (file.size > 10 * 1024 * 1024) throw new Error("File too large")
const buffer = await file.arrayBuffer()
const id = crypto.randomUUID()
const key = `images/${id}`
// Store original
await context.env.MY_BUCKET.put(`${key}/original`, buffer, {
httpMetadata: { contentType: file.type },
})
// Generate thumbnails via Image Resizing
const sizes = [
{ width: 150, suffix: "thumb" },
{ width: 400, suffix: "small" },
{ width: 800, suffix: "medium" },
{ width: 1600, suffix: "large" },
]
for (const { width, suffix } of sizes) {
// Store reference — actual resize happens on CDN
await context.env.MY_BUCKET.put(`${key}/${suffix}.webp`, buffer)
}
return {
id,
urls: {
original: `https://images.tanstackship.com/${key}/original`,
thumb: `https://images.tanstackship.com/cdn-cgi/image/width=150/${key}/original`,
small: `https://images.tanstackship.com/cdn-cgi/image/width=400/${key}/original`,
medium: `https://images.tanstackship.com/cdn-cgi/image/width=800/${key}/original`,
large: `https://images.tanstackship.com/cdn-cgi/image/width=1600/${key}/original`,
},
}
}
)
Performance Impact
| Image Type | Before | After (AVIF + Responsive) | Savings |
|---|---|---|---|
| Hero image (1920px) | 1.2 MB (JPEG) | 180 KB (AVIF) | 85% |
| Blog thumbnail (400px) | 180 KB (JPEG) | 35 KB (AVIF) | 81% |
| Product screenshot (1200px) | 850 KB (PNG) | 120 KB (WebP) | 86% |
| Icon set (32px SVG) | 25 KB | 25 KB (already optimized) | — |
| Total page weight | 2.5 MB | 380 KB | 85% reduction |
Image CDN Cost Comparison
| Provider | Bandwidth | Transformations | Storage |
|---|---|---|---|
| Cloudflare Images | Included with Workers | $0.50/1000 unique | $5/1000 images |
| Cloudflare Image Resizing | Free with Pro Biz | per-request | N/A (via R2) |
| imgix | $0.10/GB | Included | $0.20/GB |
| Cloudinary | Free tier: 25GB CDN | 25GB transformations | 25GB storage |
| AWS CloudFront + Lambda@Edge | $0.085/GB | Lambda cost | S3 pricing |
Image Optimization Checklist
- [ ] AVIF used as primary format, WebP as fallback
- [ ] Responsive images with srcset and sizes attributes
- [ ] Lazy loading for all images below the fold
- [ ] Explicit width and height to prevent CLS
- [ ] CDN-based image transformation for dynamic sizing
- [ ] Automated build pipeline for static images
- [ ] Image quality set to 80-85 (diminishing returns after this)
- [ ] SVG used for icons and illustrations where possible
- [ ] Preload LCP images with fetchpriority="high"
- [ ] Monitor Lighthouse image optimization score weekly
Conclusion
Image optimization is not optional — it is the single highest-impact performance optimization for most web pages. The modern approach combines three strategies:
- Format selection: AVIF for quality, WebP for compatibility
- Responsive delivery: Different sizes for different viewports
- CDN optimization: Transform on the edge, cache at the edge
With Cloudflare Image Resizing, you can skip the build-time optimization pipeline entirely and let the CDN handle format selection, resizing, and compression dynamically. This simplifies the development workflow while delivering optimal images to every browser.
For a production site with complete image optimization, see tanstackship.com.
Top comments (0)