Tutorial · Build Log

Zero-Cost YouTube Caching on Cloudflare Workers

Fetch YouTube playlists dynamically, cache them on Cloudflare KV, stay on the free tier — even if the site goes viral.

The Problem with YouTube RSS

The old site used RSS to pull YouTube videos. It looked like this:

const feed = await fetch(
  `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`
);

Three problems:

  1. YouTube quietly rate-limits or blocks RSS scrapers with no error — videos just disappear
  2. RSS doesn’t support playlist-based fetching — you get the full channel feed with no way to filter by playlist
  3. XML parsing is CPU-intensive in a Cloudflare Worker (though manageable — see the analysis post)

The Solution: Two-Layer KV Cache

The replacement architecture uses the official YouTube Data API v3 with Cloudflare KV as a cache layer between your Worker and the API.

Layer 1: Channel playlists (auto-discovery, 6hr cache)
─────────────────────────────────────────────────────
KV.get('channel-playlists:{channelId}')
  HIT  → return cached JSON              // ~0.3ms CPU, 99.9% of requests
  MISS → fetch /youtube/v3/playlists     // 1 API unit, ~100-300ms wall-clock
       → KV.put(result, ttl: 6hrs)

Layer 2: Videos per playlist (1hr cache)
─────────────────────────────────────────
KV.get('playlist:{playlistId}')
  HIT  → return cached JSON              // ~0.3ms CPU, 99.9% of requests
  MISS → fetch /youtube/v3/playlistItems // 1 API unit, ~100-300ms wall-clock
       → KV.put(result, ttl: 1hr)

The key insight: I/O time (network fetches, KV reads) doesn’t count toward Cloudflare’s 10ms CPU limit. Only JavaScript execution counts. So a KV cache hit costs ~0.3ms CPU — trivially safe on the free tier.

The Code

export async function getAllPlaylists(
  channelId: string,
  kv: KVNamespace,
  apiKey: string,
): Promise<Playlist[]> {
  const cacheKey = `channel-playlists:${channelId}`;

  // Try cache first
  const cached = await kv.get(cacheKey, 'json') as Playlist[] | null;
  if (cached) return cached; // HIT

  // Cache miss — fetch from API
  const url = new URL('https://www.googleapis.com/youtube/v3/playlists');
  url.searchParams.set('part', 'snippet,contentDetails');
  url.searchParams.set('channelId', channelId);
  url.searchParams.set('maxResults', '50');
  url.searchParams.set('key', apiKey);

  const res = await fetch(url.toString());
  const data = await res.json();
  const playlists = data.items.map(/* ... transform ... */);

  // Store with 6hr TTL
  await kv.put(cacheKey, JSON.stringify(playlists), { expirationTtl: 21600 });
  return playlists;
}

The Quota Math

At 10 playlists, refreshed every hour:

API CallFrequencyUnits/day
playlists.list (channel)Every 6hrs4
playlistItems.list × 10Every 1hr240
Total244
Free quota10,000

You’d need 41 playlists refreshing every hour to hit the free quota limit. Very safe.

When You’d Need to Pay

The free tier gives you 100K Worker requests/day and 100K KV reads/day. You hit these limits at the same time:

~3,334 daily visitors to dynamic pages = $5/month upgrade trigger

At that point you’re probably monetising anyway. The correct framing: the day you need to pay $5 is the day you have 100K people visiting your site. Not a bad problem to have.

More in Build Log