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:
- Corporate proxies and security appliances rewrite the user agent.
- In-app webviews report the host application, not the engine.
- Link previewers, email clients, and feed readers fetch images with user agents that match nothing in the table.
- Every new browser version is a row somebody has to add.
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.