What each symptom actually means
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 |
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 |
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 |
| 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
Section titled “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 302s 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
Section titled “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
Section titled “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 WebPbrowser (Chromium) ─WebP──▶ server ─────────▶ thumb/sm/md/lg/xl as WebPSo 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.