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.
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
[[r2_buckets]]
binding = "R2"
bucket_name = "nodwatch-media"
preview_bucket_name = "nodwatch-media-preview"Access as env.R2 in Workers.
Basic Operations
// 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
// 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:
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:
// 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.