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.
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.pngandassets/hero.pngare 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-Matchheader with the cached ETag; R2'shead()says whether to short-circuit with a304 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.
// your uploads will appear here
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.
// 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.
// 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.
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.
// 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.
// 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.