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:
- YouTube quietly rate-limits or blocks RSS scrapers with no error — videos just disappear
- RSS doesn’t support playlist-based fetching — you get the full channel feed with no way to filter by playlist
- 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 Call | Frequency | Units/day |
|---|---|---|
playlists.list (channel) | Every 6hrs | 4 |
playlistItems.list × 10 | Every 1hr | 240 |
| Total | 244 | |
| Free quota | 10,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.
- System Design in Real Life: The 2026 Framework Shootout
- Why I Ditched Next.js for Astro (And Why You Might Too)
- Zero-Cost YouTube Caching on Cloudflare Workers