SlowDen

The same URL served me two different images

By Liza · SlowDen · Published 11 August 2026

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 11 August 2026 against a live CDN, with negative controls.

Part 1: the parameter that does nothing

Every game thumbnail in SlowDen’s catalogue 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

?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 stringStatusBytes returned
(no query at all)2062,864,826
?width=5122062,864,826
?width=2562062,864,826
?width=642062,864,826
?width=9999992062,864,826
?width=abc2062,864,826
?width=02062,864,826
?wibble=5122062,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 variation 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 sentFormatBytesPixels
image/jpeg,image/png,*/*PNG2,864,8261920 × 1080
image/webp,image/*WebP189,832512 × 288
image/avif,image/webp,image/*AVIF115,500512 × 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 sentFormats returnedTotalMeanOver 1 MB
image/jpeg,image/png,*/*188 PNG, 12 JPEG418.9 MB2,144.9 KB162 / 200
image/webp,image/*200 WebP30.5 MB156.1 KB0
image/avif,image/webp,image/*200 AVIF19.5 MB100.0 KB0 / 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, for Cooking Empire by Boar Band, is 4,017,308 bytes. The smallest, Snake 2048 by TurtleGamesStudio, 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 homepage, 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"

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 around 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 our own site

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

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

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: our 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()">`

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

Distinct thumbnail requests on first paint
Before200
After6

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:

Related reading

Making something playable?

SlowDen is a place to put a browser game where people can find it and play it — no download for the player, no store page for you. Games here come from creators, partners and third-party sources, and each one is credited to whoever made it.

Slow Cook is our creative challenge round. You get a prompt, you build something around it, you submit it for consideration. Selected entries have a chance to be featured, and selected winners may receive a shoutout across SlowDen’s social accounts. That is the honest list; there is nothing guaranteed beyond it. Check the Slow Cook page for the current round and its closing date.

Submit a browser gameSee the Slow Cook challenges

Questions people ask

Why does the same image URL return different file sizes?

Because the server is performing HTTP content negotiation on the Accept request header. We measured one URL returning a 2,864,826-byte PNG at 1920x1080 when the request sent Accept: image/jpeg,image/png,*/*, a 189,832-byte WebP at 512x288 for Accept: image/webp,image/*, and a 115,500-byte AVIF at 512x288 for Accept: image/avif,image/webp,image/*. Nothing else about the request changed. Note that both the format and the pixel dimensions changed together, so the gap is 24.8x rather than the 3x you would expect from compression alone.

Does a ?width= parameter in an image URL always resize the image?

No, and it is worth testing rather than assuming. We requested the same image with no query at all, ?width=512, ?width=256, ?width=64, ?width=999999, ?width=abc, ?width=0 and an invented ?wibble=512. All eight returned HTTP 206 and byte-identical responses of 2,864,826 bytes, so nothing in the query string was being read. Including a parameter name the server has certainly never seen is the check that distinguishes an ignored parameter from a clamped one.

Why does fetch() get a different image than the img tag on the same page?

An img element sends the browser's image Accept list, which names image/avif and image/webp explicitly. fetch() defaults to Accept: */*. Wildcards do not opt you into modern formats, so the two requests negotiate differently. We measured this on one live page: the rendered thumbnail reported naturalWidth 512 and naturalHeight 288, while fetch() on the identical URL a second later returned a blob of 2,864,826 bytes with content-type image/png.

Why does encodedBodySize return 0 for third-party images?

Size fields in the Resource Timing API are withheld for cross-origin resources unless the responding server sends a Timing-Allow-Origin header. We measured 200 third-party image entries on one page and every single one reported encodedBodySize 0. The only response headers the page could read were the CORS-safelisted set: content-type, content-length, cache-control and last-modified. The practical consequence is that real-user monitoring on your own page cannot measure the weight of third-party images it loads.

Should I add loading="lazy" to every image in a grid?

Not to the ones that can be above the fold on first paint, because lazy-loading those delays the largest contentful paint. We made the first six cards eager and lazy-loaded the rest, which took first-paint thumbnail requests on our homepage from 200 to 6, measured in a browser before and after on the same build. After scrolling 1000 px it had loaded 50, which is the intended behaviour. One precondition: the container must already reserve the image's box, or you trade page weight for layout shift. Ours does, via aspect-ratio: 16/10.

Does my og:image get the same optimised version my visitors get?

Not necessarily, and this is measurable rather than theoretical. Our game pages set og:image to a CDN URL that negotiates on Accept. Any client sending */* receives the full-size fallback, which across our 200 images averages 2,144.9 KB and reaches 4,017,308 bytes at the largest, where a browser on the same page receives around 100 KB. We verified the header behaviour, not the behaviour of any particular crawler, which we cannot observe from outside their infrastructure.