# Cloudflare R2: object storage without egress fees

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

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-06-25 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/cloudflare-r2/

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.

By the way, if you're unsure whether your data belongs in R2, KV, D1 or Durable Objects, I built a free [Cloudflare storage chooser](https://flaviocopes.com/tools/cloudflare-storage-chooser/) to help you decide.

## Create a bucket

```bash
npx wrangler r2 bucket create my-app-uploads
```

Add it to `wrangler.jsonc`:

```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](https://flaviocopes.com/json/), or binary data like an uploaded file.

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

```js
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:

```js
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:

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

And on the way out:

```js
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:

```js
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:

```js
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](https://developers.cloudflare.com/r2/).
