Kirito
The Next.js Image Optimization That Brought Down Our Vercel Deployment
Adding next/image to 500 product images seemed fine until Vercel started timing out during build – because each image triggered a remote fetch.
The Next.js Image Optimization That Brought Down Our Vercel Deployment
"Deployment failed after 45 minutes: Timeout."
Every. Single. Build.
Vercel's build logs showed it hanging at the same step: Generating optimized images.
The Setup
We migrated an e-commerce site to Next.js 14 with 500 product pages, each with 3 images.
We used next/image for automatic optimization:
import Image from 'next/image'; export default function ProductPage({ product }) { return ( <Image src={product.imageUrl} // External URL (S3) width={800} height={600} alt={product.name} /> ); }
The Problem
Next.js optimizes images at build time by default for static generation.
Our product pages were static (exported at build time).
For each image, Next.js would:
- Download the image from the external URL (S3)
- Optimize it (resize, compress, convert to WebP)
- Save it to
.next/static/media - Generate a
srcsetfor responsive sizes
With 500 products × 3 images = 1,500 images.
Each image download took ~200ms (S3 latency). That's 300 seconds just in download time.
Plus optimization CPU time.
Total build time: 45+ minutes → Vercel timeout (45 min limit).
The Investigation
Vercel logs showed:
[===] Optimizing images...
/products/shirt-1/hero.jpg (1/1500) - 200ms
/products/shirt-1/thumbnail.jpg (2/1500) - 180ms
... (45 minutes later)
Error: Build exceeded maximum time limit of 45 minutes.
I checked next.config.js:
module.exports = { images: { domains: ['my-s3-bucket.s3.amazonaws.com'], // No custom loader, no optimization limits } };
The issue: Next.js was re-optimizing images on every build, even though the source images never changed.
The Fixes
1. Use a custom loader to serve already-optimized images
// next.config.js module.exports = { images: { loader: 'custom', loaderFile: './lib/imageLoader.js', }, };
// lib/imageLoader.js export default function myImageLoader({ src, width, quality }) { // Use a CDN that already has optimized versions return "https://cdn.myapp.com/$ {src}?w=$ {width}&q=$ {quality || 75}"; }
2. Pre-optimize images during build with a script
// scripts/optimize-images.js const sharp = require('sharp'); const fs = require('fs'); const path = require('path'); const images = getAllProductImages(); for (const image of images) { const sizes = [640, 750, 828, 1080, 1200, 1920, 2048]; for (const size of sizes) { await sharp(image.original) .resize(size) .webp({ quality: 80 }) .toFile('public/optimized/$ {image.id}-$ {size}.webp'); } }
3. Use 'unoptimized' for external images (if acceptable)
<Image src={product.imageUrl} unoptimized // Skip Next.js optimization width={800} height={600} alt={product.name} />
4. Switch to 'next/legacy/image' (not recommended)
import Image from 'next/legacy/image'; // This doesn't optimize at build time
The Permanent Solution
We moved to on-demand image optimization using Vercel's built-in image optimization (which only runs at request time, not build time):
// next.config.js module.exports = { images: { domains: ['my-s3-bucket.s3.amazonaws.com'], deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048], imageSizes: [16, 32, 48, 64, 96, 128, 256], // No build-time optimization for external images }, };
But this still optimizes on first request. To avoid cold starts, we added a warm-up script:
// scripts/warmup-images.js const allProductImages = getProductImages(); for (const img of allProductImages) { // Request optimized versions to cache them await fetch('/_next/image?url=$ {encodeURIComponent(img.url)}&w=800&q=75'); }
The Configuration That Finally Worked
// next.config.js module.exports = { images: { remotePatterns: [ { protocol: 'https', hostname: 'images.myapp.com', port: '', pathname: '/**', }, ], minimumCacheTTL: 60 * 60 * 24 * 365, // 1 year deviceSizes: [640, 750, 828, 1080, 1200, 1920], imageSizes: [16, 32, 48, 64, 96, 128, 256], formats: ['image/webp'], }, // Only static export if necessary, otherwise use ISR output: 'standalone', // Not 'export' };
And we switched to Incremental Static Regeneration (ISR) instead of full static export:
export async function getStaticProps() { return { props: { product }, revalidate: 3600, // Revalidate every hour }; }
Now images are optimized on-demand and cached forever.
The Prevention
Monitor image optimization build time
# Add timing logs NEXT_IMAGE_OPTIMIZATION_LOGGING=true next build
Use a custom image CDN
We moved to Imgix which handles optimization at the edge:
// lib/imageLoader.js export default function imgixLoader({ src, width, quality }) { const url = new URL('https://myapp.imgix.net/$ {src}'); url.searchParams.set('w', width); url.searchParams.set('q', quality || 75); url.searchParams.set('auto', 'format'); return url.toString(); }
Set up build caching
# vercel.json { "buildCommand": "next build", "outputDirectory": ".next", "functions": { "pages/**/*.png": { "maxDuration": 10 } }, "images": { "sizes": [640, 828, 1200] } }
What I Learned
- 'next/image' with external URLs downloads and optimizes at build time for static exports.
- For 100+ images, this kills build performance.
- Use 'loader="custom"' or 'unoptimized' for external images.
- Consider on-demand optimization (Vercel or CDN) instead of build-time.
- ISR is better than full static export for image-heavy sites.
That 45-minute build timeout cost us half a day of debugging. Now we never build-optimize more than 50 images. Everything else goes through a CDN.