zudo-cloudflare-wisdom
GitHub repository

Type to search...

to open search from anywhere

R2 (Object Storage)

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.

Pairing R2 with D1

The same "no distributed transaction" constraint that shapes deletion (below) also shapes creation — just pointed in the opposite direction.

Blob first, row last

Write the blob first, the D1 row last:

// 1. Write the blob first. If this fails, nothing else has happened yet.
const key = `uploads/${crypto.randomUUID()}`;
await env.FILES.put(key, fileData, {
  httpMetadata: { contentType },
});

// 2. Row last. If this fails, the blob is orphaned but harmless —
//    nothing in D1 claims it exists.
await env.DB.prepare(
  "INSERT INTO photos (id, r2_key, content_type) VALUES (?, ?, ?)",
).bind(crypto.randomUUID(), key, contentType).run();

This protects the same invariant as the delete ordering below, applied to creation instead: a D1 row must never claim a blob that doesn't exist. Writing the blob first means a failure between the two steps leaves an orphaned object — recoverable, see reclamation below — instead of a dangling pointer that 404s in front of a user.

Immutable UUID keys

Derive keys from crypto.randomUUID(), or a content hash (see Key safety) — never from a user-supplied name, a display title, or anything that might change later.

  • No rename problem. A key derived from a mutable field forces a choice on rename: leave the key stale and misleading, or move the object. R2 has no atomic rename, so "moving" is copy-then-delete-old — reopening the exact two-step failure window this whole section exists to avoid.

  • No collision problem. A UUID key never needs an existence check before writing; two uploads can't accidentally land on the same key the way two files both named photo.jpg could.

Copy-before-batch

Immutable keys have a corollary for batch operations: when a job replaces or regenerates a set of objects (a resize pass, a re-encode, a migration), write every new blob under a new key first, in full, and only run the D1 batch update once every object it's about to reference actually exists. A batch UPDATE that goes out while an upload loop is still running risks the same dangling-pointer failure as a single record, just multiplied — a crash mid-batch leaves some rows pointing at blobs that were never written.

Reclaiming orphaned blobs

Blob-first writes make orphaned objects an expected byproduct, not an exceptional one — every failure between step 1 and step 2 above leaves one behind. A periodic reclamation job is required, not optional, and it needs three properties:

Grace period first, or you delete a blob mid-write

Never delete an object younger than the longest plausible gap between "blob finished uploading" and "D1 row committed" in your write path — retries, queueing, and cold starts all stretch that gap. An object that looks orphaned two seconds after upload might just be a normal request that hasn't reached step 2 yet. Pick a grace period comfortably longer than your worst-case write latency (hours, not seconds), and skip anything newer than that.

  • Bounded, paginated scan. list() caps out at 1000 keys per call, same as KV — scanning a large bucket means paging through with cursor in bounded batches, not enumerating the whole bucket in one pass.

  • Reference-aware deletion, scoped to non-reusable keys. If every key is a UUID minted once per object (see Immutable UUID keys), delete on "zero rows still reference this key" — a COUNT(*) check against D1 — never on "the row that created this is gone." That check is safe here because a UUID key with zero references can never gain a new one later: nothing will ever be created that points at that exact UUID again. It is not safe for content-addressed keys, where a brand-new upload can reference an old key at any moment — see Reclaiming content-addressed keys safely below for why a live COUNT(*) races against concurrent uploads, and what to coordinate instead.

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 });

Key safety

R2 keys built from client-influenced input need the same validation discipline as any other untrusted string reaching storage.

Charset and length

Constrain any client-influenced portion of a key to a safe allowlist ([a-zA-Z0-9_-], no .., no leading /, no control characters) before it becomes part of an R2 key. Validate length against the UTF-8 byte count, not the character count — R2 keys can be up to 1024 bytes, and multi-byte characters (CJK, emoji) can blow that budget well before 1024 characters. Prefer generating the key server-side (see Immutable UUID keys) and storing any client-supplied name as customMetadata instead of trusting it in the key path at all.

