After this lesson you'll be able to create an R2 bucket, read and write objects from a Worker binding, connect to the same bucket with a stock S3 SDK, and reason about what R2 actually costs you.
R2 is Cloudflare's object storage product: buckets and keys, just like Amazon S3, for storing any blob — images, video, backups, JSON blobs, model weights, whatever doesn't fit neatly into a database row. The headline difference from S3 and every other major cloud object store is pricing structure, not features: R2 charges nothing for egress — the bandwidth cost of serving data back out to the internet or to another cloud. On S3, egress is often the line item that dwarfs storage cost once you're serving any real traffic; on R2 it's zero, by design, regardless of how much data you move out.
R2 is S3-compatible at the API level: it implements a large subset of the S3 REST API (PutObject, GetObject, HeadObject, ListObjectsV2, multipart upload operations, CopyObject, and bucket-level operations like CreateBucket/DeleteBucket). That means existing S3 SDKs — the AWS SDK for JS, boto3, etc. — work against R2 by pointing them at a different endpoint and different credentials, with no code rewrite for the common operations. Notable gaps: no ACLs, no object tagging, no versioning, and some S3 encryption modes aren't supported — check the S3 compatibility matrix before assuming a niche S3 feature works.
There are two ways to talk to a bucket:
env.MY_BUCKET (get, put, delete, list). This path never leaves Cloudflare's network — no HTTP round trip, no S3 signing, and it's what you want for anything running inside a Worker.https://<ACCOUNT_ID>.r2.cloudflarestorage.com, using region auto.Buckets are private by default. Nothing is reachable over the public internet until you explicitly enable public access or front the bucket with a Worker or custom domain — covered in the next lesson.
npx wrangler r2 bucket create my-app-uploads
Bucket names: lowercase letters, numbers, and hyphens only, 3-63 characters, can't start or end with a hyphen.
// wrangler.jsonc
{
"r2_buckets": [
{
"binding": "MY_BUCKET",
"bucket_name": "my-app-uploads"
}
]
}
export interface Env {
MY_BUCKET: R2Bucket;
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const key = url.pathname.slice(1); // "/avatar.png" -> "avatar.png"
if (req.method === "PUT") {
await env.MY_BUCKET.put(key, req.body, {
httpMetadata: { contentType: req.headers.get("content-type") ?? undefined },
});
return new Response(`Stored ${key}`, { status: 201 });
}
if (req.method === "GET") {
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);
return new Response(object.body, { headers });
}
if (req.method === "DELETE") {
await env.MY_BUCKET.delete(key);
return new Response(null, { status: 204 });
}
return new Response("Method not allowed", { status: 405 });
},
};
First create an R2 API token (R2 dashboard → Manage API Tokens) scoped to Object Read & Write, optionally restricted to one bucket. This gives you an Access Key ID and a Secret Access Key — save the secret immediately, it isn't retrievable afterward. Then use it exactly like an S3 credential pair:
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: "auto",
endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: R2_ACCESS_KEY_ID,
secretAccessKey: R2_SECRET_ACCESS_KEY,
},
});
await s3.send(new PutObjectCommand({
Bucket: "my-app-uploads",
Key: "reports/2026-07.csv",
Body: csvBuffer,
ContentType: "text/csv",
}));
const { Body } = await s3.send(new GetObjectCommand({
Bucket: "my-app-uploads",
Key: "reports/2026-07.csv",
}));
This is the same @aws-sdk/client-s3 package you'd use against real S3 — only the endpoint, region, and credentials change. It's how you migrate an existing S3-backed service to R2 with minimal code churn, or run one codebase against either provider.
Verified from the R2 pricing page at time of writing — check it directly before relying on exact figures, since pricing can change:
| Item | Standard storage | Infrequent Access storage |
|---|---|---|
| Storage | $0.015 / GB-month | $0.01 / GB-month |
Class A operations (writes/lists — PutObject, ListObjects, CopyObject, etc.) | $4.50 / million requests | $9.00 / million requests |
Class B operations (reads — GetObject, HeadObject, etc.) | $0.36 / million requests | $0.90 / million requests |
| Egress (data transfer out) | $0 — always, at any volume | |
DeleteObject, DeleteBucket, and AbortMultipartUpload are free. Infrequent Access storage adds a per-GB retrieval fee and a 30-day minimum storage duration; Standard storage has no retrieval fee. The free tier (Standard storage only) includes 10 GB-month storage, 1 million Class A operations, 10 million Class B operations, and unlimited egress per month.
The point to internalize: egress is the one line that's always zero. Storage and operations are still metered and billed — R2 removed one specific cost, not all of them.
GetObject or PutObject calls — e.g. one object per user session, or a naive list-then-get loop hitting the bucket key-by-key instead of batching — can rack up real operations cost even though every byte of egress is free. Before assuming R2 is cheap for a given workload, estimate request volume, not just data volume: high-request-count, low-data-volume patterns (lots of tiny objects, frequent metadata checks, chatty list operations) are where R2's bill shows up.
The R2 documentation is the canonical reference for concepts and the Workers/S3 APIs; pair it with the R2 pricing page for current numbers, since pricing is subject to change.
env.MY_BUCKET.get() once per incoming request to serve a large video file to end users, at high traffic volume. Which R2 cost actually scales with that traffic?The Workers binding (env.MY_BUCKET) for code running inside a Worker — no HTTP round trip or S3 signing. The S3-compatible API (endpoint https://<ACCOUNT_ID>.r2.cloudflarestorage.com, region auto, R2-specific access key/secret) for code outside Workers or for reusing an existing S3 SDK with minimal changes.