WebP fallbacks that actually work

Most broken WebP deployments fail the same way: the server decided the browser supported WebP, and it was wrong.

User-agent sniffing is guessing

The usual approach is a lookup table of user-agent strings. It fails for predictable reasons and a few unpredictable ones:

The failure mode is not a slightly larger image. It is a broken image on a page you cannot reproduce locally, reported by a customer using something you have never heard of.

What Accept actually says

A browser that can decode WebP announces it:

Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8

This is a statement of capability from the client itself, and it is correct by construction — a client that cannot decode WebP has no reason to advertise it.

The subtlety is the wildcards. image/* and */* are not declarations of WebP support. curl sends */*. So do plenty of proxies and previewers. If you treat a wildcard as consent, you are back to guessing, just with extra steps.

So the rule is: serve WebP only on an explicit image/webp in Accept. The cost of being conservative is bytes. The cost of being wrong is a support ticket.

Two cases where the original is the right answer

The client did not ask for WebP. Covered above. Serve the source format.

The transformation failed. An image that the encoder rejects — a truncated file, an exotic colour profile, something that is not really a JPEG — still exists at the origin and can still be served. A visitor receiving a 400 KB original instead of a 90 KB WebP has a slow page. A visitor receiving a broken image icon has a broken page. These are not the same severity and should not have the same response.

Vary: Accept is still required

If one URL can return two different formats, every cache between you and the user needs to know that. Without Vary: Accept, a shared cache will happily hand a WebP to the next client along, which may be the one that cannot decode it.

At the edge we go further and put the negotiated format into the cache key itself, so our own cache does not depend on Vary being honoured correctly — which, historically, it often is not.

The short version

Read Accept. Require an explicit match. Send Vary: Accept. When anything goes wrong, serve the original. None of this is clever, and that is the point.