Content-addressing and its encryption caveat

Keying objects by a hash of their content (sha256:${hash}) gives dedup for free: identical content always lands on the same key, so uploading the same file twice costs one object with two referencing D1 rows, not two objects.

The caveat is encryption. If content is encrypted before it reaches R2, hash the plaintext, not the ciphertext — a random IV/nonce makes ciphertext differ even for identical plaintext, so hashing ciphertext defeats the dedup entirely. Hashing the plaintext restores it, but the hash itself then reveals when two uploads share identical content, even to someone who can't decrypt either one. Decide whether that leak is acceptable for the data before choosing content-addressing under encryption — if it isn't, accept the storage cost of no dedup instead.

Reclaiming content-addressed keys safely

A content-addressed key can be referenced by a brand-new D1 row at any time — including the instant after a reclaimer's COUNT(*) check finds zero references. If the reclaimer trusts that snapshot and deletes the object afterward, a concurrent create that inserted its reference in between now points at nothing. The reference-aware deletion in Reclaiming orphaned blobs only holds for keys that are never reused; a reusable key needs the reference count and the delete decision coordinated through D1 itself, not read separately and acted on later.

Track each content-addressed key's reference count and a tombstone timestamp in its own row:

CREATE TABLE blob_refs (
  r2_key         TEXT PRIMARY KEY,
  ref_count      INTEGER NOT NULL DEFAULT 0,
  tombstoned_at  INTEGER  -- set when ref_count drops to 0; cleared by the next reference
);

Every write that references a content-addressed key increments the count and clears any tombstone in the same statement, so a reference that arrives after a tombstone was set un-tombstones the key atomically:

await env.DB.prepare(
  `INSERT INTO blob_refs (r2_key, ref_count, tombstoned_at)
   VALUES (?, 1, NULL)
   ON CONFLICT (r2_key) DO UPDATE SET
     ref_count = blob_refs.ref_count + 1,
     tombstoned_at = NULL`,
).bind(r2Key).run();

Every delete of a referencing D1 row decrements the count, and only sets the tombstone once it reaches zero:

await env.DB.prepare(
  `UPDATE blob_refs
   SET ref_count = ref_count - 1,
       tombstoned_at = CASE WHEN ref_count - 1 <= 0 THEN ? ELSE tombstoned_at END
   WHERE r2_key = ?`,
).bind(Date.now(), r2Key).run();

The reclaimer claims a tombstone — past the grace period — atomically before touching R2, the same claim-before-mutate shape used throughout this site:

const claim = await env.DB.prepare(
  `UPDATE blob_refs
   SET tombstoned_at = NULL
   WHERE r2_key = ? AND ref_count <= 0 AND tombstoned_at < ?
   RETURNING r2_key`,
)
  .bind(r2Key, Date.now() - GRACE_PERIOD_MS)
  .first();

if (claim) {
  // Won the claim: no reference has arrived since the tombstone was set,
  // and none can silently reappear without going through the INSERT
  // above, which would need this same row to still show ref_count > 0.
  await env.FILES.delete(r2Key);
} else {
  // Not eligible: still referenced, tombstone too fresh, or another
  // reclaimer already claimed it -- leave the object alone.
}

Creators must still write the blob, never skip the upload on a dedup hit

The claim above closes the gap between checking references and deciding to delete, but a physical env.FILES.delete() and a concurrent creator's D1 insert are still two separate operations against two separate systems — nothing makes them atomic with each other. The remaining sliver closes only if creators never skip env.FILES.put() just because a dedup lookup found the key already in D1. Content-addressed put() is idempotent — writing identical bytes to the same key twice is a no-op in effect — so always call it before writing the D1 reference (the same blob first, row last ordering used for UUID keys). That guarantees the object exists at the moment any row references it, even if a reclaimer deleted it moments earlier in the same narrow window.

This costs one extra table and one extra write per create/delete, in exchange for a reclamation job that can never race a legitimate reference into a dangling pointer.

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

Revision History

CreatedUpdated