Storage

R2

R2 is the file storage block of the Cloudflare platform: a durable, S3-compatible bucket where reading bytes back never costs egress fees. Keys are arbitrary strings like uploads/<session>/<id>. The hero illustration below is itself an R2 read.

// two demos: drop a file to upload, plus the hero image is itself live from R2 ↓

R2 object write and read flows with a safety check Two flows share one Worker and one R2 bucket. In the upload flow, a browser posts an image, the Worker checks type, size, and image safety with Workers AI, and only writes it to the uploads/ prefix if the check passes. In the read flow, a browser requests an image and the Worker reads an object from R2. R2 charges nothing for egress on either path. workers ai llama 3.2 vision browser your visitor worker validates type · size · safety then routes r2 bucket dev-demo-r2 uploads/<sid>/… assets/hero.png review verdict POST image signed url put if ok GET image bytes get object $0 egress
// one bucket, many object keys — uploads pass a safety check before put()

Why R2 feels different

  • No egress fees. Reading bytes back from R2 costs nothing. Serve the same image to a million visitors and pay nothing extra for the bandwidth out — only the storage that holds it and the request operations that fetch it. This is the headline R2 differentiator versus S3, GCS, or Azure Blob.
  • S3-compatible API. R2 speaks the S3 protocol, which means every SDK, CLI tool, and migration script that already works against S3 works against R2. Drop-in compatibility with the existing object-storage ecosystem.
  • One bucket, many key prefixes. R2 isn't a folder system; it's a flat keyspace where keys happen to contain / characters. uploads/sessionId/file.png and assets/hero.png are sibling keys in the same bucket, organized only by convention.
  • Conditional reads, Range requests, and ETags. R2 honors HTTP semantics. A returning visitor's browser sends an If-None-Match header with the cached ETag; R2's head() says whether to short-circuit with a 304 Not Modified. Range requests work for partial reads of large objects. This is what makes the hero asset's caching trick possible.

Try it

Drop a file

Drag an image into the box below — or click to pick one. The Worker chooses an R2 key tied to your session cookie, writes the object, stores a little metadata, then renders it back from a real R2 read. That proxy pattern is useful when your app needs to inspect bytes or attach metadata before storage.

How the upload works

The Worker buffers the body, sniffs the first 16 bytes against the declared content-type, runs a safety check, and only then calls env.R2_DEMO.put. Each object can also carry string key/value metadata. Here, customMetadata stores an expiry timestamp the read path can check later.

src/handlers/r2.ts — handleUpload
// Upload handler — accepts an image, validates magic bytes,
// runs a safety check through AI Gateway, and only then writes
// it to R2 under a per-session key. The response includes a
// short-lived signed URL, never the raw R2 key.

const arrayBuffer = await request.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);

// Cross-check: the Content-Type header is trivially spoofable,
// so we sniff the first 16 bytes to confirm.
if (!sniff(declaredType, bytes.subarray(0, 16))) {
  return jsonError(400, "Contents don't match declared type");
}

// Safety gate. Runs BEFORE the put so rejected bytes never reach
// storage. Fails closed on timeout or parse error.
const verdict = await moderateImage(env, bytes, declaredType);
if (!verdict.ok) {
  return verdict.reason === "content_policy"
    ? json({ error: "blocked", reason: "content_policy" }, 422)
    : json({ error: "moderation_unavailable" }, 503);
}

const id = crypto.randomUUID();
const key = `uploads/${sessionId}/${id}.${ext}`;
// 10-minute window so a leaked URL stops working soon after.
const expiresAt = Date.now() + 60 * 10 * 1000;

await env.R2_DEMO.put(key, bytes, {
  httpMetadata: { contentType: declaredType },
  customMetadata: {
    expiresAt: String(expiresAt),
    sessionId,
  },
});

// HMAC-sign the key + expiry so the URL is unforgeable and
// stops working at `exp` even if the object lingers in R2.
const token = await signToken(env, { key, exp: expiresAt });
return Response.json({
  url: `/api/r2/file/${token}`,
  expiresAt,
});

The important R2 boundary is simple: untrusted bytes should be checked before they become stored objects. This demo does the cheap checks first — size, content-type allowlist, and magic bytes — then asks Workers AI for a safety verdict before the put. If that check fails or times out, the upload is rejected before anything reaches the bucket. AI Gateway keeps the internal moderation metadata — verdict, category, and a short image description — while the public response only shows the R2 object details. Rejected uploads get generic feedback, not detailed category hints.

How the read path works

