Image CDNs power most modern apps. Profile pictures, product images, blog covers — all need resized, format-negotiated, cached, fast. This post is the working design.
The pieces
[User] → [Edge cache] → [Origin server] → [Object storage]
│ │
↓ (miss) ↓
[Transform service] [Source images]
Cache → transform on miss → fetch source → respond. Most requests hit edge.
URL design
https://cdn.example.com/img/USER_AVATAR_ID/w=200,h=200,fit=crop,fmt=avif/file.jpg
Or query params:
https://cdn.example.com/img/USER_AVATAR_ID.jpg?w=200&h=200&fit=crop&fmt=avif
Path-based is more cacheable. Query params can be normalized at edge.
Operations: resize (w/h), crop/fit, format, quality, blur, etc. Stay simple.
Format negotiation
Accept: image/avif, image/webp, image/*
Server selects:
- AVIF (smallest; modern browsers).
- WebP (smaller; near-universal).
- JPEG/PNG fallback.
Save 30-70% bandwidth vs JPEG with no quality loss.
def select_format(accept):
if "image/avif" in accept: return "avif"
if "image/webp" in accept: return "webp"
return "jpeg"
Also: serve different content per Accept; cache key includes format.
Cache hierarchy
[User] → [CDN edge cache (90% hits)]
│
↓ (miss)
[Origin cache (8% hits)]
│
↓
[Transform service (2% miss)]
│
↓
[Object storage]
Layered caching. Edge serves most; origin cache covers cold edges; transform only on true misses.
For Cloudflare-class: edge alone is usually enough.
On-demand transform
async def serve(request):
src_id, params = parse_url(request.path)
cache_key = f"{src_id}:{normalize(params)}"
if cached := await cache.get(cache_key):
return cached
src = await object_store.get(src_id)
transformed = await transform(src, params)
await cache.put(cache_key, transformed, ttl=86400 * 30)
return transformed
Tools:
- libvips / sharp (Node) — fast image lib.
- PIL / Pillow (Python) — slower but ubiquitous.
- imageflow — Rust lib.
- Cloudflare Images / Vercel — managed on-demand.
For self-host: Sharp or libvips behind a small HTTP service.
Preventing abuse
URLs that resize an image to arbitrary dimensions can be abused: requesters force generation of millions of unique sizes, blowing up cache and CPU.
Mitigations:
- Signed URLs: only your service can construct valid URLs.
/img/USER_ID/w=200,h=200/file.jpg?sig=... - Whitelist sizes: only specific dimensions (s, m, l, xl).
- Rate limit per IP.
- CDN cache absorbs repeat requests.
For public-facing CDN: signed URLs are the safest.
Source storage
Object storage:
- S3 / R2 / GCS: standard.
- Versioning: immutable IDs; reuploads = new IDs.
- Lifecycle: archive old originals to cheaper tiers.
URLs reference IDs; never hot-link to S3 directly (no CDN cache; slower).
Cache invalidation
For long-lived URLs (e.g., user avatar): when image changes, URL must change.
/img/USER_AVATAR_v2/...
vs trying to invalidate cache (slow; expensive). Use IDs that change on update.
For temporary content: short TTL is fine.
EXIF / metadata
Strip EXIF (privacy: GPS coordinates, camera info) on upload.
img = Image.open(src)
img.save(dst, quality=85, exif=b"")
Always for user-uploaded content.
Cost control
- Cap dimensions: max 4096×4096; reject bigger.
- Cap quality: 85-90 default; no 100 (negligible quality boost; huge size).
- Use AVIF/WebP: 30-70% bandwidth savings.
- Cache TTL: long; URLs change for updates.
- Origin shielding (cloudflare): single origin hit per cache miss across edges.
CDN options
| Pros | Cons | |
|---|---|---|
| Cloudflare Images | Cheap, integrated | Cloudflare lock |
| Vercel | Tight Next.js | Vercel-only |
| imgix | Mature, full features | Pricier |
| Cloudinary | Most features | Pricier |
| Self-host on Workers | Edge + R2; cheap | DIY ops |
| AWS CloudFront + Lambda@Edge | AWS native | Cold starts |
For self-host on edge: Cloudflare Workers + R2 + Sharp/imageflow. Cheap; performant.
Picture element
<picture>
<source srcset="/img/x/w=400,fmt=avif/file.jpg" type="image/avif">
<source srcset="/img/x/w=400,fmt=webp/file.jpg" type="image/webp">
<img src="/img/x/w=400,fmt=jpeg/file.jpg" alt="..." width="400">
</picture>
Browser picks the format it supports. Server-side: same image generated in 3 formats (cached).
Responsive images
<img srcset="
/img/x/w=400/file.jpg 400w,
/img/x/w=800/file.jpg 800w,
/img/x/w=1600/file.jpg 1600w
" sizes="(max-width: 600px) 100vw, 50vw" src="/img/x/w=800/file.jpg">
Browser picks size based on viewport. Save bandwidth on small screens.
Capacity sketch
For a moderate-sized SaaS:
- 100M images stored.
- 10M unique requests/day.
- 90% cache hit rate.
- Transform service handles ~12k req/day (the misses).
- Object storage: terabytes; CDN bandwidth: tens of TB/month.
Cost: ~$500-2000/month at this scale on Cloudflare. Worth comparing to Cloudinary.
Common mistakes
1. Direct S3 hotlink
No CDN cache; users hit S3 directly. Slow + expensive. Always behind CDN.
2. No signed URLs for public CDN
Abusers generate millions of unique sizes; cache useless; bill explodes.
3. JPEG only
Modern formats save 30-70%. Bandwidth wasted; pages slower.
4. Cache invalidation
Trying to invalidate cache for user avatar updates. Use versioned URLs instead.
5. EXIF preserved
Privacy leak. Strip on upload.
What I’d ship today
For new image-heavy apps:
- Cloudflare Images if low ops budget.
- R2 + Workers + Sharp if more control.
- imgix / Cloudinary if needing CRM-grade transforms.
- Signed URLs universally.
- AVIF/WebP/JPEG picture element.
- Responsive srcset for hero images.
Read this next
If you want my image CDN reference (Workers + R2 + Sharp), it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .