Skip to content

Serving R2 Images via Cloudflare Workers Best Practices

In a developer community I participate in, a user recently asked: “What is the best practice for using Cloudflare Workers to serve images from an R2 bucket? I’m currently fetching the whole object into memory, but it feels slow and I’m worried about scalability and caching.”

This is a common bottleneck. When moving from a traditional S3-plus-server setup to a serverless edge architecture, the “obvious” way to write the code is often the least performant.

To serve images from R2 efficiently:

  1. Never await object.arrayBuffer(). Instead, pipe the ReadableStream directly from the R2 object to the Response.
  2. Leverage the Cache API. R2 has no egress fees, but Workers have execution time limits. Using the caches.default API reduces Worker sub-requests and latency.
  3. Set Correct Headers. R2 doesn’t automatically guess Content-Type perfectly; store the MIME type in R2 metadata and re-emit it in the Worker response.

Solution 1: The High-Performance Streaming Proxy

Section titled “Solution 1: The High-Performance Streaming Proxy”

This approach is ideal if you need a simple “private-to-public” gateway. It avoids loading the image into the Worker’s memory (RAM), which prevents crashes on large files and reduces Time to First Byte (TTFB).

Version: Node 20+ / Wrangler (Cloudflare Workers Runtime) — illustrative example.

export default {
async fetch(request, env) {
const url = new URL(request.url);
const key = url.pathname.slice(1); // Assuming URL is domain.com/image.jpg
if (!key) {
return new Response("Object key required", { status: 400 });
}
// Retrieve the object from the R2 bucket
const object = await env.MY_BUCKET.get(key);
if (object === null) {
return new Response("Object Not Found", { status: 404 });
}
// Prepare headers from R2 metadata
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
// Crucial for performance: Pipe the body directly
return new Response(object.body, {
headers,
});
},
};

Why this works: By passing object.body (which is a ReadableStream) directly into the Response constructor, the Worker starts sending data to the client as soon as the first chunks arrive from the R2 storage cluster. Using object.writeHttpMetadata(headers) automatically handles Content-Type, Content-Language, and other standard headers you may have set when uploading.


While R2 doesn’t charge for egress, every env.BUCKET.get() call counts against your Worker’s Class B operations. To optimize costs and speed, you should check the Cloudflare Edge Cache before hitting the bucket.

Version: Node 20+ / Wrangler (Cloudflare Workers Runtime) — illustrative example.

export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
// 1. Try to find the image in the edge cache
let response = await cache.match(cacheKey);
if (!response) {
console.log("Cache miss - fetching from R2");
const key = url.pathname.slice(1);
const object = await env.MY_BUCKET.get(key);
if (object === null) {
return new Response("Not Found", { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
// Direct the edge to cache this for 1 week
headers.set("Cache-Control", "public, max-age=604800");
response = new Response(object.body, { headers });
// 2. Use ctx.waitUntil to store in cache without blocking the user
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
},
};

Why this works: This pattern implements a “Pull-through Cache.” The first request for an image fetches it from R2 and stores it in Cloudflare’s global CDN. Subsequent requests from the same region will be served directly from the CDN cache, bypassing the Worker’s R2 call entirely. This results in sub-10ms response times for cached assets.


Feature Solution 1 (Direct) Solution 2 (Cached)
Latency Medium (R2 Fetch) Very Low (CDN Hit)
R2 Ops Cost 1 Class B per request 1 Class B per cache miss
Complexity Low Medium
Best For Private/Dynamic content Public static assets/images

1. Can I transform images (resize, compress) while serving from R2? Yes, but be careful. If you have a Cloudflare Paid plan, you can use fetch() with the cf: { image: { ... } } property. However, this usually requires the image to be publicly accessible via a URL. If your R2 bucket is private, you must first fetch the image in a Worker (as shown above) and then pass it to the Image Transformation service, or use a tool like sharp via WASM (though this is much more complex and computationally expensive).

2. How do I handle large files (over 50MB)? The streaming approach in Solution 1 handles large files naturally because the Worker doesn’t buffer the content. However, be aware of the Worker’s memory limit (usually 128MB). As long as you stay away from .arrayBuffer() or .text(), the stream remains a “passthrough” and won’t count significantly against your memory limit.

3. What about Range requests (video seeking or partial downloads)? If you need to support seeking in videos or partial image loads, you must pass the Range header from the client’s request into the R2.get() options:

const object = await env.MY_BUCKET.get(key, {
range: request.headers,
});

This ensures the Worker only fetches the requested bytes from R2 rather than the whole file.

  • Avoid Buffering: Never use await response.arrayBuffer() unless you absolutely need to modify the binary data of the image.
  • MIME Types: Ensure images are uploaded to R2 with the correct httpMetadata (e.g., image/webp), or manually set the Content-Type in your Worker.
  • Favicon/Robots: Add logic to handle favicon.ico or robots.txt so they don’t trigger unnecessary R2 lookups.
  • Security: If the bucket is meant to be private, ensure your Worker validates an API key or JWT before calling env.MY_BUCKET.get().