Building an image optimization pipeline with a CDN
Build one Worker that turns an embedded public image into three WebP resources and serves the HTML that selects between them. You can inspect the decoded images locally, then deploy the same code with Workers Cache enabled and check whether repeated requests actually hit the cache.
Understanding image CDNs
An image pipeline needs both transformation and delivery. Here, the
Images binding resizes raw image
bytes inside a Worker. Workers Cache can serve
the resulting HTTP response without executing that Worker again. It must be enabled explicitly;
returning a Cache-Control header alone does not enable it.
This example fixes the output format to WebP and exposes only three image paths. The source is a synthetic 1,280 × 640 PNG with four colored panels. It makes the example self-contained and easy to check; it does not demonstrate photographic quality or reduced file size. There are no uploads, private images, or arbitrary source URLs.
Prepare the project
The commands below use Bash on Linux. The tested versions are
Node.js 26.12.1, Corepack, Yarn 4.12.0, and
Wrangler 4.154.0. Node 26 is a maintained release line; these are reproduction pins, rather than a
claim that other patches are incompatible. Corepack is a separate prerequisite on Node 26: follow
its installation instructions if corepack
is unavailable. You also need cURL for the deployed cache check and a browser that supports WebP.
For deployment, use a Cloudflare account with Workers and the Images binding available, an existing
workers.dev subdomain, and Wrangler authentication for that account. Local development below does
not require a cloud deployment. Consult Cloudflare’s
authentication options
before deploying; image transformations and Worker requests can incur charges.
From a writable directory, paste this block. It refuses to replace an existing image-pipeline
directory and leaves your shell in its original directory:
(
for tool in node corepack; do
command -v "$tool" >/dev/null 2>&1 || {
printf 'Missing prerequisite: %s\n' "$tool" >&2
exit 1
}
done
mkdir image-pipeline &&
cd image-pipeline &&
mkdir src &&
cat > package.json <<'JSON' &&
{
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": {
"start": "wrangler dev --ip 127.0.0.1 --port 8787",
"build": "wrangler deploy --dry-run --outdir dist",
"deploy": "wrangler deploy"
},
"devDependencies": { "wrangler": "4.154.0" }
}
JSON
printf 'nodeLinker: node-modules\nignorePath: true\nenableScripts: true\nnpmMinimalAgeGate: 0\n' > .yarnrc.yml &&
touch yarn.lock
)
The local lockfile gives this example its own Yarn project boundary. Its
Yarn settings select node_modules and keep the pinned
Yarn executable instead of inheriting a parent’s yarnPath. They also allow the native dependencies’
installation scripts. npmMinimalAgeGate: 0 makes this isolated example install its pinned release
without a parent’s package-age delay; keep your organization’s policy when integrating it into an
existing application. From that same parent directory, install and check the actual CLI:
(cd image-pipeline && corepack yarn install && corepack yarn exec wrangler --version)
Expect 4.154.0. If installation fails, fix the reported cause and repeat this install block in the
same parent directory. Keep package.json and the project directory; rerunning the creation block
would correctly refuse the existing directory. If creation itself stopped partway through, inspect
that directory before continuing or choose a new empty location.
Setting up image optimization
Save this as image-pipeline/wrangler.jsonc. Choose a Worker name you own and have not used before
when deploying, so the example does not replace an existing application:
{
"name": "image-pipeline-example",
"main": "src/index.ts",
"workers_dev": true,
"compatibility_date": "2026-10-28",
"images": { "binding": "IMAGES" },
"cache": { "enabled": true, "cross_version_cache": false }
}
The images entry supplies env.IMAGES. The separate cache entry opts this Worker into
Workers Cache. The image responses
below are public and cacheable for 3,600 seconds; the HTML and failure responses use no-store.
Save the complete Worker as image-pipeline/src/index.ts:
interface ImagesBinding {
input(stream: ReadableStream<Uint8Array>): {
transform(options: { width: number }): {
output(options: { format: 'image/webp' }): Promise<{
response(options?: { headers?: Record<string, string> }): Response
}>
}
}
}
const sourceBase64 =
'iVBORw0KGgoAAAANSUhEUgAABQAAAAKAAgMAAAC+nT0yAAAADFBMVEXIPCgoyGQoPMjctCj/cAxvAAADMUlEQVR42u3TQQ0AIAADsQWPmMQkiOAzQk9CsyXlzfJGdBVAgAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAAFECBAgAIIECBAAQQIEKAAAnymVd4uzwJdGCBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAAFECBAgAIIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIECBAAQQIEKAAAgQIUAABAgQogAABAhRAgAABCiBAgAAFECBAgAIIECBAAQQIEKAAAgQIEKAAAgQIUAABAgQogAABAhRAgAA/6QB65UPMTK7o+gAAAABJRU5ErkJggg=='
const source = Uint8Array.from(atob(sourceBase64), (value) => value.charCodeAt(0))
const page = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>Responsive image pipeline</title>
<style>body{margin:0}img{display:block;width:100%;max-width:640px;height:auto}</style>
</head>
<body>
<h1>Responsive image pipeline</h1>
<img src="/image/1280.webp"
srcset="/image/320.webp 320w, /image/640.webp 640w, /image/1280.webp 1280w"
sizes="(max-width: 640px) 100vw, 640px" width="1280" height="640"
alt="Four colored panels used to check image resizing">
</body>
</html>`
const widths: Record<string, number> = {
'/image/320.webp': 320,
'/image/640.webp': 640,
'/image/1280.webp': 1280,
}
export default {
async fetch(request: Request, env: { IMAGES: ImagesBinding }): Promise<Response> {
const url = new URL(request.url)
const notFound = () =>
new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } })
if (request.method !== 'GET' || url.search !== '') return notFound()
if (url.pathname === '/') {
return new Response(page, {
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
})
}
if (!Object.hasOwn(widths, url.pathname)) return notFound()
const width = widths[url.pathname]
try {
return (
await env.IMAGES.input(new Blob([source]).stream())
.transform({ width })
.output({ format: 'image/webp' })
).response({
headers: {
'Cache-Control': 'public, max-age=3600',
'X-Image-Run': crypto.randomUUID(),
},
})
} catch {
return new Response('Image transformation unavailable', {
status: 503,
headers: { 'Cache-Control': 'no-store' },
})
}
},
}
Only /image/320.webp, /image/640.webp, and /image/1280.webp select a transformation. Their
decoded dimensions should be 320 × 160, 640 × 320, and 1,280 × 640, respectively, with red/green
panels above blue/yellow panels. The binding sets the response’s content type from the requested
output format. Renaming PNG bytes or setting a WebP header would not convert an image.
Implementing responsive images
Start the local server from the parent directory:
(cd image-pipeline && corepack yarn start)
Open http://127.0.0.1:8787/. You should see the four panels, fitted to the viewport up to a
640-pixel display width. Open each image path directly to inspect its content and dimensions.
Stop the foreground server with Ctrl+C before the next step. If port 8787 is occupied, stop the
other listener or choose another port in the start script before starting this example.
The HTML’s sizes matches that CSS layout: 100vw through 640 pixels, then 640px. Its 320w,
640w, and 1280w descriptors describe actual resource widths, rather than display widths. The
width and height attributes reserve a 2:1 aspect ratio, while src supplies the default image.
See the HTML image selection rules.
Inspect the chosen image request in your browser’s network panel. A 320-pixel viewport at DPR 1, an 800-pixel viewport at DPR 1, and a 640-pixel viewport at DPR 2 exercise different resource needs. Use fresh browser contexts when comparing them: selection can depend on the browser, device pixel ratio, and cached resources. The markup gives the browser candidates; it does not force every browser to make one universal choice.
Local Wrangler uses a limited offline Images implementation that supports this example’s resizing and explicit format. It checks the page and transformations, but it does not establish a deployed Cloudflare cache hit. Cloudflare documents these local binding limits.
Deploy and observe the cache
Build first. A successful dry run writes the bundled Worker to image-pipeline/dist/index.js
without uploading it. Repeating the build updates that disposable output directory:
(cd image-pipeline && corepack yarn build)
After confirming your account, authentication, and unused Worker name, deploy from the same parent directory:
(cd image-pipeline && corepack yarn deploy)
Open the workers.dev URL printed by Wrangler and check the page and three image URLs again.
Substitute that origin in both commands below. These are complete GETs that discard the body and
print headers; cURL is not using a browser’s image cache:
(
command -v curl >/dev/null 2>&1 || { printf 'Install cURL first\n' >&2; exit 1; }
curl -fsS -D - -o /dev/null 'https://YOUR-WORKER.YOUR-SUBDOMAIN.workers.dev/image/640.webp' &&
curl -fsS -D - -o /dev/null 'https://YOUR-WORKER.YOUR-SUBDOMAIN.workers.dev/image/640.webp'
)
Look for CF-Cache-Status: HIT on a repeated request and compare X-Image-Run with the preceding
response. A hit should retain the marker generated when the Worker produced that response. A
Cache-Control header, identical image bytes, or a stable marker alone does not prove a hit.
If the status is absent, check the deployed cache.enabled setting; repeated misses require
checking the response policy and cache key. Cloudflare’s
cache debugging guide explains these
statuses. Do not treat a local preview as equivalent evidence.
This establishes a cache observation, not a speed or bandwidth benchmark. Measure actual bytes and page metrics on representative images before claiming a performance improvement.
Keep the public example bounded
Every image here is public, and the Worker accepts a fixed set of paths. Keep that boundary when experimenting: accepting arbitrary URLs would add a different source-fetching and access-control problem. Do not substitute confidential images into this public cacheable response path. Private delivery needs its own authorization and cache design.
For a real image library, replace the synthetic source only after choosing how originals are stored
and authorized, then verify the decoded output widths before advertising them in srcset. Managed
processing and delivery are another option; see Transloadit’s Smart CDN.
Rather not run this yourself?
Transloadit’s Smart CDN transforms and delivers files on the fly.
