The answer. A public bucket on a custom domain serves files with caching for free, but the moment a file needs a check (a token, a login, a per-tenant path) the Worker becomes the file server, and file servers have rules. Pass the incoming headers to get(): onlyIf evaluates If-None-Match and If-Modified-Since so unchanged files answer 304 with no body, and range evaluates Range so video players and resumable downloads get 206 with only the bytes they asked for. Then copy the object's stored Content-Type, Cache-Control and friends onto the response with writeHttpMetadata, set ETag from httpEtag, and stream object.body. Set Cache-Control at upload time in httpMetadata and the same header both caches at the browser and drives the edge cache in front of your Worker.
The pattern.
const object = await env.BUCKET.get(key, { onlyIf: request.headers, range: request.headers });
if (object === null) return new Response("not found", { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers); // content-type, cache-control, content-disposition...
headers.set("etag", object.httpEtag);
if (!("body" in object)) return new Response(null, { status: 304, headers }); // precondition met
if (object.range && "offset" in object.range) {
headers.set("content-range", `bytes ${object.range.offset}-${object.range.offset + (object.range.length ?? object.size) - 1}/${object.size}`);
return new Response(object.body, { status: 206, headers });
}
return new Response(object.body, { headers });
Watch out.
get()returns anR2Objectwithout a body when the precondition fails; check forbodybefore streaming or you serve an empty 200.head()is cheaper thanget()when you only need metadata, and both count as Class B operations.- A Worker in front of R2 is billed per request; a public bucket domain is not. Gate only what needs gating.
Related: r2-upload-through-worker-or-presign-for-big-files · r2-custom-domain-choose-once · static-assets-run-worker-first-decides-who-answers