# A gallery that is fast on a phone and sharp on a desktop

> One photo, two srcSets. Which widths to ask for on mobile vs desktop, what each one actually weighs, and the two things that silently give you the wrong size. Every number on this page was measured against production.

import { TRANSFORM_WIDTHS } from '@nitida/asset-client';

A phone and a 27-inch monitor need very different bytes from the same photo.
This page is the recipe: **upload once, serve two ladders**, with the weights
measured so the speed/quality trade is a number and not a feeling.

:::tip[Every claim here is executed, not written]
A verification script runs this page against production and asserts each
number — it is not prose that happens to contain figures. It was written
*after* two of the
instructions on an earlier draft turned out to be wrong — see
[What running it caught](#what-running-it-caught).
:::

---

## 1 · The imports, which are not all in one package

```ts
import {
  NitidaClient, getTransformUrl, getTransformSrcSet, setTenantId,
} from "@nitida/sdk";
import { setCdnBase, type TransformWidth } from "@nitida/asset-client";
```

⚠️ **`setCdnBase` is not re-exported by `@nitida/sdk`**, while `setTenantId`
is. Importing both from the SDK fails at module load.

---

## 2 · Upload: ask for the ladder, and upload the biggest master you have

The default is `["original"]` and nothing else.

```ts
await nt.upload(file, {
  fileName: "villa-sunset.jpg",
  presets: ["original", "thumb", "sm", "md", "lg", "xl"],
});
```

**The source is the ceiling — there is no upscale.** Measured: a 2400 px master
asked for `width=2560` returns **2400 px**. Put widths in your `srcSet` that
your masters can actually sustain, or the browser picks a candidate that does
not exist at that size.

---

## 3 · What each width weighs

Measured on a 2400×1600 master, WebP at `quality=75`:

| width | 400 | 600 | 800 | 960 | **1080** | 1600 | **1920** |
|---|---|---|---|---|---|---|---|
| **KB** | 21 | 32 | 56 | 74 | **86** | 123 | **175** |

That table *is* the trade-off. A phone served at `1080` downloads **86 KB**;
served at `1920` it downloads **175 KB** — double the weight for pixels the
screen cannot resolve.

| surface | CSS width | DPR | ask for |
|---|---|---|---|
| Mobile · 2-col grid | ~190 px | 2–3 | `400`, `480` |
| Mobile · full screen | ~400 px | 2–3 | `800`, `960`, `1080` |
| Desktop · grid | ~300–400 px | 1–2 | `400`, `600`, `800` |
| Desktop · lightbox | ~1200–1600 px | 1–2 | `1600`, `1920` |
| Full-bleed retina hero | — | 2 | `2560` / `3840` — **only if the master reaches it** |

---

## 4 · One photo, both galleries

```tsx
// No `quality` here — auto-quality probes the MASTER, so the image is
// compressed exactly once. Pinning a number turns on the shortcut that reads an
// already-compressed variant instead. See the double-pass warning below.
<img
  src={getTransformUrl(asset, { format: "webp", width: 800 })}
  srcSet={getTransformSrcSet(asset, [400, 800, 1200, 1600], {
    format: "webp",
  })}
  sizes="(max-width: 768px) 100vw, 40vw"
/>
```

**`sizes` is what makes the phone pick 800 and the desktop pick 1600 from the
same tag.** Leave it out and the browser assumes `100vw` — it will fetch the
large candidate for everyone, and the whole ladder buys you nothing.

### Quality and format

- **Omit `quality` unless you have a hard byte budget.** `75` for grids and
  `80` for the large view are sensible *numbers*, but pinning either one is what
  triggers the second compression pass on a laddered asset — **read the
  warning below first if your assets have the preset ladder.**
- **`format: "webp"`.** The DSL accepts `avif`, but this platform standardises
  on WebP.
- **Grid cells:** `{ fit: "cover", gravity: "auto" }` crops to fill and frames
  itself — verified to return an exact 400×400. **Lightbox:** `fit: "inside"`,
  which never crops.

:::danger[Pinning `quality` on a laddered asset compresses the image twice]
`/t/` short-circuits to the smallest **stored variant** that covers the
requested size — and that variant is already a WebP that has been through one
lossy pass. The shortcut only fires when the request carries an **explicit
numeric `quality`**, so `quality: 75` is precisely what turns it on.

Measured on five photographs at `width=800` ([the benchmark](/guides/transform-benchmark/)):
the laddered asset answered from `md` (1280 px WebP) every time, came back
**7–9 % smaller** than the same photo served from its master — and **worse on
both PSNR and SSIM, 5 times out of 5** (up to −3.01 dB / −0.0200 SSIM). Smaller
*and* worse is generation loss, not a saving.

- **Want the master's quality?** Drop `quality` and let auto-quality run: it has
  to probe the full-resolution master, so it cannot take the shortcut. Measured
  cost: between −2.6 % and +4.7 % bytes, with SSIM up on all five.
- **Raising `quality` does not undo it.** The doubly-compressed output at
  `quality: 80` is still worse than the single-pass output at `quality: 60` —
  and 31 % heavier.
- **Worst at 640, 1280 and 1920**, the widths that match `sm`/`md`/`lg`
  exactly: no downscale in between to hide the first pass. At 640 and 1920 the
  laddered result is *heavier* as well.
- **Assets uploaded with `["original"]` only are unaffected** — every transform
  comes off the master.
:::

---

## 5 · Widths are a fixed ladder

{TRANSFORM_WIDTHS.length} of them: {TRANSFORM_WIDTHS.join(" · ")}.

Anything off it is **HTTP 400 at the edge**, deliberately — verified with
`width=777`. Import the `TransformWidth` type and an off-ladder number becomes a
compile error instead of a production 400.

---

## 6 · The first request of each width is cold

A transform is generated on first request and then cached at the edge
immutably. If the gallery must be fast for its *first* visitor too, either ask
for those sizes as stored presets at ingest, or warm them with a sweep of
requests after upload.

---

## What running it caught

The first version of this recipe was written by reading the source. Executing it
found two errors in it:

1. **`setCdnBase` imported from the wrong package** — it failed on the first
   line. Anyone following the instructions would have bounced immediately.
2. **`width=2560` returns 2400 px** when the master is 2400 px wide. The draft
   recommended 2560 for desktop lightboxes without saying it depends on the
   source.

Neither is visible from reading the code. That is why the script exists, and why
it runs against production rather than a fixture.

A third thing was found later, the same way: the `quality: 75` / `80` advice on
this page was written without knowing that pinning a numeric `quality` is what
enables the stored-variant shortcut — so the recommendation was quietly asking
for a second lossy pass on exactly the assets we tell everyone to upload.
The measurement, on all five documentation photographs and every gallery width,
is [the transform benchmark](/guides/transform-benchmark/).