We run a video discovery site across eight regions, and for a long time our biggest embarrassment was thumbnails. A single category page at TrendVidStream renders 40-60 video cards, and each card wants a poster image in three sizes: a 320px grid tile, a 640px hover preview, and a 1280px hero for the watch page. The source images come from a dozen upstream providers at wildly inconsistent dimensions, formats, and byte sizes. Some were 2MB PNGs. Some were WebP already. Some were 404s. Our PHP origin was resizing these on the fly with GD, caching the output to disk, and getting hammered every time a crawler discovered a new region's catalog.
The origin resize approach fails in a specific, predictable way. GD is single-threaded per request, image processing is CPU-bound, and our LiteSpeed workers have a hard 180-second execution budget. When Googlebot crawls the US, GB, DE, and BR catalogs in the same hour, hundreds of never-before-seen thumbnail URLs hit the origin simultaneously, each triggering a synchronous decode-resize-encode-write cycle. PHP-FPM pool saturation, 503s, and a very unhappy Search Console coverage report. The fix was to stop treating thumbnail generation as an origin concern at all. This is a walkthrough of the serverless thumbnail API we built on Cloudflare Workers, the edge cache strategy behind it, and the origin-side signing and reconciliation code that keeps it honest.
The Problem With Resizing at the Origin
Before any code, it's worth being precise about what actually breaks, because the naive reaction is "add more origin cache" and that only delays the failure.
- Cold-URL storms. A resized thumbnail is only cached after the first request. Crawlers and region launches generate thousands of unique first requests in a burst. Cache-on-first-hit gives you zero protection against exactly the traffic pattern that hurts.
- CPU on the critical path. Every uncached thumbnail blocks a request worker doing libjpeg/libwebp work. That worker cannot serve HTML while it resizes an image.
- Storage sprawl. Three sizes times two formats (WebP + JPEG fallback) times a growing catalog is a disk-management problem we did not want on FTP-deployed shared hosting.
- No global distribution. Our origin lives in one datacenter. A user in Vietnam pulling 50 thumbnails pays 50 round trips across the Pacific to a box in the US.
Cloudflare Workers solves the last point for free (the code runs in ~330 cities), and Workers combined with the Cache API plus a resizing backend solves the first three. The Worker becomes a thin, stateless transform-and-cache layer. The origin's only job is to serve the original bytes once, and to sign the URLs so nobody can turn our Worker into an open image-resizing proxy for the entire internet.
Architecture: Signed URLs, Edge Transform, Immutable Cache
The data flow has three actors:
- The origin (PHP 8.4). Owns the catalog in SQLite, knows the canonical source image URL for each video, and mints short-lived HMAC-signed thumbnail URLs that are embedded directly in the rendered HTML.
- The Worker. Validates the signature, checks the edge cache, and on a miss fetches the original and asks Cloudflare Image Resizing to produce the requested variant. Then it writes an immutable cache entry.
- The cache. Cloudflare's edge cache, keyed by the full variant URL. Once written, an entry is served without ever touching the Worker's fetch path again.
The signature matters more than it looks. Without it, thumb.example.com/?src=<any url>&w=1280 is a service that will resize any image on the internet and bill you for the bandwidth. Signing binds each URL to (src, width, format) and an expiry, so only URLs our origin generated are honored.
Signing URLs at the PHP Origin
Here is the origin-side helper. It fits our existing stack: no new dependencies, just hash_hmac, and it reads the signing key from the same environment config we already deploy over FTP. The signature covers every parameter that affects the output bytes, which is what prevents a client from tampering with the width to bypass the cache or generate an unbounded set of variants.
<?php
// app/Helpers/Thumbnail.php (PHP 8.4)
declare(strict_types=1);
final class Thumbnail
{
private const WORKER_HOST = 'https://thumb.trendvidstream.com';
private const ALLOWED_WIDTHS = [320, 640, 1280];
private const TTL_SECONDS = 86400 * 7; // signature valid 7 days
public function __construct(private readonly string $signingKey) {}
/**
* Build a signed, edge-cacheable thumbnail URL.
*/
public function url(string $sourceUrl, int $width, string $format = 'webp'): string
{
if (!in_array($width, self::ALLOWED_WIDTHS, true)) {
throw new InvalidArgumentException("Unsupported width: {$width}");
}
// Expiry is bucketed to the day so identical requests share a cache key
// instead of producing a unique signature every second.
$exp = (intdiv(time(), 86400) * 86400) + self::TTL_SECONDS;
$params = [
'src' => $sourceUrl,
'w' => (string) $width,
'f' => $format,
'exp' => (string) $exp,
];
ksort($params);
$canonical = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$sig = hash_hmac('sha256', $canonical, $this->signingKey);
return self::WORKER_HOST . '/t?' . $canonical . '&sig=' . $sig;
}
/**
* Emit a <picture> element with WebP + JPEG fallback and a srcset.
*/
public function picture(string $sourceUrl, string $alt): string
{
$webp = [];
$jpeg = [];
foreach (self::ALLOWED_WIDTHS as $w) {
$webp[] = $this->url($sourceUrl, $w, 'webp') . " {$w}w";
$jpeg[] = $this->url($sourceUrl, $w, 'jpeg') . " {$w}w";
}
$fallback = $this->url($sourceUrl, 640, 'jpeg');
return sprintf(
'<picture>' .
'<source type="image/webp" srcset="%s" sizes="(max-width:768px) 45vw, 320px">' .
'<img src="%s" srcset="%s" sizes="(max-width:768px) 45vw, 320px"' .
' loading="lazy" decoding="async" alt="%s" width="320" height="180">' .
'</picture>',
htmlspecialchars(implode(', ', $webp), ENT_QUOTES),
htmlspecialchars($fallback, ENT_QUOTES),
htmlspecialchars(implode(', ', $jpeg), ENT_QUOTES),
htmlspecialchars($alt, ENT_QUOTES)
);
}
}
Two design decisions are load-bearing here:
-
Bucketed expiry. If
expweretime() + TTL, every page render would produce a slightly different signature, and therefore a different cache key, for the same image. Bucketingexpto a day boundary means all requests for a given thumbnail within a day resolve to the same URL, so the edge cache actually gets hits. This one detail is the difference between a 5% and a 95% cache hit ratio. -
ksortbefore signing. The Worker rebuilds the canonical string from sorted params. If both sides don't agree on parameter ordering, signatures never match. Sort on both ends and the ordering of query params in the URL becomes irrelevant.
The Worker: Validate, Transform, Cache
The Worker itself is small. It verifies the HMAC using WebCrypto (constant-time comparison is important — a naive === on the hex strings leaks timing information), checks the Cache API, and on a miss delegates the actual pixel work to Cloudflare Image Resizing via the cf.image fetch option. The Worker never decodes a pixel itself; it orchestrates.
// worker.js — Cloudflare Workers (module syntax)
const ALLOWED_WIDTHS = new Set([320, 640, 1280]);
const ALLOWED_FORMATS = new Set(['webp', 'jpeg']);
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname !== '/t') return new Response('Not found', { status: 404 });
const p = url.searchParams;
const src = p.get('src'), w = parseInt(p.get('w'), 10);
const f = p.get('f'), exp = parseInt(p.get('exp'), 10), sig = p.get('sig');
if (!src || !ALLOWED_WIDTHS.has(w) || !ALLOWED_FORMATS.has(f) || !sig) {
return new Response('Bad request', { status: 400 });
}
if (Number.isNaN(exp) || exp * 1000 < Date.now()) {
return new Response('Expired', { status: 410 });
}
if (!(await verify(env.SIGNING_KEY, { src, w: String(w), f, exp: String(exp) }, sig))) {
return new Response('Invalid signature', { status: 403 });
}
// Cache key is the normalized variant URL, minus the signature.
const cacheUrl = new URL(url);
cacheUrl.searchParams.delete('sig');
const cacheKey = new Request(cacheUrl.toString(), { method: 'GET' });
const cache = caches.default;
let response = await cache.match(cacheKey);
if (response) {
response = new Response(response.body, response);
response.headers.set('X-Thumb-Cache', 'HIT');
return response;
}
// Miss: fetch original and let Cloudflare Image Resizing do the transform.
const upstream = await fetch(src, {
cf: {
image: { width: w, format: f, quality: 82, fit: 'cover', metadata: 'none' },
cacheTtl: 86400,
cacheEverything: true,
},
});
if (!upstream.ok) {
// Serve a tiny transparent placeholder rather than a broken image.
return placeholder(w);
}
response = new Response(upstream.body, upstream);
response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
response.headers.set('X-Thumb-Cache', 'MISS');
response.headers.delete('set-cookie');
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
async function verify(secret, params, sig) {
const canonical = Object.keys(params).sort()
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join('&');
const key = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(canonical));
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join('');
return timingSafeEqual(expected, sig);
}
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let out = 0;
for (let i = 0; i < a.length; i++) out |= a.charCodeAt(i) ^ b.charCodeAt(i);
return out === 0;
}
function placeholder(w) {
const h = Math.round((w * 9) / 16);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">` +
`<rect width="100%" height="100%" fill="#1a1a2e"/></svg>`;
return new Response(svg, {
status: 200,
headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=300' },
});
}
Things worth calling out:
-
cacheEverythingandcacheTtlon the upstream fetch cache the original source bytes at the edge too, so a cache miss on the 640px variant doesn't re-fetch the source if the 320px variant already pulled it. -
metadata: 'none'strips EXIF/ICC data. Upstream provider thumbnails sometimes carry embedded color profiles that bloat the file and, occasionally, orientation flags that would flip our posters. -
immutableCache-Control. Because the URL is content-addressed by(src, w, f, exp-bucket), the bytes for a given URL never change.immutabletells browsers to never revalidate, which kills conditional-request chatter entirely. -
The
set-cookiedelete. We learned the hard way on the origin side that anySet-Cookieon a cacheable response makes downstream caches refuse to store it. The same rule applies at the edge.
Wiring It Into a Multi-Region Catalog
Our catalog is populated by a multi-region cron that pulls trending video metadata per region into SQLite with FTS5 for search. The thumbnail source URLs arrive as part of that metadata, and they are not all trustworthy — some upstream providers return placeholder or expired image URLs. We do not want to sign a URL to an image that 404s, because the Worker would then serve our SVG placeholder for a week (the signature TTL). So the cron does a cheap reconciliation pass: a HEAD request to confirm the source is alive before the URL is ever eligible for signing.
# cron/reconcile_thumbnails.py — runs after the per-region fetch step
import sqlite3
import concurrent.futures as cf
import requests
DB = "data/catalog.db"
TIMEOUT = 4
def check(row):
video_id, src = row
try:
r = requests.head(src, timeout=TIMEOUT, allow_redirects=True)
ok = r.status_code == 200 and r.headers.get("content-type", "").startswith("image/")
except requests.RequestException:
ok = False
return video_id, ok
def main():
con = sqlite3.connect(DB)
con.row_factory = None
rows = con.execute(
"SELECT id, thumb_src FROM videos "
"WHERE thumb_checked_at IS NULL OR thumb_checked_at < strftime('%s','now','-3 days')"
).fetchall()
updated = 0
with cf.ThreadPoolExecutor(max_workers=16) as pool:
for video_id, ok in pool.map(check, rows):
con.execute(
"UPDATE videos SET thumb_ok = ?, thumb_checked_at = strftime('%s','now') "
"WHERE id = ?",
(1 if ok else 0, video_id),
)
updated += 1
con.commit()
con.close()
print(f"reconciled {updated} thumbnails")
if __name__ == "__main__":
main()
The picture() helper on the origin then only signs URLs for rows where thumb_ok = 1; everything else renders the inline SVG placeholder directly, so a dead upstream image never wastes a signed request or pins a placeholder in the edge cache for a week. Running this on a 3-day window keeps the HEAD volume small — with 16 threads it clears tens of thousands of rows per region in a couple of minutes, which fits comfortably inside the cron budget.
Measuring Whether It Actually Worked
A thumbnail API is only a win if the cache hit ratio is high. Low hit ratio means you are still doing the expensive transform constantly, just on someone else's CPU that you pay for. The X-Thumb-Cache header makes this observable. A quick Go probe against a sample of real catalog URLs tells us the hit ratio and the miss-path latency, which we run from a couple of regions after every deploy.
// tools/thumbprobe/main.go
package main
import (
"bufio"
"fmt"
"net/http"
"os"
"time"
)
func main() {
f, _ := os.Open("sample_urls.txt")
defer f.Close()
client := &http.Client{Timeout: 10 * time.Second}
var hits, misses int
var missTotal time.Duration
sc := bufio.NewScanner(f)
for sc.Scan() {
url := sc.Text()
if url == "" {
continue
}
start := time.Now()
resp, err := client.Get(url)
if err != nil {
fmt.Printf("ERR %s: %v\n", url, err)
continue
}
elapsed := time.Since(start)
resp.Body.Close()
switch resp.Header.Get("X-Thumb-Cache") {
case "HIT":
hits++
case "MISS":
misses++
missTotal += elapsed
}
}
total := hits + misses
if total == 0 {
fmt.Println("no samples")
return
}
ratio := float64(hits) / float64(total) * 100
fmt.Printf("samples=%d hit=%d miss=%d ratio=%.1f%%\n", total, hits, misses, ratio)
if misses > 0 {
fmt.Printf("avg miss latency: %v\n", missTotal/time.Duration(misses))
}
}
What we saw after moving the whole catalog over:
- Origin CPU for image work dropped to zero. GD is no longer in the request path at all. PHP-FPM pools now only render HTML and JSON.
- Cache hit ratio settled around 96% once the bucketed-expiry fix landed. Before bucketing, it hovered near 30% because every render minted fresh signatures.
- Miss-path latency of 120-400ms, dominated by the source fetch plus resize, all of it off our origin.
- No more crawl-storm 503s. A region launch is now just a batch of cache misses spread across Cloudflare's edge, not a synchronous stampede on one PHP box.
Trade-offs and What I'd Watch
This is not free of sharp edges:
- Cloudflare Image Resizing is a paid feature and is billed per unique transformation. The bucketed-expiry and content-addressed cache keys are what keep "unique transformations" low; get the keying wrong and the bill scales with pageviews, not with catalog size.
-
Signature key rotation needs care. Rotating
SIGNING_KEYinvalidates every URL currently embedded in cached HTML pages. We rotate by supporting two valid keys in the Worker during a transition window equal to our HTML cache TTL. -
The placeholder-on-error path can mask real breakage. Because a failed source fetch returns a 200 SVG, a provider going fully dark looks like "images just got a bit uglier" rather than an alarm. The reconcile cron's
thumb_okcounts are what we actually alert on.
Conclusion
The core move here is refusing to do CPU-bound work on the request-serving origin, and refusing to trust that a cache-on-first-hit will save you from burst traffic. A Cloudflare Worker gives you a stateless, globally distributed transform layer; HMAC-signed URLs keep it from becoming an open proxy; content-addressed cache keys with bucketed expiry give you the 95%+ hit ratio that makes the whole thing economical; and a small origin-side reconciliation job keeps dead upstream images out of the pipeline. The origin goes back to doing what it's good at — serving HTML from SQLite — and the pixel-pushing happens at the edge, close to the eight regions of users who actually asked for it. If you're running a media-heavy site on modest origin hardware, this pattern buys you an enormous amount of headroom for a very small amount of code.
Top comments (0)