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”.
Two delivery modes, one mental model ¶
Cloudflare Images supports two ways to ask for a resized image:
- Named variants
. You create a variant called
gridwithwidth=1200, fit=scale-down. Then you requesthttps://imagedelivery.net/<hash>/<image-id>/gridand get that fixed size back. Add agrid2xvariant for retina. Adddisplay,hero, and their*2xsiblings 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.
Three profiles, not seven sizes ¶
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.
Generating srcSet once, per profile ¶
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.
Bootstrapping variants on a fresh account ¶
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.)
Downloads: two paths, one button ¶
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.
What I would change next ¶
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 explicittype="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-downnever crops, so the grid thumbnails always show the whole frame. That’s right for some photos and wrong for others. Afocal_x/focal_ycolumn onphotosplusfit=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.
In this series ¶
- Building a photography portfolio on Cloudflare’s full stack. The stack overview: Pages, D1, Images, Access, and Workers AI.
- Two sites, one codebase.
The
scripts/deploy-pages.mjsindirection, per-site D1, and Gitea CI. - Cloudflare Images flexible delivery and retina srcSet. Variant setup, srcSet generation, and the original-download endpoint. (this post)
- 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.
- Stripping EXIF before upload and backfilling existing photos.
What gets stripped, what stays, and the
npm run backfill:exifscript. - AI-assisted captions, alt text, and tags. The Workers AI vision-model dispatcher and where it helps versus where it confidently invents.
Try it ¶
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 .