DEV Community

Liza Bhadana
Liza Bhadana

Posted on

The same URL served me two different images

I fetched one image URL twice, in the same browser, on the same page, about a second apart.

The first fetch returned 2,864,826 bytes of PNG at 1920×1080.
The second returned 115,500 bytes of AVIF at 512×288.

Same URL. Same machine. Same session. Nothing cached differently, nothing random.

The difference was one request header I never wrote, and would not have thought to look at.

The URL also carries ?width=512, which looks like it is asking for a small image. It is not. That parameter does nothing at all.

Everything below was measured on 2026-08-11 against a live CDN, with negative controls.


Part 1: the parameter that does nothing

Every game thumbnail in the catalogue I work on is stored on a partner CDN, and every URL ends the same way:

https://static.playgama.com/p-img/pg/hedgies/preview/fe27263…?width=512
Enter fullscreen mode Exit fullscreen mode

?width=512. That reads like a resize instruction. So the first thing worth doing is the thing almost nobody does: change it and see if anything happens.

Query string Status Bytes returned
(no query at all) 206 2,864,826
?width=512 206 2,864,826
?width=256 206 2,864,826
?width=64 206 2,864,826
?width=999999 206 2,864,826
?width=abc 206 2,864,826
?width=0 206 2,864,826
?wibble=512 206 2,864,826

Byte-identical, every time. The parameter is decoration. ?width=abc and ?width=0 are not errors, they are simply ignored, and so is a parameter name that does not exist.

That last row matters more than it looks. If I had only tested ?width=512 against ?width=256 and seen no change, I could have told myself the CDN was clamping to some minimum. Adding a parameter the CDN has certainly never heard of proves the simpler thing: nothing in the query string is being read.

A checker that cannot fail is not a checker. So I also corrupted the path — same URL with /preview-NOT-REAL/ spliced in — and got a clean 404. Good. The probe can tell yes from no.

Takeaway for your own code: before you build a responsive image pipeline on top of somebody else's URL parameter, feed it nonsense. A parameter that returns identical bytes for 512, 0, abc and a made-up name is not a knob. It is a comment.


Part 2: the header that does everything

If the URL is not choosing the image, something else is. It turned out to be Accept — the header your browser sends to say which formats it can decode, and which you almost never set by hand.

Same URL, three requests, only the Accept header changed:

Accept sent Format returned Bytes Pixels
image/jpeg,image/png,*/* PNG 2,864,826 1920 × 1080
image/webp,image/* WebP 189,832 512 × 288
image/avif,image/webp,image/* AVIF 115,500 512 × 288

Two things are happening at once here, and it is worth separating them.

The first is the obvious one: format. AVIF beats WebP beats PNG, which is the whole point of content negotiation and is working exactly as designed.

The second is the one that surprised me: resolution. The modern variants are not just better-compressed versions of the same picture. They are a different, smaller picture — 512 × 288 against 1920 × 1080. The fallback is the full-size original; the negotiated variants are derivatives capped at 512 px wide.

(That the PNG is "the original upload" is my inference from its size and dimensions, not something the CDN told me. What is measured is the pixel count.)

So the byte gap is not 3× from better compression. It is 24.8×, because format and resolution moved together.

And critically: Accept: */* gets you the PNG. So does no Accept header at all. So does a browser-style HTML accept string. Wildcards do not opt you in. You have to name image/webp or image/avif explicitly, and only real image requests from real browsers do that.


Part 3: 200 images, not one

One image is an anecdote. So I ran the same three-header probe across every thumbnail in the catalogue — 200 URLs × 3 Accept headers = 600 requests.

The trick that makes this cheap: a Range: bytes=0-0 request downloads one byte, but the 206 response still carries Content-Range: bytes 0-0/<total> — and the CDN still performs content negotiation on it. So one byte per request buys you both the true size and the negotiated format. 600 bytes of transfer instead of roughly 600 MB.

