Skip to content

Workers R2 -- Upload, Download and Delete Objects

DodaTech 5 min read

In this tutorial, you will learn how to perform object operations in Cloudflare R2 directly from Workers -- uploading files, streaming downloads, and deleting objects without an intermediate server. Serverless object operations are important because they eliminate the need for a backend application layer between your users and storage, reducing latency and operational cost. A real-world example is a file sharing service where users upload documents directly through a Worker that streams them into R2.

The R2 Worker Binding

When you bind an R2 bucket to a Worker using [[r2_buckets]] in wrangler.toml, the binding exposes an object API on env.BUCKET_NAME. The API includes put(), get(), delete(), list(), and head() methods. Objects are identified by a string key, which can include slashes to simulate a directory hierarchy. Every operation runs within the same request lifecycle as your Worker, with no separate connection setup.

Upload and Download Flow

flowchart TD
    U[User Request] --> W[Worker]
    W -->|PUT /upload| P[env.BUCKET.put key, body]
    W -->|GET /file| G[env.BUCKET.get key]
    P --> R1[Object Stored in R2]
    G --> R2[Object Streamed to Client]
    W -->|DELETE /file| D[env.BUCKET.delete key]
    D --> R3[Object Removed]

    style W fill:#3498db,color:#fff
    style R1 fill:#2ecc71,color:#fff
    style R2 fill:#2ecc71,color:#fff
    style R3 fill:#e74c3c,color:#fff

Uploading an Object

// PUT /upload -- upload a file to R2
export default {
  async fetch(request, env) {
    if (request.method !== 'PUT') {
      return new Response('Method not allowed', { status: 405 });
    }

    const url = new URL(request.url);
    const key = url.searchParams.get('key') || crypto.randomUUID();

    // Stream the request body directly into R2
    await env.BUCKET.put(key, request.body, {
      httpMetadata: request.headers,
      customMetadata: {
        uploadedBy: 'worker',
        uploadedAt: new Date().toISOString()
      }
    });

    return new Response(JSON.stringify({ key, size: request.headers.get('Content-Length') }), {
      status: 201,
      headers: { 'Content-Type': 'application/json' }
    });
  }
};

// Request: PUT /upload?key=docs/report.pdf with binary body and Content-Type: application/pdf
// Expected response:
// {"key": "docs/report.pdf", "size": "245760"}

The put() method accepts a key, a readable stream or array buffer, and optional metadata. The request body is streamed directly to R2 without buffering in the Worker, enabling uploads of arbitrarily large files.

Downloading an Object

// GET /file?key=docs/report.pdf -- download from R2
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const key = url.searchParams.get('key');

    if (!key) {
      return new Response('Missing key parameter', { status: 400 });
    }

    const object = await env.BUCKET.get(key);

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

    // Stream the object body directly to the response
    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set('ETag', object.httpEtag);

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

// Request: GET /file?key=docs/report.pdf
// Expected response: The PDF file streamed with original Content-Type and ETag headers

The get() method returns an object with body (a ReadableStream), key, size, httpEtag, and metadata. The writeHttpMetadata() method copies the original upload headers onto the response.

Deleting an Object

// DELETE /file?key=docs/report.pdf -- delete from R2
export default {
  async fetch(request, env) {
    if (request.method !== 'DELETE') {
      return new Response('Method not allowed', { status: 405 });
    }

    const url = new URL(request.url);
    const key = url.searchParams.get('key');

    if (!key) {
      return new Response('Missing key parameter', { status: 400 });
    }

    await env.BUCKET.delete(key);

    return new Response(JSON.stringify({ deleted: key }), {
      headers: { 'Content-Type': 'application/json' }
    });
  }
};

// Request: DELETE /file?key=docs/report.pdf
// Expected response:
// {"deleted": "docs/report.pdf"}

The delete() method removes the object. It succeeds even if the key does not exist -- no error is thrown for a missing key. Check existence first with head() if you need to confirm the object existed before deletion.

Checking Object Metadata Without Downloading

// HEAD /file?key=logo.png -- check metadata only
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const key = url.searchParams.get('key');

    if (!key) return new Response('Missing key', { status: 400 });

    const object = await env.BUCKET.head(key);

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

    return new Response(JSON.stringify({
      key: object.key,
      size: object.size,
      etag: object.httpEtag,
      uploaded: object.customMetadata?.uploadedAt
    }), {
      headers: { 'Content-Type': 'application/json' }
    });
  }
};

// Request: HEAD /file?key=logo.png
// Expected response:
// {"key": "logo.png", "size": 45200, "etag": "\"abc123\"", "uploaded": "2026-06-23T12:00:00Z"}

The head() method returns an object's metadata without its body. Use this for existence checks, size verification, and cache validation without incurring data transfer costs.

Common Errors and Troubleshooting

Object Not Found

Reading or deleting a non-existent key returns null from get() and head(). Always check for null before accessing object.body or object.httpEtag.

Large Upload Timeouts

Workers have a CPU timeout of 30 seconds on the free plan and 15 minutes on the paid plan. For very large uploads, use multipart upload via the S3 API instead of the Worker binding.

Metadata Size Limit

R2 custom metadata is limited to 2 KB total. Exceeding this limit causes a MetadataTooLarge error. Keep metadata concise and store larger attributes as separate objects.

ETag Mismatch

R2 returns an MD5 hash as the ETag for single-part uploads. For multipart uploads, the ETag is not an MD5 of the full object. Do not use ETags for integrity verification of multipart uploads.

Stream Disconnection

If the client disconnects mid-upload, R2 does not store the partial object. The Worker should handle stream errors gracefully and return a 503 status on failure.

Practice Questions

  1. What method does the R2 binding provide to check object existence without downloading the body?
  2. How does the put() method in R2 handle the request body to avoid memory limits?
  3. What happens when you call delete() on a key that does not exist?

FAQ

Can I upload objects larger than the Worker memory limit?

Yes. The put() method accepts a ReadableStream, which streams the data directly to R2 without buffering the entire object in Worker memory. This enables uploads of files much larger than the 128 MB Worker memory limit.

How does R2 handle concurrent writes to the same key?

R2 uses last-writer-wins semantics for concurrent writes to the same key. The most recent successful put() overwrites previous data. For atomic operations, use conditional put with the onlyIf parameter to check the existing object before overwriting.

What is the maximum key length in R2?

R2 object keys can be up to 1024 bytes of UTF-8 encoded text. Keys can contain any UTF-8 character including spaces and slashes. Very long keys may cause performance issues and should be avoided.

Summary

The R2 Worker binding provides put(), get(), delete(), and head() methods for full object lifecycle management at the edge. Uploads stream directly from the request body to R2, downloads stream from R2 to the response, and all operations complete within a single Worker invocation. These primitives form the storage backbone for Serverless applications built on Cloudflare Workers.

This guide is brought to you by the developers of Cloudflare, Cloudflare R2, and Durga Antivirus Pro at DodaTech.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro