Skip to content
sproutboatDocs
Esc
↑↓navigate↵open⌘Jpreview
On this page

R2-compatible object storage

Object storage bound to env.NAME, with the R2 binding API, multipart uploads and direct transfer URLs.

Object storage with the R2 binding API. Each object’s bytes live in their own file on disk.

Configuration

"r2_buckets": ["MEDIA"]

Bare names are auto-provisioned on deploy; see Storage resources.

Usage

export default {
  async fetch(request, env, ctx) {
    const key = new URL(request.url).pathname.slice(1);

    if (request.method === "PUT") {
      await env.MEDIA.put(key, request.body, { httpMetadata: { contentType: "text/plain" } });
      return new Response("stored\n", { status: 201 });
    }

    const object = await env.MEDIA.get(key); // null when missing
    if (!object) return new Response("not found\n", { status: 404 });

    const headers = new Headers({ etag: object.httpEtag });
    object.writeHttpMetadata(headers); // content-type, cache-control, ...
    return new Response(object.body, { headers });
  },
} satisfies ExportedHandler<Env>;

The other calls, with their results:

const head = await env.MEDIA.head("path/file.txt"); // metadata only, or null
await env.MEDIA.delete("path/file.txt");
const { objects, truncated, cursor } = await env.MEDIA.list({ prefix: "path/", limit: 100 });

This is the Cloudflare Workers R2 API, so the same code runs on Workers. From compatibility date 2026-10-26 every call returns a Promise and an object is the Workers shape. Before it they are synchronous, and the older API applies (see below). await works at both dates, so write it.

Methods

Method Signature Returns
put (key, value, { httpMetadata?, customMetadata?, onlyIf? }) Promise<object | null>. value is a string, an ArrayBuffer, a typed array, a ReadableStream or a Blob; null when onlyIf fails.
get (key, { range?, onlyIf? }) Promise<object | null>, null when the key is missing
head (key) Promise<object | null>: the metadata without the body
delete (key | keys[]) Promise<void>
list ({ prefix?, limit?, cursor?, delimiter?, include? }) Promise<{ objects, truncated, cursor?, delimitedPrefixes }>. limit defaults to and is capped at 1000.
createMultipartUpload (key, { httpMetadata?, customMetadata? }) Promise<upload>
resumeMultipartUpload (key, uploadId) an upload
createUploadUrl (key, { maxBytes?, expiresIn?, sha256?, httpMetadata?, customMetadata? }) { url, expiresAt }. A Sproutboat addition.
createDownloadUrl (key, { expiresIn? }) { url, expiresAt }. A Sproutboat addition.

An object has key, size, etag, httpEtag (quoted, for an ETag header), checksums, uploaded (a Date), httpMetadata and customMetadata. From get, it also has body (a ReadableStream), bodyUsed, text(), json(), arrayBuffer(), blob() and writeHttpMetadata(headers), which copies the content type and cache headers onto a Headers. A get with a range of { offset, length }, { suffix } or a Range header returns that part of the object.

createUploadUrl and createDownloadUrl exist only on Sproutboat, so a handler that calls them does not run on Workers. Await them anyway: they return Promises from 2026-10-26.

Large files

put and get hold the whole object in memory. A request body is capped at 100 MiB from 2026-10-27 (1 MiB before). For anything larger, use one of these:

Direct transfer URLs. createUploadUrl and createDownloadUrl return a short-lived URL on your own hostname. The client sends the file straight to it with PUT, or fetches it with GET or HEAD (byte ranges supported), and the bytes never pass through your handler.

export default {
  async fetch(request, env, ctx) {
    const { url } = await env.MEDIA.createUploadUrl("uploads/video.mp4", { maxBytes: 500 * 1024 * 1024 });
    return Response.json({ uploadTo: url });
  },
} satisfies ExportedHandler<Env>;
  • Each URL works once and expires after expiresIn seconds: 900 by default, between 30 and 3600.
  • An upload is capped at maxBytes: 100 MiB by default, 5 GiB at most.
  • Pass sha256 (lowercase hex) to have the upload rejected when its contents do not match.

Multipart uploads. Send a large object in parts, each within the request body limit:

const upload = await env.MEDIA.createMultipartUpload("big.bin");
const part = await upload.uploadPart(1, chunk); // part numbers 1 to 10000
await upload.complete([part]); // or await upload.abort()

Store upload.key and upload.uploadId to continue later with env.MEDIA.resumeMultipartUpload(key, uploadId). Unfinished uploads are deleted after seven days.

Before compatibility date 2026-10-26

Calls are synchronous and return their value directly, an object’s uploaded is an ISO string, and list returns { objects, truncated, cursor } with no delimitedPrefixes. An object from get has body and text() as strings, json(), and toResponse(init?):

const obj = env.MEDIA.get("path/file.txt"); // null when missing
return obj.toResponse(); // content-type from httpMetadata, etag from httpEtag

Move compatibility_date to 2026-10-26 or later for the Workers API above.

Was this page helpful?