Skip to content
FLAVIO COPES
flaviocopes.com
2026

Cloudflare R2: object storage without egress fees

By Flavio Copes

How to store and serve files like images and uploads with Cloudflare R2 from a Worker. S3-compatible storage with no egress fees.

~~~

When users upload a photo, or you generate a PDF, you need somewhere to put the file. On Cloudflare, that’s R2.

R2 is object storage. You store files, called objects, in a bucket, and read them back later. If you’ve used Amazon S3, it’s the same idea, and it’s even S3-compatible.

The big difference is the price. With most providers you pay every time someone downloads a file (that’s “egress”). R2 has no egress fees. If you serve a lot of files, that adds up fast.

Let’s use it.

Create a bucket

npx wrangler r2 bucket create my-app-uploads

Add it to wrangler.jsonc:

{
  "r2_buckets": [
    {
      "binding": "UPLOADS",
      "bucket_name": "my-app-uploads"
    }
  ]
}

Now env.UPLOADS is your bucket in code.

Store a file

You store an object under a key, which is just a path-like string. The value can be text, JSON, or binary data like an uploaded file.

Here we save a file that came in from a form upload:

export default {
  async fetch(request, env) {
    const body = await request.arrayBuffer()
    await env.UPLOADS.put('avatars/user-123.png', body)
    return new Response('Saved')
  },
}

Read a file back

get returns the object. You can stream it straight into a response:

const object = await env.UPLOADS.get('avatars/user-123.png')

if (!object) {
  return new Response('Not found', { status: 404 })
}

return new Response(object.body)

object.body is a stream, so you’re not loading the whole file into memory. It just flows through to the user.

Set the content type

When you serve a file, the browser needs to know what it is. Store some metadata with the object, then use it when you serve:

await env.UPLOADS.put('avatars/user-123.png', body, {
  httpMetadata: { contentType: 'image/png' },
})

And on the way out:

const object = await env.UPLOADS.get('avatars/user-123.png')

const headers = new Headers()
object.writeHttpMetadata(headers)

return new Response(object.body, { headers })

writeHttpMetadata copies the content type (and other metadata) onto the response for you.

List and delete

List objects, optionally by prefix:

const list = await env.UPLOADS.list({ prefix: 'avatars/' })
list.objects.forEach((o) => console.log(o.key))

And delete one when it’s no longer needed:

await env.UPLOADS.delete('avatars/user-123.png')

A note on serving files

Serving files through a Worker, like above, gives you control. You can check that the user is allowed to see the file before returning it.

If a file is public and you don’t need a check, you can also connect a custom domain to the bucket and let Cloudflare serve it directly. Even faster, even less code.

When to use R2

R2 is for files: user uploads, images, video, generated documents, backups. Anything that isn’t structured rows (D1) or simple key lookups (KV).

The no-egress pricing makes it especially good for things people download a lot. The full reference is in the R2 docs.

~~~

Related posts about cloudflare: