DEV Community

Casper Tran
Casper Tran

Posted on

Finding the full-size original behind a thumbnail: srcset, CDN size rules and fallbacks

The image you see on a page is almost never the file the site stores. A Wikipedia infobox shows the Eiffel Tower at 330 px wide while the uploaded photo is 2900 × 4830. An Unsplash grid tile is 400 × 267; the same photo without the query string is 7360 × 4912. Right-click, "Save image as", and you get the small one.

I build a Chrome extension that downloads all the images on a page, and most of its code is not about downloading. It is about working out, for each <img>, which URL points at the biggest real file, and what to do when that guess is wrong. Below is how that works, with sizes I checked with curl this week.

Step 1: read srcset before src

img.src (or currentSrc) is whatever the browser picked for the current viewport and pixel ratio. srcset lists everything the site was willing to send. On a WP Tavern article card the browser shows the 300 px copy, but the srcset ends with the upload itself:

<img src=".../236-Brian-Coords-on-WordPress-as-the-Source-of-Truth-300x169.jpg"
     srcset=".../...-Truth-1024x576.jpg 1024w,
             .../...-Truth-300x169.jpg 300w,
             .../...-Truth-768x432.jpg 768w,
             .../...-Truth.jpg 1280w">
Enter fullscreen mode Exit fullscreen mode

So the first move is to parse srcset and take the largest candidate. Width descriptors (1280w) compare directly. A srcset uses either widths or densities (2x), never both, so densities just get scaled up to stay comparable:

function bestSrcsetCandidate(srcset, base) {
  let best = null, bestScore = -1;
  for (const part of splitSrcset(srcset)) {
    const [raw, desc = '1x'] = part.trim().split(/\s+/, 2);
    const n = parseFloat(desc);
    const score = /w$/i.test(desc) ? n : /x$/i.test(desc) ? n * 1000 : 1;
    if (score > bestScore) { bestScore = score; best = new URL(raw, base).href; }
  }
  return best;
}
Enter fullscreen mode Exit fullscreen mode

Do not split srcset on commas. Cloudinary URLs contain commas (/w_400,h_300,c_fill/), and a naive split(',') cuts them in half. The HTML spec says a URL is a run of non-whitespace and only a comma at the end of that run ends it, so splitSrcset walks the string character by character.

Also look at the <source> elements inside a <picture>. One caveat: a <source media="..."> is art direction, usually a different crop, so it is a separate image and not a bigger version of the same one. And lazy loaders keep the real list in data-srcset or data-lazy-srcset until the element scrolls into view.

sizes matters less than people expect here. It tells the browser how wide the slot is so it can pick from srcset; it never adds a candidate. If you want the largest file, ignore sizes and read the descriptors.

Step 2: srcset often stops short of the original

Wikipedia is the clean example. The infobox <img> on the Eiffel Tower article:

src:    //thumb.wikimedia.org/.../thumb/8/85/Tour_Eiffel_Wikimedia_Commons_(cropped).jpg/330px-Tour_...jpg?utm_source=...
srcset: //thumb.wikimedia.org/.../thumb/8/85/Tour_Eiffel_Wikimedia_Commons_(cropped).jpg/960px-Tour_...jpg?utm_source=... 2x
Enter fullscreen mode Exit fullscreen mode

The best srcset entry is 960 × 1599. The original, at upload.wikimedia.org/wikipedia/commons/8/85/Tour_Eiffel_Wikimedia_Commons_(cropped).jpg, is 2900 × 4830 and 4.3 MB. No markup on the page points there. You only get it by knowing how the CDN builds its paths.

That is the job of a list of per-host rewrite rules. Each rule is a test and an apply on a URL object, and the first rule that matches wins:

function upgradeToFullSize(href) {
  const u = new URL(href);
  for (const rule of UPGRADE_RULES) {
    if (rule.test(u)) { rule.apply(u); break; }
  }
  return u.href;
}
Enter fullscreen mode Exit fullscreen mode

My list has about 50 rules now. Four of them cover most of the patterns you will meet.

Size in the path: Wikimedia

Thumbnails are /thumb/<hash>/<file>/<width>px-<file>. Drop /thumb/ and the last segment, and move to the upload host (thumbnails moved to thumb.wikimedia.org this year, originals did not):

{
  test: (u) => /^(upload|thumb)\.wikimedia\.org$/.test(u.hostname) && u.pathname.includes('/thumb/'),
  apply: (u) => {
    const m = /^(.*)\/thumb\/(.+?)\/[^/]+$/.exec(u.pathname);
    if (m) { u.hostname = 'upload.wikimedia.org'; u.pathname = `${m[1]}/${m[2]}`; u.search = ''; }
  },
}
Enter fullscreen mode Exit fullscreen mode

Size in the file name: WordPress

WordPress writes a resized copy per registered size as name-300x169.jpg, and since 5.3 it also caps big uploads at 2560 px as name-scaled.jpg. Stripping the suffix gives the upload:

{
  test: (u) => /\/wp-content\/uploads\//.test(u.pathname),
  apply: (u) => {
    u.pathname = u.pathname.replace(/-(?:\d{2,5}x\d{2,5}|scaled|rotated)(\.(?:jpe?g|png|webp|gif|avif))$/i, '$1');
  },
}
Enter fullscreen mode Exit fullscreen mode

The WP Tavern thumbnail above goes from 300 × 169 to 1280 × 720. This rule matches on the path, not the host, because WordPress runs on millions of domains.

Size in the query: Shopify and Unsplash

Image CDNs that resize on the fly usually take the size as query parameters. Remove them and the CDN serves the stored file. On a Shopify store (Death Wish Coffee's home page):

/cdn/shop/files/Blanket-146.jpg?crop=center&height=500&v=1711647378&width=500   -> 500 × 500
/cdn/shop/files/Blanket-146.jpg?v=1711647378                                    -> 1500 × 1500
Enter fullscreen mode Exit fullscreen mode
{
  test: (u) => u.hostname === 'cdn.shopify.com' || /\/cdn\/shop\/(files|products)\//.test(u.pathname),
  apply: (u) => {
    u.pathname = u.pathname.replace(/_(\d+x\d*|x\d+|pico|icon|thumb|small|compact|medium|large|grande)(@\dx)?(\.(?:jpe?g|png|webp|gif))$/i, '$3');
    for (const k of ['width', 'height', 'crop']) u.searchParams.delete(k);
  },
}
Enter fullscreen mode Exit fullscreen mode

The rule deletes only width, height and crop, and leaves v (a cache buster) in place. Older Shopify themes put the size in the file name instead (_400x.jpg, _grande.jpg), so the rule handles both.

Unsplash is the same idea with Imgix parameters: delete w, h, fit, crop, q, dpr, auto, fm and cs. There is one trap. /opengraph/ share cards are a 1 × 1 PNG stretched by w and h, so stripping their parameters gives you a single pixel. The rule skips that path.

Know where to stop: Flickr

Flickr encodes the size as a one-letter suffix. For one photo from the Explore page:

55634550852_65365e37ea_n.jpg  -> 240 × 320
55634550852_65365e37ea.jpg    -> 375 × 500
55634550852_65365e37ea_b.jpg  -> 769 × 1024
55634550852_65365e37ea_o.jpg  -> HTTP 410
Enter fullscreen mode Exit fullscreen mode

The original (_o) uses a different secret than the other sizes, so rewriting the suffix gets you a 410. The rule upgrades small sizes to _b and leaves anything at _b or above alone. A unit test pins that boundary, so a later "improvement" to the rule cannot start producing 410s.

Step 3: the page may already link to the original

Galleries and lightboxes often wrap a thumbnail in a link to the big file:

<a href="/photos/full/IMG_2041.jpg"><img src="/photos/thumb/IMG_2041.jpg"></a>
Enter fullscreen mode Exit fullscreen mode

If the closest <a href> ends in an image extension, that URL beats any rewrite rule, because the site itself says it is the bigger version. The link still goes through the rules, though: a WooCommerce gallery link can point at the -scaled copy. Google Images is a special case of the same thing. Results are base64 thumbnails, and each result's original URL sits in the page's script data next to the thumbnail as ["<thumb>",h,w],["<original>",h,w].

Step 4: trust the bytes, not the extension

Ask that same Shopify URL for Blanket-146.jpg with a browser's Accept: image/avif,image/webp,*/* header and you get Content-Type: image/webp and WebP bytes. The file name still says .jpg. Plenty of CDNs pick the format from Accept, and some send the wrong Content-Type too.

So the extension never trusts either. It reads the first bytes:

if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'jpg';
if (b[0] === 0x89 && ascii(1, 3) === 'PNG') return 'png';
if (ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') return 'webp';
if (ascii(4, 4) === 'ftyp' && ['avif', 'avis'].includes(ascii(8, 4))) return 'avif';
if (/<(!doctype html|html|head|body)[\s>]/.test(head)) return 'html';
Enter fullscreen mode Exit fullscreen mode

The html check matters as much as the image ones. A lot of "image" responses are error pages served with status 200.

Step 5: when the original fails, fall back loudly

Rules are guesses, and guesses break when a site changes its CDN. So every item keeps a short list of URLs, best first:

const urls = [item.resolvedUrl, item.fullUrl, item.linkUrl, item.url].filter(Boolean);
Enter fullscreen mode Exit fullscreen mode

fullUrl is the rewritten original, linkUrl is the page's own link when a rule changed it, and url is what the page showed. The fetch loop tries each one in turn and moves to the next one on any of these:

  • a non-2xx status (Flickr's 410, WordPress's 404 for a -scaled file that was never made);
  • bytes that sniff as HTML;
  • a non-image Content-Type with bytes that do not sniff as anything;
  • a CDN-specific "no image" signal. Wayfair answers some masters with HTTP 200, a no-image: true header and a 513 × 321 placeholder JPEG.

One more case comes after the download: Chrome cannot decode a TIFF or HEIC original, so if conversion fails on the upgraded file, it fetches the page's own image and converts that.

The fallback should be visible. When a wanted original fails and the extension saves the page's smaller image, it counts that and tells the user. Otherwise "full size" quietly turns into "thumbnail" and nobody notices until the print comes back blurry.

Rules for writing rules

A few habits that kept the list from turning into a source of bug reports:

  • Make the test narrow. Match the exact host and the path shape, not just a substring. A rule that fires on the wrong host rewrites a working URL into a broken one.
  • Never ask for a bigger size than the stored one. Target's Scene7 server, Flipkart and Ulta will all upscale if you pass a bigger width, and you end up with a large blurry file that is worse than the original. Removing the size parameter is safer than raising it.
  • Leave watermarks and overlays alone. If a Cloudinary URL has an l_ or u_ layer step, the site is adding a watermark, and the rule returns the URL exactly as served.
  • Verify on live URLs and date the comment. Most rules in my list carry a comment like (verified 2026-10-24), so when a site changes I know how old my assumption is.

I put all of this into PixLasso, a Chrome and Edge extension that saves every image on a page (or across many tabs) into one ZIP at the largest size it can find, and can convert them to JPG, PNG or WebP on your computer. It is free for 30 files per batch; Pro is $9.99 once. https://pixlasso.com/?src=devto

Disclosure: I am the developer. The code above is simplified from the extension's urls.js and fetch pipeline.

Top comments (0)