Cloudflare Images with flexible delivery and retina srcSet


Cloudflare Images can serve every size you need from one source. Once you stop thinking in fixed variants and start thinking in widths, the responsive <img> writes itself.

This post is part of the photography portfolio series . It covers how the photography sites at alex.edestudio.us and jamie.edestudio.us deliver photos to the browser: one source per photo in Cloudflare Images, three rendering profiles (grid, display, hero), retina-aware srcSet, and an honest answer to “how do downloads work”.


Cloudflare Images supports two ways to ask for a resized image:

  • Named variants . You create a variant called grid with width=1200, fit=scale-down. Then you request https://imagedelivery.net/<hash>/<image-id>/grid and get that fixed size back. Add a grid2x variant for retina. Add display, hero, and their *2x siblings for other places on the site.
  • Flexible delivery . You enable flexible variants account-wide and request URLs like https://imagedelivery.net/<hash>/<image-id>/w=1200,fit=scale-down,quality=85,sharpen=1,metadata=none. The width and quality live in the URL, not in a pre-created variant.

I use both. Flexible delivery in production, named variants as the fallback when something is misconfigured. The site picks between them at request time based on the IMAGE_DELIVERY env var.

// functions/lib/images.ts
function useFlexibleDelivery(env: Env): boolean {
  return env.IMAGE_DELIVERY === "flexible";
}

function flexibleVariant(width: number, quality: number, dpr: 1 | 2): string {
  const parts = [`w=${width}`, "fit=scale-down", `quality=${quality}`, "sharpen=1", "metadata=none"];
  if (dpr === 2) parts.push("dpr=2");
  return parts.join(",");
}

The dpr=2 parameter is the whole retina story in flexible mode. No separate 2x variant request, no extra setup; Cloudflare Images renders at 2x density from the same URL pattern.


I think in terms of where the image is being shown, not what size it needs to be:

Profile Default width Default quality Where it’s used
grid 1200 px 85 Gallery and album grid thumbnails
display 2400 px 90 Full photo page, lightbox
hero 2560 px 90 Home page hero cycler

Each profile reads from IMAGE_WIDTH_<PROFILE> and IMAGE_QUALITY_<PROFILE> env vars in wrangler.jsonc, with the table above as defaults if the var is missing or invalid. That means I can bump grid quality from 85 to 88 in one wrangler edit, redeploy, and every gallery image switches without a code change.

function profileWidth(env: Env, profile: ImageProfile): number {
  const key = `IMAGE_WIDTH_${profile.toUpperCase()}` as keyof Env;
  const raw = env[key];
  if (typeof raw === "string" && raw.trim()) {
    const n = Number(raw);
    if (Number.isFinite(n) && n > 0) return n;
  }
  return DEFAULT_WIDTHS[profile];
}

The fallbacks aren’t paranoid; they exist because Pages Functions get a fresh env object on every request and an empty string in wrangler.jsonc is a real possibility if someone is editing config.


profileSrcSet builds the 1x/2x pair:

export function profileSrcSet(env: Env, cfImageId: string, profile: ImageProfile): string {
  const url1x = profileDeliveryUrl(env, cfImageId, profile, 1);
  if (!url1x) return "";
  const url2x = profileDeliveryUrl(env, cfImageId, profile, 2);
  if (!url2x || url2x === url1x) return url1x;
  return `${url1x} 1x, ${url2x} 2x`;
}

The 1x/2x return-early when the URLs are equal is a small guard against degenerate cases (no IMAGES_ACCOUNT_HASH, missing variant config). The same withImageFields helper attaches gridUrl, gridSrcSet, displayUrl, displaySrcSet, heroUrl, heroSrcSet, and downloadUrl to every photo row before it leaves the Pages Function . The React side just renders them:

<img
  src={photo.gridUrl}
  srcSet={photo.gridSrcSet}
  sizes="(min-width: 720px) 320px, 100vw"
  alt={photoAlt(photo)}
/>

The browser does the rest. sizes tells the browser how wide the image will be on screen; srcSet tells it what’s available; it picks. No JavaScript at all on the client for image selection.


A new Cloudflare account does not have flexible delivery enabled and does not have named variants. npm run images:setup does both in one go.

// scripts/setup-image-variants.mjs (excerpt)
const VARIANTS = [
  { id: "grid", options: { fit: "scale-down", width: 1200, metadata: "none" } },
  { id: "display", options: { fit: "scale-down", width: 2400, metadata: "none" } },
  { id: "hero", options: { fit: "scale-down", width: 2560, metadata: "none" } },
  { id: "grid2x", options: { fit: "scale-down", width: 2400, metadata: "none" } },
  { id: "display2x", options: { fit: "scale-down", width: 4800, metadata: "none" } },
  { id: "hero2x", options: { fit: "scale-down", width: 5120, metadata: "none" } },
  { id: "download", options: { fit: "scale-down", width: 12000, metadata: "none" } },
];

