Noctem Labs

Supabase image transform returns 400 for format=jpeg, and every placeholder silently disappeared

getPlaceholderUrl asked Supabase for format=jpeg at quality=10. The transform accepts neither — format must be origin|webp|avif and quality must be >= 20 — so every low-quality image placeholder on th

Symptom

format=jpeg
quality=10
Repositorypaws-of-london
Introduced2026-03-01
Detected2026-07-23
Fixed2026-07-23
Commits16f7966, bdaea1e

144 days. That's how long paws-of-london served flat colour rectangles instead of image placeholders, and nobody noticed because the thing that was supposed to notice was built to agree with the bug.

Right, let's get into it.

Symptom

Every low-quality placeholder image on the site, the little blurred-out preview that's meant to load before the real image does, was just... a block of colour. No blur, no shape, nothing. The function responsible, getPlaceholderUrl, was asking Supabase's image transform for format=jpeg at quality=10. Reasonable-sounding request. The kind of thing you'd write without thinking twice about it.

Except the transform doesn't accept that. It never did. Every single call was hitting a 400 and falling back to a flat colour placeholder, silently, for 144 days. Introduced 2026-03-01. Found 2026-07-23. That's not a rounding error, that's most of a year, and the whole time the site just quietly degraded and looked fine doing it.

What we thought it was

First instinct, and I mean genuinely the first thing that came to mind, was a CDN caching issue. Flat colour placeholders feel like a caching problem. You imagine some edge node serving a stale default, or a cache key collision, or some TTL that's gone wrong somewhere upstream. It's the kind of failure that's vague enough to blame on infrastructure, which is exactly why it's tempting.

It was believable for a couple of reasons. One, the site had been through a CDN config change earlier in the year, unrelated, but recent enough to be suspicious. Two, flat colour is what you'd expect a cache to serve if it had nothing better, a kind of default-default. So the early debugging session went looking in the wrong place entirely, poking at cache headers and edge behaviour, because that story made sense and this one didn't yet.

It wasn't a caching problem. It was never a caching problem. But you chase the believable thing first, that's just how debugging works, you go where the shape of the failure points you and sometimes it points you somewhere completely wrong.

Root cause

The actual mechanism sits in src/lib/imageCdn.ts:180@bdaea1e^ and src/lib/imageCdn.ts:181@bdaea1e^. Two lines. That's genuinely all it took.

The function was constructing a transform request with format=jpeg and quality=10, and Supabase's transform API just doesn't support either of those values in that combination. Format has to be origin, webp, or avif. Quality has to be 20 or above. Both constraints violated, in two adjacent lines, by a function whose entire job was to generate a lightweight placeholder image.

So every request 400d. Not intermittently, not under load, every time. And the fallback logic, which existed presumably for exactly this kind of failure, kicked in and served a flat colour block instead. Which is arguably the fallback doing its job. It's just that its job, in this case, was masking a bug that should never have shipped.

The fix

The fix itself is not dramatic, it's a correction to the two parameters so they sit inside what the transform API actually accepts. Format moves to one of the supported values, quality moves above the floor. That's it. That's the whole change, landed in commit 16f7966 after being introduced in commit bdaea1e.

The unglamorous part is that the fix is small enough to feel almost insulting given how long the bug lived. Two parameters, two lines, one afternoon of work once someone actually looked at it. The gap between "how long this took to notice" and "how long this took to fix" is the real story here, and it's not really a story about code.

Why it happened

Here's the bit that actually matters. The unit test for this function asserted the broken behaviour. Not "didn't test it", not "tested something adjacent and missed it". It asserted format=jpeg and quality=10 as the expected output, which meant the test suite was, structurally, guarding the bug rather than catching it.

So every time someone ran the suite, green light. Every CI run, green light. The test wasn't blind to the problem, it was actively agreeing with it, which is a much worse position to be in than having no test at all. A missing test is a gap you can eventually notice. A wrong test is a gap dressed up as coverage, and it'll pass review every single time because reviewers trust green suites, that's sort of the whole point of having them.

This is the systemic bit. Somewhere in the original PR, someone wrote the function, wrote a test that captured what the function actually did rather than what it was supposed to do, watched it pass, and moved on. Nobody manually checked a rendered placeholder against expected output, because the test suite said everything was fine, and why would you double check something the suite already confirmed? That's the whole mechanism. Confidence substituted for verification, and the substitution held for 144 days because nothing forced it to be checked again.

How to avoid it

If you're reading this because you've got a similar function sitting somewhere in your own codebase, the transferable lesson isn't "test your image transforms more carefully", that's too specific to be useful to you. It's this: a test that asserts current output is not the same as a test that asserts correct output, and the difference between those two things is invisible from inside the green checkmark.

Anywhere you're wrapping a third-party API with constraints you don't control, like format or quality enums, minimums, allowed ranges, whatever, the test needs to encode those constraints independently of what your function currently does. Ideally against the actual API contract, or at minimum against documented values, not against a snapshot of current behaviour. If the test was written by copying the function's output rather than deriving it from the spec, you don't have a test. You have a mirror.

And it's worth, every so often, actually looking at what a passing test produces. Not the assertion, the artefact. Render the placeholder. Look at the image. It takes about ten seconds and it would have caught this on day one instead of day 144.

Anyway. Two lines, one bad test, a lot of flat colour rectangles nobody clocked for months. That's the whole thing.

Every commit, file reference and date above is checked against this incident's evidence record before publication. Evidence: supabase-transform-400-format-jpeg.