Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

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

  1. cdn.dribbble.com/userupload/... with a query: drop the query and add ?format= with the file's own extension (jpg, png).
  2. .gif: drop the query, then look for data-animated-url on the same element. The still- file is only the first frame.
  3. still-*.png next to .mp4 sources: the poster is an image, the shot is the unprefixed MP4.
  4. Send no image/webp in Accept, 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.