Accept sent Formats returned Total Mean per image Over 1 MB
image/jpeg,image/png,*/* 188 PNG, 12 JPEG 418.9 MB 2,144.9 KB 162 / 200
image/webp,image/* 200 WebP 30.5 MB 156.1 KB 0
image/avif,image/webp,image/* 200 AVIF 19.5 MB 100.0 KB 0 / 200

200 of 200 negotiated successfully. Not one image failed to produce a modern variant.

The per-image ratio ranges from 2.4× to 48.3×, median 22.1×. Thirty-five thumbnails are over 3 MB in the fallback path; the largest is 4,017,308 bytes. The smallest is 154 KB — already small enough that negotiation barely helps it.

That spread is the useful part. There is no single multiplier you can assume. If you need the number, you have to measure the set.


Part 4: the demo that made it click

Everything above I measured from a terminal, which is exactly the position that produces wrong conclusions about browsers. So I did it from inside the page instead.

On the live page, in Chrome, I read the rendered thumbnails and then fetched one of the very same URLs from the same page:

// what the <img> actually got
document.querySelector('img.tfull').naturalWidth   // 512
document.querySelector('img.tfull').naturalHeight  // 288

// same URL, same page, one second later
const r = await fetch('https://static.playgama.com/p-img/pg/hedgies/preview/fe27263…?width=512');
(await r.blob()).size          // 2864826
r.headers.get('content-type')  // "image/png"
Enter fullscreen mode Exit fullscreen mode

The <img> tag got a 115 KB AVIF. fetch() got a 2.8 MB PNG. One page, one browser, one URL, two completely different images.

Nothing is broken. <img> sends the browser's image Accept list. fetch() defaults to */*. They are two different clients that happen to live in the same tab.

Once you have seen that, a whole category of confusing bug reports resolves itself.


Part 5: why you cannot see this from your own page

The natural next move is to measure it with the Resource Timing API and put it on a dashboard. That does not work here, and the reason is worth knowing.

All 200 thumbnail entries came back with encodedBodySize = 0. Not "small". Zero. Every one.

That is not a bug either. Size fields on cross-origin resources are hidden unless the responding server sends Timing-Allow-Origin. This CDN does not, so the numbers are withheld from the page.

The headers the page can read are the CORS-safelisted four: content-type, content-length, cache-control, last-modified.

So: your own real-user monitoring cannot measure the weight of the third-party images on your own page. Third-party image weight is invisible from the inside by default. It has to be measured from outside, deliberately, which is why it tends not to be measured at all.


Part 6: where this actually bites

Your og:image. Every one of our 200 game pages sets og:image to one of these URLs. Anything that fetches it while sending */* receives the 1920 × 1080 PNG — up to 4 MB — where a visitor to the same page receives ~100 KB.

I want to be careful here, because it is easy to overclaim. What I measured is the header, not the crawler. I confirmed that a client sending */* gets the PNG. I could not verify what any particular social crawler actually sends, because I cannot make requests from their infrastructure. If your preview images matter to you, the honest move is to check your own logs, not to trust my guess or anyone else's.

Your CI budget. A page-weight check that fetches images with a plain HTTP client is measuring the fallback path. It can fail a build over 400 MB of images that no user will ever download — or, if you set the budget from that number, quietly pass everything forever.

