Next.js• 5 MIN READ
Kirito

Kirito

6/10/20265 min read

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:

  1. Download the image from the external URL (S3)
  2. Optimize it (resize, compress, convert to WebP)
  3. Save it to .next/static/media
  4. Generate a srcset for 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.