Last quarter our edge logs surfaced something embarrassing: 38,000 requests an hour to /thumb/* on a single European POP, and every one of them was a PHP request whose entire job was to 302 redirect the browser to i.ytimg.com. At ViralVidVault we track viral videos across the EU, and a single trending grid renders 40–60 thumbnails. Multiply that by real traffic and our LiteSpeed origin was burning worker processes proxying images it doesn't even own.
Three problems were stacked on top of each other:
- Origin load. Every thumbnail was a full PHP request against the LiteSpeed SAPI, competing with actual page rendering and SQLite reads.
-
A GDPR problem. Redirecting an EU visitor to
i.ytimg.commeans their browser makes a direct third-party request to Google's CDN — a request we are not allowed to fire before consent. - Broken Core Web Vitals. We never knew a thumbnail's dimensions until it arrived, so Cumulative Layout Shift punished us, and we were shipping JPEGs to browsers that would happily accept WebP or AVIF.
I moved the whole thing onto a Cloudflare Worker. Below is the architecture, the URL-signing scheme, the actual Worker code, and the Python and Go tooling I used to warm and load-test it before it went live.
Why an edge Worker instead of resizing at the origin
The naive fix is to resize in PHP with GD or Imagick and cache the output on disk. I ran that for a week and hated it. Image resizing is CPU-bound, and on shared LiteSpeed hosting CPU is exactly the resource you're rationed on. Every cold thumbnail stole cycles from page rendering, and the disk cache didn't help visitors in Warsaw or Lisbon because the bytes still traveled from one origin in one datacenter.
A Worker flips all of that:
- It runs in 300+ cities, so a visitor in Madrid is served from Madrid, not from wherever your origin happens to live.
- The
caches.defaultAPI gives you per-object control over the cache key — you can key on the negotiated image format, not just the URL. - Cloudflare's image resizing runs in the
fetch()cfoptions, so you never allocate a pixel buffer in your own code. - You can strip cookies, referrers, and other PII at the edge before anything reaches a third party.
- It decouples image traffic from your origin entirely. My PHP process count for
/thumb/*went to zero.
The mental model that made it click for me: the Worker is a signed, format-aware caching reverse proxy in front of YouTube's thumbnail CDN. The two words that carry all the weight there are "signed" and "caching."
Signing thumbnail URLs so the Worker can't become an open proxy
The moment you put a public endpoint like img.viralvidvault.com/t/<id> on the internet, someone will try to use it to proxy arbitrary content, run up your resize quota, or poison your cache with junk keys. The fix is to make the app the only thing that can mint valid URLs, using an HMAC the Worker can verify but nobody else can forge.
This is a non-secret integrity check, not encryption — the video ID and width are public. All the signature does is prove we generated this exact combination of parameters. Here is the signer in our PHP 8.4 app, which reads trending videos out of SQLite and renders the grid:
<?php
// app/Helpers/ThumbSigner.php — mints signed, cacheable thumbnail URLs
declare(strict_types=1);
final class ThumbSigner
{
private const WORKER_BASE = 'https://img.viralvidvault.com';
private const TTL = 86400 * 30; // 30 days
public function __construct(private readonly string $secret) {}
public function url(string $videoId, int $width = 320, string $quality = 'hqdefault'): string
{
$exp = time() + self::TTL;
$payload = sprintf('%s|%d|%s|%d', $videoId, $width, $quality, $exp);
// 16 bytes of the digest is plenty for a non-secret integrity check
$sig = substr(hash_hmac('sha256', $payload, $this->secret), 0, 32);
return self::WORKER_BASE . '/t/' . rawurlencode($videoId)
. '?w=' . $width
. '&q=' . rawurlencode($quality)
. '&exp=' . $exp
. '&sig=' . $sig;
}
}
A few deliberate choices in there. The expiry is baked into the signed payload, so a leaked URL stops working after 30 days without any server-side state to track. I only keep 32 hex characters (128 bits) of the digest to keep URLs short — this is an integrity check, not a password, so truncation is fine. And the same $secret lives in the app config and in the Worker's environment binding; rotating it invalidates every outstanding URL at once, which is occasionally exactly what you want.
In a template it's just <img src="<?= $signer->url($video['id'], 320) ?>" width="320" height="180" loading="lazy"> — and note that because we control the width, we can finally emit correct width/height attributes and kill the layout shift.
The Worker itself
The Worker validates the signature, rejects expired links, negotiates the output format from the request's Accept header, resizes through Cloudflare's image pipeline, and caches the result with a format-specific cache key. It's deployed with wrangler deploy and the secret is bound as THUMB_SECRET.
// worker.js — deployed with `wrangler deploy`
const UPSTREAM = "https://i.ytimg.com";
async function hmacHex(secret, message) {
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
return [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, "0")).join("");
}
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 withHeaders(res, status) {
const r = new Response(res.body, res);
r.headers.set("Cache-Control", "public, max-age=2592000, immutable");
r.headers.set("X-Cache", status);
r.headers.set("Referrer-Policy", "no-referrer");
r.headers.delete("Set-Cookie");
return r;
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/t\/([A-Za-z0-9_-]{6,20})$/);
if (!match) return new Response("Not found", { status: 404 });
const videoId = match[1];
const w = url.searchParams.get("w") ?? "320";
const q = url.searchParams.get("q") ?? "hqdefault";
const exp = url.searchParams.get("exp") ?? "0";
const sig = url.searchParams.get("sig") ?? "";
if (Number(exp) < Math.floor(Date.now() / 1000)) {
return new Response("Expired", { status: 410 });
}
const expected = (await hmacHex(env.THUMB_SECRET, `${videoId}|${w}|${q}|${exp}`)).slice(0, 32);
if (!timingSafeEqual(sig, expected)) {
return new Response("Bad signature", { status: 403 });
}
const wantsWebp = (request.headers.get("Accept") ?? "").includes("image/webp");
const fmt = wantsWebp ? "webp" : "jpg";
const cacheKey = new Request(`${url.origin}/t/${videoId}?w=${w}&q=${q}&f=${fmt}`, request);
const cache = caches.default;
const hit = await cache.match(cacheKey);
if (hit) return withHeaders(hit, "HIT");
const upstream = await fetch(`${UPSTREAM}/vi/${videoId}/${q}.jpg`, {
cf: {
image: { width: Number(w), format: wantsWebp ? "webp" : "jpeg", quality: 82 },
cacheEverything: true,
},
});
if (!upstream.ok) return new Response("Upstream error", { status: 502 });
const response = withHeaders(new Response(upstream.body, upstream), "MISS");
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
},
};
The details that matter in production:
-
The cache key includes the negotiated format. Without
f=${fmt}in the key, a Chrome user's WebP would get served to a client that only asked for JPEG, and vice versa. This is the single most common Worker caching bug I see. -
timingSafeEqualmatters even for a truncated integrity check. A naive===on the signature leaks timing information; the constant-time compare closes that door for basically free. -
ctx.waitUntilwrites to cache without blocking the response. The visitor gets their bytes; thecache.putfinishes afterward. -
Set-Cookieis stripped andReferrer-Policy: no-referreris set so nothing leaks upstream and nothing marks the response uncacheable. -
max-age=2592000, immutabletells the browser to never revalidate. Because the URL is signed and versioned by its parameters, a changed image means a changed URL — so immutable is safe.
Warming the cache from CI so the first EU visitor never pays
A cold cache means the first person to view a trending video eats the full resize latency. For a viral-video site that's exactly the wrong person to punish, because trends spike fast. So after every cron fetch that updates our trending table, a small Python job pre-warms the top thumbnails across our core EU regions and asserts that they actually landed in the edge cache.
#!/usr/bin/env python3
# warm_thumbs.py — pre-warm the edge cache for today's trending grid
import sys
import sqlite3
import httpx
DB = "data/backlink.db"
WARM_LIMIT = 200
REGIONS = ("DE", "FR", "GB", "NL")
def trending_ids(db_path: str, limit: int) -> list[str]:
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
placeholders = ",".join("?" * len(REGIONS))
rows = con.execute(
f"SELECT DISTINCT video_id FROM videos "
f"WHERE region IN ({placeholders}) "
f"ORDER BY trend_score DESC LIMIT ?",
(*REGIONS, limit),
).fetchall()
con.close()
return [r["video_id"] for r in rows]
def main() -> int:
ids = trending_ids(DB, WARM_LIMIT)
cold = 0
with httpx.Client(timeout=10.0, http2=True) as client:
for vid in ids:
# the PHP app owns the secret; ask it for a signed URL
signed = client.get(
"https://viralvidvault.com/internal/sign",
params={"v": vid, "w": 320},
).text.strip()
r = client.get(signed, headers={"Accept": "image/webp"})
r.raise_for_status()
if r.headers.get("X-Cache") == "MISS":
cold += 1
print(f"warmed {len(ids)} thumbs across {len(REGIONS)} regions, {cold} were cold")
return 0
if __name__ == "__main__":
sys.exit(main())
The key discipline here is that the warmer never touches the HMAC secret. It asks an internal, IP-allowlisted PHP endpoint to sign the URL, then fetches it exactly like a browser would — same Accept: image/webp header, so it warms the same cache key a real Chrome user will hit. The X-Cache header the Worker sets is what makes this observable: if cold isn't dropping to near zero on the second run, something is wrong with your cache key.
Running this from a single CI box only warms one or two POPs, not all 300. That's an honest limitation. In practice warming your busiest region catches the majority of the spike, and organic traffic warms the rest within minutes — but don't tell yourself a single-region warm means the global cache is hot.
Load-testing before we trusted it
Before pointing production <img> tags at the Worker, I wanted p95 numbers, not vibes. A short Go program gives you an honest concurrent latency profile without the overhead of a full load-testing framework.
// loadtest.go — measure p50/p95/p99 latency against the worker
package main
import (
"fmt"
"net/http"
"sort"
"sync"
"time"
)
func main() {
const (
target = "https://img.viralvidvault.com/t/dQw4w9WgXcQ?w=320&q=hqdefault&exp=4102444800&sig=REPLACE"
concurrency = 50
total = 5000
)
client := &http.Client{Timeout: 8 * time.Second}
jobs := make(chan int, total)
var mu sync.Mutex
var samples []time.Duration
var wg sync.WaitGroup
for w := 0; w < concurrency; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for range jobs {
start := time.Now()
req, _ := http.NewRequest("GET", target, nil)
req.Header.Set("Accept", "image/webp")
resp, err := client.Do(req)
if err != nil {
continue
}
resp.Body.Close()
mu.Lock()
samples = append(samples, time.Since(start))
mu.Unlock()
}
}()
}
for i := 0; i < total; i++ {
jobs <- i
}
close(jobs)
wg.Wait()
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
p := func(q float64) time.Duration { return samples[int(float64(len(samples))*q)] }
fmt.Printf("n=%d p50=%v p95=%v p99=%v\n", len(samples), p(0.50), p(0.95), p(0.99))
}
Run it once to warm the cache, then run it again for the numbers that matter — the second run is all HITs and represents steady state. Against a warm edge cache from a European client I measured p50 around 14ms and p95 around 31ms, which is the range where thumbnails feel instant and never gate the layout. Cold-cache runs are noisier because they include the upstream fetch and the resize, which is exactly why the Python warmer exists.
What actually changed
After a week in production, measured against the same trending grid:
-
PHP requests to
/thumb/*dropped from ~38k/hour to zero. The origin no longer participates in image serving at all. - Edge cache hit ratio settled at ~97% once the warmer was wired into the cron. The 3% misses are genuinely new videos.
- Median thumbnail payload fell ~35% by shipping WebP to browsers that accept it, at quality 82 where the difference is invisible in a 320px grid.
-
Layout shift on category pages went to effectively zero, because we now emit correct
width/heightand the URL carries the render size. - No EU browser hits Google's CDN directly anymore — every thumbnail comes from our own domain.
The GDPR angle nobody talks about
That last point is the one I care about most, and it rarely comes up in performance write-ups. When you embed i.ytimg.com directly, an EU visitor's browser opens a connection to a Google server, sending their IP and a referrer, before they've consented to anything. Under the GDPR and the German court reading of the ePrivacy rules, that's a third-party data transfer you can't justify on a page that's still waiting for a consent choice.
Proxying through your own Worker changes the legal shape of the request. The browser talks only to img.viralvidvault.com. The Worker fetches from YouTube server-to-server, with no cookies and Referrer-Policy: no-referrer, so no visitor IP or referring URL leaves the EU edge toward Google. You've turned an unavoidable third-party request into a first-party one you fully control — which is the same reason we run our own GDPR-compliant analytics instead of embedding someone else's script.
Conclusion
A video thumbnail API sounds like a toy problem until you're serving forty of them per page to viral-scale traffic. Pushing it to a Cloudflare Worker gave us three wins at once: the origin stopped burning PHP processes on redirects, EU visitors stopped making pre-consent requests to Google, and Core Web Vitals recovered because we finally controlled dimensions and format. The signing scheme keeps the endpoint from becoming an open proxy, the Python warmer keeps trend spikes cheap, and the Go load test kept me honest about latency before I shipped. The whole thing is well under 200 lines of code and has been the least-maintenance service we run. If your stack redirects to a third-party image CDN today, this is a weekend's work with an outsized payoff.
Top comments (0)