# What each symptom actually means

> A status code here rarely means what it means elsewhere. The table that saves the afternoon.

Most of these produce a *confident wrong diagnosis* rather than an error, which
is why they cost hours. Read the symptom, not the intuition.

| symptom | what it actually is |
|---|---|
| `PUT failed: network error`, everything else green | your origin is not in the **bucket's** CORS policy. The browser PUTs straight to storage, so storage answers that preflight — not your app, not any tenant setting |
| **400** on an image URL | the width is not on `TRANSFORM_WIDTHS`. Deliberate: an open resize endpoint is a DoS amplifier |
| **410** on a video `/t/` URL | that video has **no poster** variant. When it has one, `/t/` succeeds and returns the *poster* as an image (`x-transform-source: poster`) — never playable video. Use `urlFor(asset, "video")` |
| **422** `source_undecodable` | the stored bytes are not a decodable image — a corrupt upload we stored faithfully (the sha256 matches). No retry helps; re-upload. Distinct from a **500**, which means *we* broke |
| **404** on a preset you never asked for | `presets` defaults to `["original"]`, so a bare upload has no `lg`. Since 2026-08-22 `getAssetUrl` falls back to `/t/` when the DTO says the rung is missing — if you still get a 404, the DTO you passed carried no `presets` for it to check |
| **404** on EVERY URL of one asset — variant, raw, ladder and `/t/` alike | the asset is `private`. Check `visibility` on the DTO **before** you check storage: everywhere else a 404 means the object was never written, here it means the door is closed. Mint a signed URL with `getPrivateAssetUrl`. See [Access and privacy](/concepts/access-and-privacy/) |
| **401** on an `/a/…` signed URL | one of three, in the order worth checking: the `exp` has passed (they are meant to), the path was edited after signing (the signature covers it), or the signing key was rotated since the URL was minted |
| a private asset still serves for up to a minute after the flip | the CDN refreshes its list of private assets on a **60-second** TTL. Revocation is "within a minute", not "this instant" |
| **404** on a video URL | the tenant prefix is base36, not decimal. Tenant 10 is `/a/v/`, not `/10/v/`. Invisible for tenants ≤ 9 |
| **404** on an `original` URL | either it was never written (presets omitted `"original"`), or you built `-o.bin` by omitting `mime`, or the stored extension came from the filename (`-o.jpg`) and you asked for `-o.jpeg` |
| **a portrait photo arrives stretched wide**, and only some do | `@nitida/asset-compressor-web` **0.6.0–0.6.2** forced the stored (pre-EXIF) dimensions onto a decode that had already applied the rotation — a measured **1.78×** stretch. Needs all three: a parseable header (**HEIC is immune**), EXIF orientation 5–8, and a long side above the box, which is why it looks random. **Fixed in 0.6.3**; the damage is pre-upload, so stored photos must be re-uploaded, not repaired. See [Compression → Rotation](/guides/compression/#rotation-a-photo-held-upright-stays-upright) |
| **`width`/`height` on the DTO disagree with the image you get** | an `asset-manager` older than 2026-08-28 recorded the *stored* pair for EXIF-rotated sources while serving the *rotated* variants, so the aspect ratio came back transposed. Fixed. The **variant** dimensions were always correct — if you need one number to trust before re-processing, use the variant's |
| `process returned no assetId` | an `asset-manager` older than 2026-08-16. Fixed |
| `waitReady timeout` on a video | a transcode plus the HLS ladder runs 1–2 min. The 5-minute default is a floor a 4K source can beat |
| asset stuck at `processing` | fixed 2026-08-16. If you still see it, the row was written by an older deployment |
| `aq.assets.variants()` returns `[]` | known: it returns empty even with an admin key while the row holds variants. **Use `dto.presets`** |
| video plays at 240p for the first 8 seconds | the classic `canPlayType` check sent Chrome down the native-HLS branch. See [Video and HLS](/guides/video-and-hls/) |
| an upload "worked" but nothing is retrievable | `presets: ["thumb"]` stores no original. Verify by SHA-256 round-trip, never by upload count |
| a re-upload succeeds and proves nothing | presign short-circuits on bytes it has seen. Generate unique bytes when testing |

## The one that is not an error at all

**A `202` on a master playlist is a success.** The ladder is built by a job; the
first request returns `202` while it runs and `302`s to the cached master
afterwards.

No `error` event ever fires. A player that only swaps sources in an error
handler will spin forever on a ladder that is still transcoding. Race a timer
against `canplay` instead, and cancel it the moment `canplay` fires so healthy
HLS keeps its adaptive bitrate.

## WebKit cannot encode WebP — and it does not matter as much as it looks

Asking a canvas for `image/webp` on **any** WebKit browser yields something
else. This is not an iOS quirk: MDN's compatibility data records
`canvas.toBlob(type="image/webp")` as **unsupported in Safari**, full stop —
desktop Safari on macOS included — and every browser on iOS is WebKit
underneath, so they all inherit it.

Measured on iOS 27 / Chrome 151, a 9.1 MP photo: `2.05 MB → 743.7 kB`, labelled
`jpeg`. The engine declined WebP, the compressor fell back to JPEG, and the
file was named for **the bytes that came out** rather than the format that was
requested.

### Why it barely matters

**Delivery is WebP for everyone regardless.** The client format only decides
what crosses the wire on the way *in*; the server re-encodes every variant:

```
browser (WebKit)  ──JPEG──▶  server ─────────▶  thumb/sm/md/lg/xl as WebP
browser (Chromium) ─WebP──▶  server ─────────▶  thumb/sm/md/lg/xl as WebP
```

So a Safari user's visitors still get WebP. What the fallback costs is a
slightly larger upload — JPEG at the same visual quality is bigger than WebP —
and an archived `original` that is JPEG rather than WebP.

:::caution[Label by the bytes, never by the format you asked for]
The failure mode is not the fallback — the fallback is correct. It is naming
the file for what you *requested*. A `.webp` that is secretly a PNG breaks
every downstream consumer that trusts the extension, and that exact bug was
found in **five separate places** once anyone looked.
:::