For a new Next.js product you have to choose how images are served: the built-in optimizer behind `next/image`, a custom loader pointing at a dedicated image service, or pre-sized assets rendered with `unoptimized`. How would you make that call?
answer
- who runs the transform, and who pays
- fixed catalogue versus endless uploads
- the loader is one function in one config file
- private images need an authorisation story
- measure before migrating, not after arguing
basics
~20 sDecide by who performs and pays for each transform, and by who controls the image URL. The built-in optimizer is zero-config but runs on your servers, a custom loader hands transformation to an external service, and unoptimized assets skip the step entirely.
solid answer
~60 sI frame it as three questions. First, where does the CPU go? The built-in optimizer transforms on your runtime, so its cost scales with unique variants and traffic; a custom loader moves that work to a service that bills separately; `unoptimized` moves it to build time or to whoever produced the asset. Second, who owns the URL? The built-in path keeps everything under your origin and your `remotePatterns` policy; an external loader publishes URLs you do not control and needs its own access rules, but it is also cacheable independently of your app. Third, how variable is the catalogue? A handful of art-directed marketing images can simply be pre-sized and shipped `unoptimized`; a user-upload catalogue where every image is new and every crop is arbitrary is exactly what a transformation service is for. I would default to the built-in optimizer for a product of ordinary size, and treat the loader as the escape hatch I adopt when measurement — not anticipation — shows transform cost or cache behaviour has become a real constraint.
go deeper
Know that three delivery paths exist — the built-in optimizer, a custom loader, and unoptimized — and that they differ in where the resizing work happens.
Explain what a custom loader replaces: URL construction only, while the component still emits the srcset, reserves layout space and lazy-loads. Say what unoptimized costs you in responsive candidates.
Argue from measurements — variant counts, cache hit rate, transform CPU share — and note the operational consequences of each path, including an external dependency sitting in the page's critical rendering path.
Own the reversibility and the policy: one wrapper component, versioned immutable URLs, an authorisation story for private assets, and written triggers that would cause the team to revisit the choice on evidence.
## The three paths, stated precisely **Built-in optimizer.** `<Image>` with a normal `src` renders markup pointing at `/_next/image`, and your runtime performs the resize and re-encode on demand, caching results on disk. Zero configuration beyond `images.remotePatterns` for remote sources. **Custom loader.** Setting `images.loader: 'custom'` with `images.loaderFile` in `next.config` — or passing a `loader` function on individual `<Image>` components — replaces URL construction. Your function receives the source, the requested width and the quality, and returns a URL string; Next still emits the responsive `srcset`, still reserves layout space, still lazy-loads, but the bytes come from wherever your function points. `/_next/image` is not involved. **`unoptimized`.** Per component or globally via `images.unoptimized`, Next renders the original URL. You keep layout reservation and lazy loading; you lose the width ladder and the format negotiation, because there is only one file. ## What actually decides it **Cost location and shape.** The built-in optimizer consumes CPU on the same process that renders your pages, and that cost is a function of *unique variants*, not of image count. A small marketing site with a fixed set of images warms its cache and stops working. A marketplace where every listing is a new upload never reaches a steady state, and encode work becomes a persistent share of runtime capacity. A service bills you per transform or per stored derivative instead — often more predictable, always a line item. **Runtime shape.** The built-in path assumes there is a server process to run the optimizer. Where a deployment has no such process, or where instances are short-lived and never keep a warm cache, the built-in path is doing its worst work continuously. That is a strong signal toward an external transformer or toward pre-sizing. **Editorial versus generative catalogues.** Twenty hand-chosen hero images with deliberate art direction per breakpoint are best handled by producing exactly the files you want. Millions of user uploads with arbitrary aspect ratios need programmatic transformation, and quite likely features the built-in optimizer does not have at all — smart cropping, face detection, on-the-fly watermarking, per-tenant rules. **Control and lock-in.** The built-in path keeps image delivery inside your repository and your deploy: one config surface, one place to reason about which hosts are trusted, no third-party dependency in your critical rendering path. A loader introduces an external dependency whose outage is your outage and whose URL format leaks into your rendered HTML. In exchange you get delivery that scales independently of your app and caching you do not have to operate. **Access control.** If images are private — medical, financial, per-tenant — the question of who authorises a fetch matters more than encoding. The built-in optimizer fetches server-side under your own rules; an external service needs signed URLs or its own token scheme, and "the URL is unguessable" is not an authorisation model. ## How I would actually sequence the decision 1. **Start with the built-in optimizer** unless a hard constraint rules it out. It is one dependency fewer, it is the path every Next developer already understands, and the abstraction is deliberately swappable. 2. **Pre-size and mark `unoptimized`** for the assets where a transform genuinely buys nothing: logos, icons, sprites, anything already emitted at the exact size by a design pipeline. This is a per-asset decision, not a global switch. 3. **Instrument before migrating.** Measure the share of runtime CPU spent on transforms, the count of distinct variants, cache hit rate, and image latency at the edge. Migrating on a hunch replaces a known cost with an unknown bill. 4. **Adopt a loader when the numbers say so**, or when you need a capability the built-in path does not have. Because the loader is a single function in one config file, the migration is genuinely contained — that containment is the reason step 1 is safe. ## What I would put in writing for the team - One image component wrapper, so the delivery decision lives in one module and product code never chooses. - A required `sizes` convention, because whichever path you pick, an inflated `sizes` is the dominant waste. - A URL-versioning rule so images are effectively immutable and can be cached hard everywhere. - An explicit list of what would trigger revisiting the choice — a CPU threshold, a catalogue-size threshold, a required capability — so the decision is reviewed on evidence rather than re-argued on preference. ## The failure modes I would call out Picking an external service on day one for a catalogue of thirty images buys operational complexity and a bill for nothing. Refusing to ever leave the built-in optimizer while transform cost grows without bound is the same mistake pointed the other way. And reaching for global `unoptimized` to make a CPU problem disappear simply relocates it onto every user's connection, where it shows up as worse LCP for the people you are trying to serve.
- What do you keep from `next/image` if you switch to a custom loader, and what do you give up?You keep the component: responsive `srcset` generation, layout reservation from width and height or `fill`, lazy loading by default, and `priority`. You give up Next's own transformation and format negotiation — your loader function returns whatever URL the external service dictates, so resizing, encoding and access control become that service's semantics, and its availability becomes part of your page's critical path.
- How would you keep the choice reversible?Wrap image rendering in a single internal component so no product code imports `next/image` directly, keep the delivery decision inside one loader module and the config, and make image URLs content-addressed so cache behaviour does not depend on the provider. Then switching paths is one module plus a config change, and can be rolled out behind a flag on a subset of routes first.
- When is global `images.unoptimized` a defensible choice rather than a smell?When every image the app renders is already produced at the right size and format by a build or asset pipeline, so a transform would only re-encode what is already correct — and when the team accepts losing the width ladder. It is defensible as a deliberate pipeline decision. It is a smell when it is switched on to make CPU cost or a configuration error disappear, because the bytes simply move onto users' connections.
saying these in an interview costs you the question
- Picks an external image service before measuring anything
- Treats unoptimized as a general performance fix
- Ignores who authorises fetches for private images
- Assumes a custom loader still uses remotePatterns
- Frames the decision as purely a cost question with no operational side