The read side has more going on than the write. We never expose the raw R2 key in URLs — instead the upload response carries a short HMAC-signed token that the read handler verifies, plus a couple of hotlink checks so the URL only works when it's reached from our own pages. The body itself is still locked down with CSP: sandbox as defense in depth.

src/handlers/r2.ts — handleFile
// File read handler — three gates before any bytes go out:
//   1. Hotlink check (Sec-Fetch-Site / Referer)
//   2. HMAC-signed token verification + exp check
//   3. R2 lazy-expiry on stored object

if (request.headers.get("sec-fetch-site") === "cross-site") {
  return notFound();
}
const referer = request.headers.get("referer");
if (referer && new URL(referer).host !== url.host) {
  return notFound();
}

const payload = await verifyToken<{ key: string; exp: number }>(env, token);
if (!payload || Date.now() > payload.exp) return notFound();

const obj = await env.R2_DEMO.get(payload.key);
if (!obj) return notFound();

return new Response(obj.body, {
  headers: secureHeaders({
    "content-type": safeType,
    "content-disposition": `inline; filename="${filename}"`,
    "content-security-policy": "sandbox",
    "cache-control": "private, no-store",
    "x-robots-tag": "noindex, nofollow, noarchive",
  }),
});

Try it

Page asset from R2

The illustration below isn't part of public/. R2 is the origin for it: the first load streams assets/hero.png from the bucket, and repeat loads can reuse browser cache or get a tiny 304 Not Modified response.

Cloudflare logo, hand-drawn sketchbook style
// served from R2 · view it directly

How serving from R2 works

Same bucket, different prefix. The asset path also handles If-None-Match: a returning visitor's browser sends the ETag it cached last time, and R2's head() tells us whether to short-circuit with a headers-only 304 Not Modified instead of streaming the whole file again.

src/handlers/r2.ts — handleAsset
// Asset handler — same bucket, different prefix.
// Conditional GET via ETag means warm caches get a 304
// with no body, which is the fast path for static assets.

const ifNoneMatch = request.headers.get("if-none-match");
if (ifNoneMatch) {
  const head = await env.R2_DEMO.head(key);
  if (head?.httpEtag === ifNoneMatch) {
    return new Response(null, { status: 304 });
  }
}

const obj = await env.R2_DEMO.get(key);
if (!obj) return notFound();

return new Response(obj.body, {
  headers: secureHeaders({
    "content-type": safeType,
    "cache-control": "public, max-age=3600",
    etag: obj.httpEtag,
  }),
});

Pre-signed URLs (the shortcut)

The Drop a file example above proxies the upload through the Worker. That's the simplest pattern, and it is the right choice when the Worker must validate bytes before storage. For large files, though, you often want the Worker to make the authorization decision without sitting in the middle of the byte transfer. The alternative: have the Worker mint a short-lived signed URL, then let the browser PUT directly to R2.

R2 speaks the S3 API, which means the same SigV4 flow that works for AWS works here. aws4fetch is a tiny dep that handles the signing. In production, pair this with a short expiry, scoped keys, bucket CORS, and usually a finalization endpoint that records what the browser uploaded.

// illustrative — not wired up on this page
// Pre-signed URLs let browsers talk to R2 directly,
// skipping the Worker for the bytes themselves. Your Worker
// still decides the bucket, key, content type, and short expiry.

import { AwsClient } from "aws4fetch";

const r2 = new AwsClient({
  accessKeyId: env.R2_ACCESS_KEY_ID,
  secretAccessKey: env.R2_SECRET_ACCESS_KEY,
  service: "s3",
});

// Sign a PUT URL the browser can upload to directly. Production
// endpoints also need CORS on the bucket and a short expiry.
const signed = await r2.sign(
  `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${bucket}/${key}`,
  { method: "PUT", aws: { signQuery: true, allHeaders: true } },
);

return Response.json({ uploadUrl: signed.url, key });

When R2 is the right tool

  • User-uploaded files — images, attachments, documents — anywhere your app produces persistent binary objects
  • Generated reports, exports, backups — large derived data your app writes once and serves on demand
  • Static assets for your application — images, fonts, videos, anything bigger than what makes sense in public/
  • Build artifacts and cached upstream responses — when re-fetching the source costs more than storing the result
  • Migration target for S3-based pipelines — same SDKs, no egress fees

For small key/value reads at single-digit milliseconds, prefer KV. For relational queries, prefer D1. For video delivery with adaptive bitrate and live streaming, prefer Stream. R2 removes egress fees, but storage and request operations still count — it is object storage, not a database, queue, or cache.