Overview
R2 speaks the S3 API, so existing SDKs work after an endpoint swap, and it charges for storage and operations but not for egress. Pick it when egress dominates the bill; stay on S3 when the data must sit inside the AWS network beside other AWS services.
Compare storage, operations, and egress on your own mix
Prices from the R2 pricing page and S3 pricing, checked October 2026.
| R2 Standard | R2 Infrequent Access | |
|---|---|---|
| Storage per GB-month | $0.015 | $0.01 |
| Class A (writes, lists) per million | $4.50 | $9.00 |
| Class B (reads) per million | $0.36 | $0.90 |
| Retrieval | none | $0.01 per GB |
| Egress | free | free |
S3 Standard (us-east-1, first 50 TB) is $0.023 per GB-month plus tiered per-GB egress and its own request fees. R2’s free tier is 10 GB-month of Standard storage, 1 million Class A and 10 million Class B operations per month. Asset-heavy sites, video, and backups that are read back often save the most. R2 has Standard and Infrequent Access classes only, so very cold archives can still be cheaper on a Glacier-class tier.
Use the S3 SDK from outside Workers and the binding inside
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
const r2 = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey },
})
await r2.send(new PutObjectCommand({ Bucket: "site-assets", Key: "hero.webp", Body: file, ContentType: "image/webp" }))Inside a Worker, use the R2 binding (cloudflare-workers); it skips the HTTP round trip. Use the S3 SDK from Node scripts, CI, and mobile apps. Serve public buckets through a custom domain, not the rate-limited r2.dev endpoint, which is for testing.
Use multipart for large or unreliable uploads
A single-part upload tops out at 5 GiB; multipart supports objects up to about 5 TiB in at most 10,000 parts. @aws-sdk/lib-storage’s Upload handles splitting, concurrency, and retries.
import { Upload } from "@aws-sdk/lib-storage"
await new Upload({ client: r2, params: { Bucket: "backups", Key: "db-2026-10-01.sql.zst", Body: stream }, queueSize: 4 }).done()Concurrent writes to the same key are limited to one per second.
Set lifecycle rules so storage does not grow unbounded
Rules run server-side per bucket (dashboard, wrangler r2 bucket lifecycle, or the S3 API; up to 1,000 rules). Actions: delete after N days or on a date, transition to Infrequent Access, and abort incomplete multipart uploads. R2 aborts incomplete multipart uploads after 7 days by default. Deletion is not instant: expect removal within about 24 hours.
- Expire temporary files (previews, signed-URL targets) after 7 to 30 days.
- For backups, keep daily copies short, weekly longer, monthly longest, and delete the rest with age rules.
Issue presigned URLs for direct browser transfer
Generate the URL server-side and let the browser PUT to R2, so bytes never cross your origin.
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
const url = await getSignedUrl(r2, new PutObjectCommand({ Bucket: "user-uploads", Key: `${userId}/${filename}`, ContentType: "image/webp" }), { expiresIn: 300 })Sign the ContentType, keep the TTL short, and key objects under the user’s ID server-side, never from a client-supplied path. Set bucket CORS to the one origin that uploads. Presigned GETs with a short TTL replace proxying downloads through a Worker.
Choose R2 for these workloads
Static assets behind cloudflare (images, video, fonts, downloads), database backups from cron (hostinger-vps), user uploads, CI artifacts, and public datasets. Skip it for in-place file edits, AWS-only features such as Object Lambda, and any case that needs S3 storage classes R2 lacks.