zudo-cloudflare-wisdom

Type to search...

to open search from anywhere

R2 (Object Storage)

CreatedApr 4, 2026UpdatedMay 28, 2026Takeshi Takatsudo

Cloudflare R2 for file and blob storage

Overview

R2 is S3-compatible object storage with zero egress fees. Use it for files, images, backups, and large data.

Setup

Create a Bucket

npx wrangler r2 bucket create my-files

Add to wrangler.toml:

[[r2_buckets]]
binding = "FILES"
bucket_name = "my-files"

Usage in Functions

interface Env {
  FILES: R2Bucket;
}

// Upload
await env.FILES.put("uploads/photo.jpg", imageData, {
  httpMetadata: { contentType: "image/jpeg" },
});

// Download
const object = await env.FILES.get("uploads/photo.jpg");
if (object) {
  return new Response(object.body, {
    headers: {
      "Content-Type": object.httpMetadata?.contentType || "application/octet-stream",
    },
  });
}

// Delete
await env.FILES.delete("uploads/photo.jpg");

// List objects
const list = await env.FILES.list({ prefix: "uploads/" });
for (const object of list.objects) {
  console.log(object.key, object.size);
}

Multipart Uploads

For files larger than ~100 MB, use multipart uploads:

const upload = await env.FILES.createMultipartUpload("large-file.zip");
const part1 = await upload.uploadPart(1, chunk1);
const part2 = await upload.uploadPart(2, chunk2);
await upload.complete([part1, part2]);

Public Access

R2 buckets are private by default. To serve files publicly:

  1. Custom domain: Connect a domain to the bucket in the Cloudflare dashboard
  2. Worker proxy: Create a Worker that reads from R2 and serves files
  3. Pages Function: Use a Pages Function as a file serving endpoint

Direct browser uploads (presigned URLs)

The native R2Bucket binding routes every upload through your Worker, so the file body counts against the Worker’s request-body size limit and burns Worker CPU time. For large user uploads (photos, video), mint a presigned PUT URL and let the browser upload directly to R2, bypassing the Worker entirely for the bytes.

R2 exposes an S3-compatible API at https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com, so any S3 presigner works. On Workers, the catch is bundle size.

Use aws4fetch, not the AWS SDK

Use aws4fetch (~5 KB minified), not @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner. The AWS SDK v3 ships heavy smithy / AbortSignal plumbing that blows the Worker 1 MB code-size limit for what is, here, a single signing call.

npm install aws4fetch

Minting a presigned PUT URL

import { AwsClient } from "aws4fetch";

interface Env {
  R2_ACCOUNT_ID: string;
  R2_ACCESS_KEY_ID: string;
  R2_SECRET_ACCESS_KEY: string;
  R2_BUCKET_NAME: string;
}

interface SignOpts {
  objectKey: string;
  contentType: string;
  expiresIn?: number; // seconds; defaults to 300 (5 minutes)
}

async function signPutUrl(env: Env, opts: SignOpts): Promise<string> {
  const client = new AwsClient({
    accessKeyId: env.R2_ACCESS_KEY_ID,
    secretAccessKey: env.R2_SECRET_ACCESS_KEY,
    service: "s3",
    region: "auto",
  });

  const expiresIn = opts.expiresIn ?? 300;
  const url = new URL(
    `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${env.R2_BUCKET_NAME}/${opts.objectKey}`,
  );
  // aws4fetch reads X-Amz-Expires from the URL when signQuery is true.
  // Without it, the s3 default is 86400 (24h) — far too long for an upload window.
  url.searchParams.set("X-Amz-Expires", String(expiresIn));

  const signed = await client.sign(
    new Request(url.toString(), {
      method: "PUT",
      headers: { "content-type": opts.contentType },
    }),
    {
      aws: {
        signQuery: true,
        // content-type is in aws4fetch's UNSIGNABLE_HEADERS by default.
        // allHeaders forces it into the signed-headers list.
        allHeaders: true,
      },
    },
  );
  return signed.url;
}

The content-type gotcha

By default aws4fetch treats content-type as unsignable and leaves it out of the signature. Passing allHeaders: true forces it into the signed headers — which is what you want, because it pins the upload to a declared MIME type.

The catch: once content-type is signed, the browser MUST PUT with the exact same content-type header. Any mismatch and R2 rejects the upload with SignatureDoesNotMatch.

// Browser side — the content-type MUST match what was signed.
await fetch(presignedUrl, {
  method: "PUT",
  headers: { "Content-Type": "image/jpeg" }, // exactly what signPutUrl signed
  body: fileBlob,
});

⚠️ Presigned URLs are not single-use

R2 does not invalidate a presigned URL after the first PUT. The only enforcement is the expiry window (X-Amz-Expires). Keep the TTL short (300 s is a sane default) and treat the URL as a capability that anyone holding it can replay until it expires.

S3 API tokens, not the binding

Presigned signing needs R2 S3 API tokensR2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY, created under R2 → Manage R2 API Tokens in the dashboard. These are a different credential from the native [[r2_buckets]] binding. A Worker can use both: the binding for server-side reads/writes, S3 tokens for minting presigned URLs.

Coordinating R2 + D1 without transactions

When a record spans both R2 (the blob) and D1 (the metadata row), there is no distributed transaction across the two. A delete or update can fail halfway, and you have to choose which inconsistency you can tolerate.

🚨 Delete R2 objects first, the D1 row last

For deletes, the safe ordering is: delete the R2 objects FIRST, then the D1 row LAST.

  • An orphaned R2 object (blob gone from D1’s view but still in the bucket) is recoverable — you can list the bucket and reconcile.
  • A dangling D1 pointer (row still claims a blob that R2 already deleted) is data loss — the UI shows a record that 404s on access.

Make the whole operation idempotent on retry: deleting an already-deleted R2 key is a no-op, and re-running the D1 delete on a missing row succeeds. So if D1 fails after R2 succeeded, return HTTP 503 (“temporarily unavailable, please retry”) — the operator retries, the R2 list is now empty, and the D1 delete completes. The trade-off favours user-visible correctness over storage tidiness.

// 1. R2 first (best-effort — swallow per-object failures, surface a count)
const r2 = await deletePhotoR2Objects(slug, env);

// 2. D1 row last. On D1 failure, return 503 so the caller can safely retry.
try {
  await deletePhotoRow(slug, env.DB);
} catch (err) {
  return Response.json(
    { success: false, error: "Photo store temporarily unavailable, please retry" },
    { status: 503 },
  );
}

return Response.json({ success: true, r2 }, { status: 200 });

Gotchas

  • No automatic public URLs: Unlike S3 with public buckets, R2 requires a Worker or custom domain to serve files publicly
  • Object key limit: Keys can be up to 1024 bytes
  • Metadata: Use customMetadata for your own key-value pairs, httpMetadata for HTTP headers