Anything that proxies or re-hosts images. Image proxies, email pipelines, RSS-to-newsletter tools, thumbnail caches, LLM ingestion — they mostly send */*, and they will pull and store the heavy original.

Anything that re-encodes. If you pull the fallback and re-encode it to WebP yourself, you are doing a lot of work to arrive somewhere near where the CDN would have put you for free.


Part 7: the defect this found on my own site

Measuring this exposed something that had nothing to do with the CDN, and it was mine.

The homepage renders every game as a card across twelve category rails. Reading the DOM on the live site:

  • 274 <img class="tfull"> elements
  • 0 with a loading attribute
  • 0 with decoding
  • 0 with srcset
  • 200 distinct thumbnail requests fired on a single first load

Every thumbnail in the catalogue, requested immediately, including rails several screens below the fold. At the measured AVIF mean that is roughly 19.5 MB of image on first paint.

(Computed, not directly observed — Timing-Allow-Origin is absent, so the browser would not tell me the sizes. It is the sum of the per-URL sizes I measured separately, for the exact 200 URLs the page requested.)

The irritating part: the generated pages had this right all along. The page generator has always emitted loading="lazy" past the first six cards. The homepage is the one file that is hand-edited rather than generated, and it never got the attribute.

The fix is three tokens — eager for the first six cards of the first rail, loading="lazy" and decoding="async" for the rest:

`<img class="tfull" src="${g.thumbnail}" alt="${alt}"` +
`${eager ? '' : ' loading="lazy"'} decoding="async" onerror="this.remove()">`
Enter fullscreen mode Exit fullscreen mode

Measured in a browser, before and after, on the same build:

Distinct thumbnail requests on first paint
Before 200
After 6

After scrolling 1000 px it had loaded 50 — which is the point. The images still arrive; they arrive when the reader is actually heading towards them.

No layout shift, either, and not by luck: .thumb already carries aspect-ratio: 16/10, so every card's box is reserved before its image exists. A lazy image drops into a slot that is already the right size. If your grid does not reserve the box, add loading="lazy" and you will trade page weight for layout shift — fix the box first.


Part 8: how to check yours, in about five minutes

  1. Break the URL parameter on purpose. Request your image with ?width=64, ?width=abc, ?width=0 and a parameter name you invented. Identical bytes every time means the parameter does nothing.
  2. Then corrupt the path, and confirm you get a 404. If that also returns 200, your probe is broken and every result above it is meaningless.
  3. Send three Accept headers*/*, image/webp,image/*, image/avif,image/webp,image/* — and compare Content-Type and size.
  4. Check the pixels, not just the bytes. naturalWidth in the browser, or the image header. Format and resolution can move together, and only looking at bytes hides that.
  5. Use Range: bytes=0-0 to sweep a whole catalogue for a few hundred bytes of transfer.
  6. Count your eager images. In the console: [...document.images].filter(i => !i.hasAttribute('loading')).length.

What I could not verify

Stated plainly, because a measurement piece that only reports its wins is not worth much:

  • What real social crawlers send. I tested Accept headers, not crawlers. Any claim about a specific platform's preview fetch would be a guess.
  • Whether the 1920 × 1080 PNG is genuinely the original upload. It is consistent with one. The CDN did not tell me.
  • Real-world impact on visitors. The 200 → 6 change is a measured reduction in requests on first paint. I have not yet measured what it does to load time for real people on real connections, and I am not going to claim a number I have not collected.

Related, from the same site


Measured while running SlowDen, a free browser-game site. The games come from partners and creators, each credited on its own page. If you have a playable browser game you can submit it, or take on a Slow Cook challenge — a creative prompt, a small game, and a chance to be featured.

Top comments (1)

Collapse
 
merbayerp profile image
Mustafa ERBAY

Great investigation. The vs fetch() comparison is probably the clearest demonstration here: same URL does not necessarily mean the same representation.

One thing I’d also inspect is the CDN’s Vary behavior. If the response changes based on Accept, ideally Vary: Accept (or an equivalent CDN cache-key configuration) needs to keep those representations separated. Otherwise an intermediary cache that ignores Accept could potentially serve a representation selected for one client to another.

The Range: bytes=0-0 technique is clever too — using Content-Range to measure an entire catalogue without downloading the objects is a great practical trick.

And I really like that you separated measured facts from inference, especially around social crawlers and the “original” PNG. Too many performance investigations quietly turn assumptions into conclusions. Nice work.