Back to Blog
Feb 18, 20264 min readOnuzulike Anthony

Cloudflare R2 for Media Storage

Storing and serving HLS segments, implementing multipart uploads for large files, and integrating R2 with Workers for access-controlled media delivery.

InfrastructureCloudflareR2StorageHLS

R2 is Cloudflare's S3-compatible object storage. The key difference from S3: zero egress fees. When you're serving media, egress is typically the largest cost driver — for video-heavy applications, R2 changes the economics entirely.

When to Use R2 vs KV vs D1

  • R2: large binary objects — HLS segments, subtitles, thumbnails, full episode files. Max object size: 5 TB.
  • KV: small values that need edge-local reads — tokens, user preferences, small config. Max value: 25 MB.
  • D1: relational data with SQL queries — watch history, ratings, user accounts. Max DB size: 10 GB.

The common pattern in NodWatch: media metadata lives in D1, actual media files live in R2.

Binding in wrangler.toml

toml
[[r2_buckets]]
binding = "R2"
bucket_name = "nodwatch-media"
preview_bucket_name = "nodwatch-media-preview"

Access as env.R2 in Workers.

Basic Operations

typescript
// Upload
await env.R2.put(`subtitles/${contentId}/${lang}.vtt`, subtitleText, {
  httpMetadata: {
    contentType: "text/vtt",
    cacheControl: "public, max-age=86400",
  },
  customMetadata: {
    contentId: String(contentId),
    language: lang,
  },
});
 
// Read
const object = await env.R2.get(`subtitles/${contentId}/en.vtt`);
if (!object) return new Response("Not Found", { status: 404 });
 
return new Response(object.body, {
  headers: {
    "Content-Type": object.httpMetadata?.contentType ?? "text/plain",
    "Cache-Control": "public, max-age=86400",
  },
});
 
// Delete
await env.R2.delete(`subtitles/${contentId}/en.vtt`);
 
// List objects with prefix
const list = await env.R2.list({ prefix: `subtitles/${contentId}/` });
for (const item of list.objects) {
  console.log(item.key, item.size);
}

HLS Segment Storage

HLS breaks video into small .ts segments (typically 2-10 seconds each) plus an .m3u8 playlist. Store them with a predictable key pattern:

hls/{contentId}/{quality}/{segment}.ts
hls/{contentId}/{quality}/playlist.m3u8
hls/{contentId}/master.m3u8
typescript
// Serve HLS playlist with correct MIME type
async function serveHlsPlaylist(contentId: string, quality: string, env: Env) {
  const key = `hls/${contentId}/${quality}/playlist.m3u8`;
  const object = await env.R2.get(key);
  if (!object) return new Response("Not Found", { status: 404 });
 
  return new Response(object.body, {
    headers: {
      "Content-Type": "application/vnd.apple.mpegurl",
      "Cache-Control": "public, max-age=3600",
      "Access-Control-Allow-Origin": "*",
    },
  });
}
 
// Serve individual segment
async function serveSegment(contentId: string, quality: string, segment: string, env: Env) {
  const key = `hls/${contentId}/${quality}/${segment}`;
  const object = await env.R2.get(key);
  if (!object) return new Response("Not Found", { status: 404 });
 
  return new Response(object.body, {
    headers: {
      "Content-Type": "video/mp2t",
      "Cache-Control": "public, max-age=31536000, immutable", // segments never change
    },
  });
}

Segments get a very long cache lifetime (immutable) because once generated, they never change. Playlists get a shorter TTL since they may be regenerated.

Multipart Upload for Large Files

Files over 100 MB should use multipart upload to avoid timeouts:

typescript
async function multipartUpload(
  key: string,
  fileStream: ReadableStream,
  contentType: string,
  env: Env
) {
  const upload = await env.R2.createMultipartUpload(key, {
    httpMetadata: { contentType },
  });
 
  const parts: R2UploadedPart[] = [];
  const reader = fileStream.getReader();
  const CHUNK_SIZE = 10 * 1024 * 1024; // 10 MB per part
  let partNumber = 1;
  let buffer = new Uint8Array(0);
 
  while (true) {
    const { done, value } = await reader.read();
 
    if (value) {
      const newBuffer = new Uint8Array(buffer.length + value.length);
      newBuffer.set(buffer);
      newBuffer.set(value, buffer.length);
      buffer = newBuffer;
    }
 
    if (buffer.length >= CHUNK_SIZE || (done && buffer.length > 0)) {
      const chunk = buffer.slice(0, CHUNK_SIZE);
      buffer = buffer.slice(CHUNK_SIZE);
      const part = await upload.uploadPart(partNumber++, chunk);
      parts.push(part);
    }
 
    if (done) break;
  }
 
  await upload.complete(parts);
}

Access Control

R2 buckets are private by default. For authenticated content delivery, generate presigned URLs or serve through a Worker that validates auth:

typescript
// Worker middleware for protected media
async function serveProtectedMedia(request: Request, env: Env) {
  // Validate auth token
  const token = request.headers.get("Authorization")?.split(" ")[1];
  if (!token || !(await validateToken(token, env))) {
    return new Response("Unauthorized", { status: 401 });
  }
 
  const url = new URL(request.url);
  const key = url.pathname.slice(1); // Remove leading slash
  const object = await env.R2.get(key);
 
  if (!object) return new Response("Not Found", { status: 404 });
 
  return new Response(object.body, {
    headers: {
      "Content-Type": object.httpMetadata?.contentType ?? "application/octet-stream",
    },
  });
}

Cost Comparison

At 10 TB egress per month:

  • AWS S3: $920 (S3 + CloudFront)
  • Google Cloud Storage: $1,200
  • Cloudflare R2: $0 egress + $15 storage = $15

For media-heavy applications, R2's egress-free model is the decisive factor.