Build1 publisher3 min readPublished
At 70,000 images, next/image is not your optimizer, it is your outage
A wallpaper site's post-mortem on serving 70,000-plus 4K masters through Next.js 15 shows the optimizer failing on Referer checks and edge CPU ceilings, not on throughput.
The Engineer · Build desk
Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened
- Zenith Walls built a catalog of over 70,000 4K and 8K images sourced across multiple external CDNs and their own Cloudflare R2 object storage.
- The source images are uncompressed files of 10MB to 25MB each.
- Throwing hundreds of uncompressed images into a Next.js grid with infinite scroll causes browser tabs to lock up trying to decode dozens of 4K bitmaps in parallel.
- The next/image optimizer falls apart when upstream CDNs reject requests missing Referer headers.
- Many external image hosts check the HTTP Referer header to protect bandwidth; when next/image optimizes server-side it makes a bare fetch without the browser's context, and the CDN returns a 403 Forbidden or a 0-byte response, leaving the user staring at an empty card.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
The team behind Zenith Walls has published an account of serving a catalog of more than 70,000 4K and 8K images through Next.js 15's App Router, sourced from several external CDNs plus their own Cloudflare R2 bucket [1][16]. The useful part is not the fix but the failure mode: according to the write-up, `next/image` broke because it fetches like a server, not because it was slow [4].
Three walls, in order of how quickly you hit them. First, the Referer check. Many image hosts protect bandwidth by inspecting the HTTP `Referer` header, and when the Next.js optimizer fetches server-side it sends a bare request with no browser context, so the upstream returns 403 or a 0-byte body and the user gets an empty card [5]. That is not a performance regression, it is a blank grid. Second, CPU. On an edge deployment the execution window is roughly 10ms to 50ms, 10ms on Cloudflare Workers, and running Sharp or WASM libvips against an 8K source inside that handler times out immediately [6]. Third, if you dodge the optimizer by pointing the browser at a public R2 bucket, browsers enforcing `Cross-Origin-Resource-Policy: same-origin` can block the fetch anyway [7].
The scale explains why per-request transformation was never going to work. The masters are uncompressed files of 10MB to 25MB [2], so 70,000 of them is somewhere between roughly 700GB and 1.75TB of source bytes [1], and a browser asked to decode dozens of 4K bitmaps in parallel locks the tab [3].
Their answer is to stop treating URLs as data. Every database `image_url` passes through a deterministic chain: `getDisplayUrl` strips thumbnail artifacts and normalizes CDN hostnames, `getProxiedUrl` rewrites to `/api/proxy` with dimensions and a source ID, and the edge route injects headers, handles R2 bindings and caching [8]. Grid tiles request width 400 at quality 75; the download button uses a separate function that returns the untouched full-resolution master [9]. The proxy itself streams rather than buffering multi-megabyte bodies in memory [10], rejects non-allowlisted hostnames as an SSRF guard before opening a connection [11], and for R2 URLs fetches through the internal bucket binding, which skips the public internet round trip and the CORP problem in one move [12].
Two things to read carefully. The published portion of the route describes an allowlist check, an R2 binding and a piped response stream with cache headers, and shows no resize step [15]; if nothing transforms at the edge, then `width` and `quality` are cache-key decoration and the actual byte savings come from `getDisplayUrl` picking a smaller upstream asset [9][10]. And the claimed outcome, slashed LCP and no blank-loading flashes without ballooning infrastructure bills, arrives without a single before-and-after number [14], on a site whose own diagnosis names CLS and LCP as the thing being destroyed [17].
Watch the cache headers, because they are the real bet: 7 days in the browser, 30 days at the shared and CDN layer, 1 day of stale-while-revalidate [13][2]. A 70,000-item catalog has a long tail, and a 30-day shared TTL only pays if requests repeat; the `immutable` directive on top of it means the practical way to correct a bad response is to change the URL rather than purge [3]. The allowlist is the other recurring cost: every new upstream source is now a code change in the proxy [11].