Workers R2 -- Upload, Download and Delete Objects
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
- What method does the R2 binding provide to check object existence without downloading the body?
- How does the
put()method in R2 handle the request body to avoid memory limits? - What happens when you call
delete()on a key that does not exist?
FAQ
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