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

ProsCons
Cloudflare ImagesCheap, integratedCloudflare lock
VercelTight Next.jsVercel-only
imgixMature, full featuresPricier
CloudinaryMost featuresPricier
Self-host on WorkersEdge + R2; cheapDIY ops
AWS CloudFront + Lambda@EdgeAWS nativeCold 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

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 .