await cf("PATCH", "/config", { flexible_variants: true });
for (const variant of VARIANTS) {
  await upsertVariant(variant);
}

The script reads CF_ACCOUNT_ID and CF_API_TOKEN from .dev.vars (or env), enables flexible variants via the Cloudflare Images API , then creates each named variant. If a variant already exists it gets PATCH’d to match the desired options. One quirk: PATCH can fail with “purging the cache” on a fresh re-run, so the script retries up to three times with backoff.

metadata: "none" on every variant is deliberate. Cloudflare Images strips EXIF from the rendered output by default for those variants, which means even if a client uploaded a file with embedded EXIF, the delivered version is clean. (More on the upload-side strip in the next post.)


The public site exposes a download button on every photo page. That button hits /api/public/download/:slug, which calls downloadDeliveryUrl:

export function downloadDeliveryUrl(env: Env, cfImageId: string): string {
  if (!cfImageId) return "";
  if (useFlexibleDelivery(env)) {
    return deliveryUrl(env, cfImageId, downloadFlexibleVariant(downloadWidth(env), downloadQuality(env)));
  }
  const variant = env.IMAGE_VARIANT_DOWNLOAD || DEFAULT_DOWNLOAD_VARIANT;
  return deliveryUrl(env, cfImageId, variant);
}

function downloadFlexibleVariant(width: number, quality: number): string {
  return [`w=${width}`, "fit=scale-down", `quality=${quality}`, "metadata=none", "format=jpeg"].join(",");
}

Public downloads come back through a high-resolution variant, not the raw stored bytes. The default width is 12000 px (Cloudflare Images’ max) at quality 95 with EXIF stripped. The Pages Function fetches that URL, sets Content-Disposition: attachment, and pipes the bytes back to the visitor. Nobody downloading from the public site gets GPS, camera serial, or original timestamp data.

There is a second download path, used only by admin tooling, that fetches the untouched original via the Cloudflare Images API:

export async function fetchCfOriginalImage(env: Env, cfImageId: string): Promise<Response> {
  const url = `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/images/v1/${encodeURIComponent(cfImageId)}/blob`;
  return fetch(url, { headers: { Authorization: `Bearer ${env.CF_API_TOKEN}` } });
}

That endpoint requires CF_API_TOKEN and returns the exact bytes that were uploaded (after any browser-side strip, but before any variant transformation). Useful for the EXIF backfill script. Never wired to a public route. Even if it were, the upload-side strip means there is no extra EXIF to leak.

The distinction matters because the README I wrote when I first set this up made it sound like blob was the public path. It isn’t. Public goes through a variant; admin goes through blob.


Two things sit on the to-do list:

  • <picture> with AVIF. Right now every URL is JPEG-backed. Cloudflare Images can serve AVIF and WebP when the browser supports them, and a <picture> element with explicit type="image/avif" sources would shave another 20-30% off the wire bytes. The lift is small; I just have not done it.
  • Per-photo focal points. fit=scale-down never crops, so the grid thumbnails always show the whole frame. That’s right for some photos and wrong for others. A focal_x / focal_y column on photos plus fit=cover,gravity={x},{y} on the grid variant would let me hand-pick the crop on a per-photo basis.

Both are improvements, not blockers. The current behaviour ships.


  1. Building a photography portfolio on Cloudflare’s full stack. The stack overview: Pages, D1, Images, Access, and Workers AI.
  2. Two sites, one codebase. The scripts/deploy-pages.mjs indirection, per-site D1, and Gitea CI.
  3. Cloudflare Images flexible delivery and retina srcSet. Variant setup, srcSet generation, and the original-download endpoint. (this post)
  4. Direct Creator Upload from the browser. Why a Pages Function should not proxy your image bytes, and what the three-request handshake looks like in DevTools.
  5. Stripping EXIF before upload and backfilling existing photos. What gets stripped, what stays, and the npm run backfill:exif script.
  6. AI-assisted captions, alt text, and tags. The Workers AI vision-model dispatcher and where it helps versus where it confidently invents.

Right-click any image on either site and look at the URL. You will see the w=, quality=, and (on retina) dpr=2 params right there in the path; there is no obfuscation, and the URL tells you exactly what render Cloudflare Images was asked for.

The Direct Creator Upload docs are a good companion read if you are wiring up the upload side. If anything in this post is wrong or could be done better, tell me on LinkedIn .

×
Page views: