Every video listing on my site needs a thumbnail, and for a long time that single requirement was the most expensive part of my infrastructure. I run DailyWatch, a free video discovery platform that indexes tens of thousands of videos across dozens of categories. A single category page renders 40 thumbnails. The home page renders more. Search results render more still. Multiply that by real traffic and you get millions of image requests a day, each one hitting a source thumbnail that might be 1280×720 when the browser only needs a 320-wide card.
The naive setup — proxy the source thumbnail through my origin, resize with GD or Imagick, and cache the result on disk — worked until it didn't. My origin runs PHP 8.4 on LiteSpeed with a SQLite FTS5 index, and it is genuinely fast at serving HTML. But image transformation is CPU-bound, disk-bound, and bursty. A crawler sweeping the site would blow through my worker pool while every PHP process sat there re-encoding JPEGs. The fix was to stop doing image work on the origin entirely and push it to the edge with a Cloudflare Worker. This post is the whole design: the routing, the request signing, the Cache API, and the cron job that warms everything after a crawl.
The problem: thumbnail sprawl at the edge
Before writing a line of Worker code, it helps to name exactly what a thumbnail API has to do, because "resize an image" hides most of the work:
- Fan-in from many sources. Source thumbnails live on third-party CDNs with unpredictable availability, mixed formats, and occasional 404s.
- Fan-out to many sizes. A card wants ~320px, a hero wants ~640px, an Open Graph tag wants exactly 1200×630. You do not want one giant image scaled by the browser.
- Format negotiation. Modern browsers accept WebP or AVIF and save 25–40% over JPEG, but you have to detect that per request.
- Cache or die. The first request for a given (url, size, format) tuple is expensive. Every request after it must be nearly free.
- Never trust the query string. If the resize URL takes an arbitrary source URL, you have just built an open image proxy that anyone can use to launder traffic or DoS a victim.
The origin approach fails the last two points hardest. Disk cache does not shard across POPs, and an unsigned proxy is a liability. Both problems disappear at the edge.
Why a Worker instead of resizing on the origin
Cloudflare Workers run in every POP, so the cache lives next to the user, not next to my server in a single datacenter. Two capabilities make them a good fit for images specifically:
-
The Cache API (
caches.default) lets you store and retrieve fullResponseobjects keyed by request, with the same edge storage that powers Cloudflare's normal CDN caching — but under your programmatic control. -
Image Resizing via
fetchoptions (cf.image) does the actual pixel work on Cloudflare's infrastructure, so the Worker never has to decode a JPEG in JavaScript. The Worker is pure orchestration: validate, key, cache, transform.
The result is that my origin's only job is to emit signed thumbnail URLs inside the HTML. It never touches image bytes again. Here is the core Worker.
// worker.js — deployed with `wrangler deploy`
const ALLOWED_HOSTS = new Set([
'i.ytimg.com',
'i3.ytimg.com',
'img.dailywatch.video',
]);
const ALLOWED_WIDTHS = new Set([160, 320, 640, 1200]);
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname !== '/thumb') {
return new Response('Not found', { status: 404 });
}
const src = url.searchParams.get('src');
const width = parseInt(url.searchParams.get('w') || '320', 10);
const sig = url.searchParams.get('sig');
// 1. Validate before doing any work.
const bad = validate(src, width, sig, env);
if (bad) return new Response(bad, { status: 400 });
const valid = await verifySignature(src, width, sig, env.SIGNING_KEY);
if (!valid) return new Response('Bad signature', { status: 403 });
// 2. Build a normalized cache key (drops sig, sorts params).
const accept = request.headers.get('Accept') || '';
const fmt = accept.includes('image/avif') ? 'avif'
: accept.includes('image/webp') ? 'webp' : 'jpeg';
const cacheKey = new Request(
`https://cache.internal/thumb?src=${encodeURIComponent(src)}&w=${width}&f=${fmt}`
);
const cache = caches.default;
let response = await cache.match(cacheKey);
if (response) {
response = new Response(response.body, response);
response.headers.set('X-Cache', 'HIT');
return response;
}
// 3. Miss: fetch + transform at the edge.
response = await transform(src, width, fmt);
response.headers.set('X-Cache', 'MISS');
// Store without blocking the response.
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
A few decisions in there are load-bearing. The cache key uses a fake internal hostname and drops the signature entirely — two requests with different valid signatures for the same image must hit the same cache entry, or your hit rate collapses. The format is folded into the key because a WebP and a JPEG of the same source are different bytes. And the ctx.waitUntil(cache.put(...)) writes the cache after the response has already started streaming to the user, so the first visitor doesn't pay the storage latency.
Signing requests so nobody weaponizes your proxy
The validation and signature check are where an image proxy earns its keep. Without a signature, ?src=https://victim.example/huge.png&w=1200 turns my Worker into free bandwidth for an attacker. The rule is simple: the origin holds a secret key, computes an HMAC over the exact (src, width) pair, and the Worker recomputes it. No matching signature, no image.
function validate(src, width, sig, env) {
if (!src || !sig) return 'Missing params';
if (!Number.isInteger(width) || !ALLOWED_WIDTHS.has(width)) {
return 'Bad width';
}
let host;
try {
host = new URL(src).hostname;
} catch {
return 'Bad src';
}
if (!ALLOWED_HOSTS.has(host)) return 'Host not allowed';
return null; // valid
}
async function verifySignature(src, width, sig, secret) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
);
const data = enc.encode(`${src}|${width}`);
const mac = await crypto.subtle.sign('HMAC', key, data);
const expected = base64url(new Uint8Array(mac));
// Constant-time-ish compare: length check then char loop.
if (expected.length !== sig.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i);
}
return diff === 0;
}
function base64url(bytes) {
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
The allow-list of hosts is defense in depth: even if the signing key leaks, an attacker can only resize images from hosts I already serve. The width allow-list matters too — without it, an attacker could request 10,000 distinct widths of the same image and fill your cache with junk, a classic cache-key poisoning trick.
Emitting signed URLs from the PHP origin
The origin's side of the contract is tiny. Every place my templates render a thumbnail, they call one helper that produces a signed /thumb URL. Because the HMAC is deterministic, identical inputs always yield identical URLs, which keeps the edge cache warm across every page that references the same video.
<?php
declare(strict_types=1);
final class ThumbUrl
{
private const WORKER = 'https://img.dailywatch.video';
private const ALLOWED = [160, 320, 640, 1200];
public function __construct(private string $signingKey) {}
public function build(string $src, int $width = 320): string
{
if (!in_array($width, self::ALLOWED, true)) {
$width = 320;
}
// Must match the Worker byte-for-byte: `${src}|${width}`.
$mac = hash_hmac('sha256', $src . '|' . $width, $this->signingKey, true);
$sig = rtrim(strtr(base64_encode($mac), '+/', '-_'), '=');
return self::WORKER . '/thumb?' . http_build_query([
'src' => $src,
'w' => $width,
'sig' => $sig,
]);
}
}
// Usage inside a template:
$thumbs = new ThumbUrl($_ENV['THUMB_SIGNING_KEY']);
$card = $thumbs->build($video['thumb_url'], 320);
$og = $thumbs->build($video['thumb_url'], 1200);
The only subtlety is that the signed string must be produced identically on both sides. PHP's hash_hmac(..., true) returns raw bytes so the base64url encoding matches crypto.subtle.sign exactly. I keep the delimiter (|) and field order documented in a comment on both sides because a mismatch there is a silent 403 that's miserable to debug. The signing key itself lives in the environment on the origin and as a Wrangler secret (wrangler secret put SIGNING_KEY) on the Worker — it is never in a repo or a URL.
Doing the actual resize at the edge
The transform function is where Cloudflare's Image Resizing does the heavy lifting. Instead of decoding bytes in the Worker, you pass a cf.image object to fetch and Cloudflare resizes on its side, returning already-encoded output in the format you asked for.
async function transform(src, width, fmt) {
const upstream = await fetch(src, {
cf: {
image: {
width,
fit: 'scale-down', // never upscale past the source
quality: 82,
format: fmt, // 'avif' | 'webp' | 'jpeg'
metadata: 'none', // strip EXIF, shrink payload
},
// Cache the *upstream* fetch separately from our Cache API entry.
cacheTtl: 86400,
cacheEverything: true,
},
});
if (!upstream.ok) {
return placeholder(width); // graceful fallback, see below
}
const out = new Response(upstream.body, upstream);
out.headers.set('Content-Type', `image/${fmt === 'jpeg' ? 'jpeg' : fmt}`);
out.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
out.headers.set('Vary', 'Accept');
out.headers.delete('Set-Cookie');
return out;
}
function placeholder(width) {
// 1x1 transparent-ish gray SVG scaled by the browser. Tiny and cache-friendly.
const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${width}' `
+ `height='${Math.round(width * 9 / 16)}' viewBox='0 0 16 9'>`
+ `<rect width='16' height='9' fill='#1a1a1a'/></svg>`;
return new Response(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=3600', // short: source may recover
},
});
}
Notes from production:
-
immutablewith a one-year max-age is safe because the cache key already encodes every input that affects the bytes. If the source image ever changes, its URL changes, so there is no stale-content risk. -
Vary: Accepttells any downstream cache that the response depends on the format negotiation. Without it, a browser that only speaks JPEG could receive an AVIF cached for a WebP-capable client. -
Deleting
Set-Cookieis not optional. If an upstream response carries a cookie, some caches will refuse to store it. Images should never set cookies. - Placeholders get a short TTL so a temporarily-down source recovers on the next crawl instead of being pinned to gray for a year.
Warming the cache after a crawl
My crawler ingests new videos on a schedule and writes them into SQLite. The moment those rows land, the thumbnails are cold at every POP, and the first real visitor to each new card pays the miss. That is exactly the traffic spike I moved off the origin, so I don't want users absorbing it either. The answer is a warmer: after each crawl, a small Python job requests every new thumbnail URL through the Worker so the first user request is already a HIT.
#!/usr/bin/env python3
"""warm_thumbs.py — prime the edge cache after a crawl."""
import asyncio
import hashlib
import hmac
import base64
import os
import sqlite3
import aiohttp
WORKER = "https://img.dailywatch.video/thumb"
KEY = os.environ["THUMB_SIGNING_KEY"].encode()
WIDTHS = (320, 640)
CONCURRENCY = 16
def sign(src: str, width: int) -> str:
mac = hmac.new(KEY, f"{src}|{width}".encode(), hashlib.sha256).digest()
return base64.urlsafe_b64encode(mac).rstrip(b"=").decode()
def new_thumb_urls(db_path: str):
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
rows = con.execute(
"SELECT thumb_url FROM videos "
"WHERE created_at > strftime('%s','now','-1 hour')"
).fetchall()
con.close()
for r in rows:
for w in WIDTHS:
src = r["thumb_url"]
yield f"{WORKER}?src={src}&w={w}&sig={sign(src, w)}"
async def warm(session, sem, url):
async with sem:
# Ask for AVIF so we warm the format most clients will negotiate.
headers = {"Accept": "image/avif,image/webp,*/*"}
async with session.get(url, headers=headers) as resp:
return resp.headers.get("X-Cache", "?"), resp.status
async def main():
sem = asyncio.Semaphore(CONCURRENCY)
async with aiohttp.ClientSession() as session:
tasks = [warm(session, sem, u) for u in new_thumb_urls("data/videos.db")]
results = await asyncio.gather(*tasks)
misses = sum(1 for cache, _ in results if cache == "MISS")
print(f"warmed {len(results)} urls, {misses} were cold")
if __name__ == "__main__":
asyncio.run(main())
This runs from the same cron that drives the crawl. Because the warmer signs URLs with the identical HMAC the templates use, it hits the exact cache keys real users will. The X-Cache header I set in the Worker turns the warmer into free instrumentation — I can watch cold vs. warm counts over time and know immediately if my hit rate is degrading. Note the width allow-list keeps this bounded: two widths per thumbnail, not an open-ended matrix, so warming a crawl of a few thousand videos is a few thousand cheap requests, not an explosion.
What actually changed after the move
The honest accounting, measured over the first month:
- Origin CPU for images dropped to zero. PHP no longer opens image bytes at all. The LiteSpeed worker pool stopped stalling during crawler sweeps, which incidentally made HTML faster too.
- Edge hit rate settled around 96% once the warmer was in place. The remaining misses are mostly brand-new videos in the gap between crawl and warm, plus the long tail of rare sizes.
- Median thumbnail latency fell because the bytes now come from a POP near the user instead of one datacenter, and AVIF negotiation shaved payload on top of that.
- Bandwidth costs moved to a predictable line item instead of a variable that spiked with every crawler.
Things I'd warn a past version of myself about. Signature mismatches are the number one source of pain, and they always come down to the signed string differing by one byte between PHP and JavaScript — pin the delimiter and field order and write a test that asserts both implementations agree. The cache key is the second: forget to drop the signature or fold in the Accept format, and your hit rate quietly craters while everything still "works." And the width allow-list is not a nicety; it is the difference between a bounded cache and one an attacker can fill at will.
Conclusion
The shape of this solution generalizes well beyond thumbnails: keep your origin authoritative and signing-only, push all the expensive, cacheable transformation to a Worker, make the cache key encode every input that changes the output, and warm the cache from the same job that creates the work. My PHP 8.4 origin got simpler and faster by doing less, and the edge absorbed the part that never belonged on a single server. If you're running any kind of media-heavy site, an image proxy is usually the first thing worth moving to a Worker — it's self-contained, easy to sign, and the hit rate makes the win obvious in a week. It's the same architecture I lean on across the stack at DailyWatch: let the origin decide, let the edge deliver.
Top comments (0)