Dribbble's image URLs: imgproxy options, Vary: Accept, and the shots that ignore resize
Every shot on dribbble.com/shots is drawn from a URL like this: https://cdn.dribbble.com/userupload/49597067/file/5137b4a13bb8a15e43a919c3c54124ec.jpg?format=webp&resize=400x300&vertical=center The usual advice
Every shot on dribbble.com/shots is drawn from a URL like this:
https://cdn.dribbble.com/userupload/49597067/file/5137b4a13bb8a15e43a919c3c54124ec.jpg?format=webp&resize=400x300&vertical=center
The usual advice is to delete everything after the ? and you get the upload. That's mostly right, but when I measured it today (12 November 2026) the answer depended on a request header, and two kinds of shots didn't follow the rule at all. Everything below was fetched with curl and the pixel sizes were read from the files with Pillow and ffprobe.
What serves the images
The response headers say server: imgproxy, with two CloudFront hops in via, cache-control: max-age=31536000, public and vary: Accept. So it's an imgproxy instance that resizes on the fly behind a CDN, and the CDN keeps a separate copy per Accept value.
On the shots page each image has a srcset with nine widths: 320, 400, 450, 640, 700, 800, 840, 1000 and 1200, all on the same file with a different resize=.
The query options on a JPEG shot
The file above is a 4800×3600 JPEG. With a browser-style Accept: image/avif,image/webp,*/*:
| Query | Result |
|---|---|
?format=webp&resize=400x300&vertical=center |
WebP 400×300, 20,178 bytes |
?resize=400x300 |
same bytes as the row above |
?resize=400x0 |
WebP 400×300 (height follows the ratio) |
?resize=400x400 |
WebP 400×400, cropped to fill, 24,954 bytes |
?format=jpg&resize=400x300 |
JPEG 400×300, 65,955 bytes |
?format=png&resize=400x300 |
PNG 400×300, 163,542 bytes |
?format=avif&resize=400x300 |
AVIF, 18,972 bytes |
?resize=400 |
ignored: the full 4800×3600 file |
?resize=10000x10000 |
the full 4800×3600 file, no upscaling |
format=webp in the grid URLs does nothing extra for a browser, because the server already picks WebP from Accept. And resize needs both numbers; a single number is silently dropped.
Deleting the query doesn't always give you the JPEG
Here is the same bare URL, no query, fetched three ways:
| Accept header | Response |
|---|---|
| none |
image/jpeg, 4800×3600, 3,516,629 bytes |
*/* |
image/jpeg, same bytes |
image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8 |
image/webp, 4800×3600, 759,592 bytes |
Chrome's navigation Accept (text/html,...,image/avif,image/webp,*/*;q=0.8) |
image/webp, same 759,592 bytes |
The pixel size is the same, but anything that sends image/webp in Accept gets a re-encoded WebP at about a fifth of the bytes. Opening the cleaned URL in a new tab sends Chrome's navigation header, which lists image/webp, so what you save from that tab is the WebP.
A PNG shot behaves the same way: 1600×1200 PNG at 1,411,905 bytes without the header, 1600×1200 WebP at 122,606 bytes with it.
The fix is to ask for the format explicitly. ?format=jpg on the JPEG returned the 3,516,629-byte file with the same MD5 as the header-less request, and it did so with or without image/webp in Accept. ?format=png did the same for the PNG shot. So the URL that gets the upload every time is the bare path plus the format that matches its extension.
fetch() sends Accept: */* by default, so code that downloads with plain fetch gets the original. The catch is the HTTP cache: if the same URL was already loaded by an <img> with a WebP Accept, a cached response can come back instead. My extension fetches with cache: 'no-store' for that reason.
Animated GIF shots ignore every option
One shot in today's grid was an animated GIF. Its markup, trimmed:
<img data-srcset=".../still-445d7141afa22f734df9c7cc9cff5da6.gif?format=webp&resize=320x240&vertical=center 320w, ..."
data-animated-url="https://cdn.dribbble.com/userupload/49587277/file/f542325d0241839a349b6059ecfba783.gif"
src="data:image/gif;base64,...">
The grid loads still-...gif, a single-frame GIF. It is 1920×1436 and 370,985 bytes, and every srcset entry returns that same file: format=webp&resize=400x300 came back as image/gif, 1920×1436, with the same MD5 as the bare URL. The animated file in data-animated-url is 1920×1436, 2,348,096 bytes, and it ignores the query too. So on GIF shots the grid already loads a full-size frame instead of a 400×300 thumbnail, and a downloader that reads only src/srcset saves the still frame. The animation is only in that data attribute.
Video shots: a PNG poster and three MP4s
Video shots also use a still- file, but it's a PNG, and imgproxy does resize it: the poster for shot 49593904 is 1600×1200 (369,627 bytes) bare and 400×300 (50,150 bytes) with ?resize=400x300. The videos come straight from S3 (server: AmazonS3) in three files:
| File | Video | Bytes |
|---|---|---|
small-908743c3....mp4 |
H.264 400×300, 10 s | 235,231 |
large-e263fe43....mp4 |
H.264 800×600, 10 s | 569,201 |
0e6c0cef....mp4 (no prefix) |
H.264 1600×1200, 10 s | 3,125,529 |
S3 ignores query strings, so ?resize= does nothing there. The unprefixed name is the full-size file.
A rule that covers all four cases
-
cdn.dribbble.com/userupload/...with a query: drop the query and add?format=with the file's own extension (jpg,png). -
.gif: drop the query, then look fordata-animated-urlon the same element. Thestill-file is only the first frame. -
still-*.pngnext to.mp4sources: the poster is an image, the shot is the unprefixed MP4. - Send no
image/webpinAccept, or don't rely on what the server picks.
I build PixLasso, a Chrome extension that saves every image on a page into one ZIP, and the Dribbble rule in it is still the simple "drop the query" version. It gets the JPEG and PNG originals because its ZIP fetch sends */*, but it doesn't read data-animated-url yet, so from the grid it saves the still frame of a GIF shot. The step-by-step version for people who just want the files is in my Dribbble download guide.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.