Serve R2 objects with their own headers, ETag and Range support

get(key, { onlyIf: request.headers, range: request.headers }) does conditional and partial reads; writeHttpMetadata plus httpEtag make a correct HTTP response.

KV/R2/D1

· Chapter

51

·

2

min read

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 an R2Object without a body when the precondition fails; check for body before streaming or you serve an empty 200.
  • head() is cheaper than get() 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