↑ top
← Back to blog
EN | ES

R2 as an Image CDN: The Complete Pipeline

From a 5MB PNG to a 100KB WebP in R2, served with the right headers. The full publish pipeline for images on Cloudflare Workers.

✦ AI Summary

Cloudflare R2 is object storage — like S3, but without egress fees to Cloudflare's network. Combined with a Worker that handles image serving, it becomes a decent image CDN. A 5MB PNG becomes roughly 80-150KB WebP.

R2 as an Image CDN: The Complete Pipeline

Cloudflare R2 is object storage — like S3, but without egress fees to Cloudflare's network. Combined with a Worker that handles image serving, it becomes a decent image CDN without paying for Cloudflare's Image Resizing feature (which is a paid add-on).

Here's the complete pipeline: how images go from a PNG on my machine to a WebP served from the edge at the right size.

The Problem with Raw Images

A camera photo or a PNG screenshot can easily be 3-8MB. Serving that to every visitor is expensive in two ways: R2 bandwidth costs and user load time. A post hero image that takes 3 seconds to load on a 4G connection kills the reading experience before it starts.

The solution is to compress images before uploading them. Not at serve time — at publish time.

The Publish Script

Every post can have a hero image. I drop it in images/<slug>.(png|jpg|webp) and run npm run publish. The script finds the image, compresses it, uploads it to R2, and sets a flag in the post metadata.

The compression step uses sharp, a Node.js image processing library:

javascriptconst compressed = await sharp(imageBuffer)
  .resize({ width: 1200, withoutEnlargement: true })
  .webp({ quality: 80 })
  .toBuffer();

Three things happen:

  1. Resize to max 1200px wide — height scales proportionally, portrait images stay portrait
  2. Convert to WebP — WebP is roughly 30% smaller than JPEG at the same visual quality
  3. 80% quality — visually indistinguishable from 100% for photographs at this size

The result: a 5MB PNG becomes roughly 80-150KB WebP. That's a 30-60x reduction.

Uploading to R2

After compression, the script uploads to R2 using the Wrangler API:

bashwrangler r2 object put dwsa-posts/<slug> \
  --file images/<slug>.webp \
  --content-type image/webp

The R2 key matches the post slug exactly. The Worker retrieves it by slug when serving the image route.

After upload, the script sets image: true in the post's frontmatter and updates the KV index. The Worker reads this flag to decide whether to use the hero image or the default OG image for the post's og:image meta tag.

Serving the Image

The Worker handles /blog/:slug/image:

javascriptconst obj = await env.POSTS_BUCKET.get(slug);
const headers = new Headers();
obj.writeHttpMetadata(headers);
headers.set("Cache-Control", "public, s-maxage=31536000, max-age=86400");
return new Response(obj.body, { headers });

writeHttpMetadata() copies the stored Content-Type from R2 to the response. The cache headers tell Cloudflare's edge to cache this for one year — the image won't change once uploaded, and if it does, the new version gets a new URL (different slug).

The OG Image Connection

When a post has a hero image, the OG meta tags point to it:

html<meta property="og:image" content="https://dwsa-mm.com/blog/my-post/image" />

Twitter, WhatsApp, and LinkedIn all render this correctly because it's a proper WebP URL with the right Content-Type header. The Worker's dynamic SVG route (/blog/:slug/og) exists as a debug tool, but SVG doesn't work as an og:image — social platforms expect a raster format.

Static Assets: The Same Pattern

The same pipeline handles static site assets — favicon, logo, OG default image, hero video. They live in static/ and get uploaded to R2 by CI whenever the files change:

static/favicon.png  →  R2 key: "favicon"  →  served at /favicon.ico
static/logo.png     →  R2 key: "logo"      →  served at /logo
static/og-default.webp → R2 key: "og-default" → served at /og-image

The Worker checks these routes before the blog routes, so they're always fast regardless of KV state.

What You Don't Get Without Image Resizing

Cloudflare's paid Image Resizing feature serves different sizes based on width query params and automatically optimizes for the requesting browser. Without it, you serve one size for everyone — the 1200px WebP.

For a blog, one size is fine. The hero image is always displayed full-width on desktop, and mobile gets the same image scaled down by CSS. The 1200px WebP at 100KB loads fast on mobile too.

If you needed to serve retina images, thumbnails, or art-directed crops per breakpoint, you'd need Image Resizing or a different approach. For a personal blog, one compressed WebP per post is the right call.

Was this helpful?