Blog

Living on 10,000 YouTube API units a day

TL;DR

  • New videos come from YouTube's channel RSS feeds, which cost no quota. The Data API fills in what RSS can't tell me (video length to drop Shorts, live status, channel avatars, comments) and stands in when a feed fails.
  • Everything the API returns is cached per channel and shared by all users, so keeping feeds fresh costs in proportion to distinct channels and new uploads, not users.
  • Last 7 days: 701 units, about 100 a day, or 1% of the quota. That's pre-launch, with one real user (me) and 40 channels. The biggest line item was not what I expected.

I'm building Plainfeed, a website that shows new videos from the YouTube channels you pick, in the order they were posted, without recommendations or Shorts. I'm building it alone, and it runs on Cloudflare's free plan.

The first wall you hit with anything like this is the YouTube Data API quota: 10,000 units per project per day, with no paid tier. The only way up is an audit. This post covers the decisions I made to fit a subscription feed into that budget, and what it actually costs in practice.

Three terms first:

The rules

I re-read the official docs in October 2026 while writing this. Here's what you're working with:

What Limit
Default quota 10,000 units/day per project, shared by most methods
search.list, videos.insert Their own buckets since June 2026: 100 calls/day each, 1 unit per call
videos.batchGetStats New in June 2026, with its own bucket of 10,000 units/day, 1 unit per call
List reads (videos.list, etc.) Usually 1 unit per call, charged again for every extra page
Writes (e.g. subscribing) Usually 50 units per call
Invalid requests Still at least 1 unit
Reset Midnight Pacific Time
Getting more Quota extension form plus a compliance audit. No paid tier

Two policies matter too. Data you got from the API has to be refreshed or deleted within 30 days (Developer Policies III.E.4), and an API client gets exactly one API project (III.D.1.c). So "just make more projects" was never an option.

On my side: a user can follow as many channels as they like (the app asks the server about 30 at a time), and a new upload should usually show up within 30 minutes.

The naive math

Approach Cost What breaks
search.list per channel for its newest videos 1 call per channel check 100 calls a day for the whole project: three users with 30 channels each get one check a day
Poll each channel's uploads playlist (playlistItems.list) every 30 min 48 units per channel per day About 208 channels for the entire site
Channel RSS + videos.list for new videos only (Plainfeed) 1 unit per 50 new videos See below

Confession: Plainfeed's day-one version used the first approach. Back then it was a site that collected the newest videos per YouTube category, and it ran search.list for each category every 3 to 8 hours. My design doc from that day says "100 units per call." A few runs a day would eat a big slice of the quota, so I switched to RSS the same day. The 31 search.list calls in my console's 30-day history are what's left of that version.

The twist: while fact-checking this post, I found that note was wrong the day I wrote it. Since June 2026, more than four months before Plainfeed's first commit, search.list has had its own bucket of 100 calls a day, separate from the 10,000 units. Whether it's 100 units per call or 100 calls a day, the conclusion is the same: you can't build a subscription feed on search.

Decision 1: discover new videos with RSS (0 units)

In short: new videos come from free RSS feeds. Zero units.

Every channel has a public feed:

https://www.youtube.com/feeds/videos.xml?channel_id=UC...

No key, no quota. Google doesn't document this feed, so what follows is what it returned when I checked in October 2026: the latest 15 videos with title, publish time, view count and link. If the link contains /shorts/, it's a Short, and you can drop it without knowing its length. Of the 590 videos currently in my cache, 153 (26%) were filtered that way for free.

What RSS doesn't give you:

Browsers can't fetch these feeds directly (no CORS headers), and the API key can't live in the browser anyway, so a server function on Cloudflare Pages does the fetching.

Decision 2: use the API for what RSS can't tell

In short: ask the API only for what RSS lacks (duration, live status, avatars, comments), 50 IDs per call for 1 unit.

Need Method Cost How often
Duration, live status videos.list 1 unit per up to 50 IDs When new videos appear. Cached 7 days, 5 min while live
Channel avatar channels.list 1 unit per up to 50 Once per channel per 7 days
Comments, replies commentThreads.list, comments.list 1 unit per page Only when the comments tab is open. Cached 30 min
Adding a channel by URL channels.list (plus videos.list for a video URL) 1–2 units On add. Same URL cached for a day

videos.list accepts 50 IDs per call (the limit isn't on its reference page, but that's what it takes), so 50 new videos cost 1 unit. Anything 3 minutes (180 seconds) or shorter is treated as a Short and dropped, except finished live streams. That caught 73 more videos the RSS links missed.

// Only videos whose cached details are stale, 50 IDs per call (1 unit). Simplified.
for (let i = 0; i < ids.length; i += 50) {
  const body = await api("videos", {
    part: "contentDetails,liveStreamingDetails,snippet,statistics",
    id: ids.slice(i, i + 50).join(","),
  });
  for (const v of body.items) {
    out[v.id] = {
      duration: parseDuration(v.contentDetails.duration),   // <= 180 s is a Short
      state: v.snippet.liveBroadcastContent,                 // live, upcoming, none
      start: v.liveStreamingDetails?.actualStartTime,       // streams sit at their start time
    };
  }
}

One thing I deliberately skipped: a subscribe button. subscriptions.insert costs 50 units and needs write access to the user's account, so subscribing happens on YouTube.

Decision 3: cache per channel, share with everyone

In short: results are cached per channel and shared by all users, so more users cost almost nothing.

Results live in Cloudflare D1 (SQLite), one row per channel: the RSS result, each video's length and live state, and the channel's avatar, each stamped with the time it was fetched.

How Plainfeed fetches new videos. The browser sends channel IDs to a server function. The server reads the D1 cache first, refetches RSS only for channels older than 30 minutes, and asks the YouTube Data API only for what RSS can't tell. Google's WebSub hub pings the server when a video is posted.
Cache first, then RSS, then the API. The times in box ① are how long each piece is kept before it's refetched.

The important part is that the cache is shared. A feed request carries only channel IDs, and the cache is keyed by channel, so it doesn't matter who asks. Whether one person or a thousand follow a channel, its video details are fetched once. Quota scales with the number of distinct channels and how much they upload, not with users.

The 30-day policy means none of this can be kept forever. A daily cleanup job deletes rows that haven't been refreshed in 30 days, and the next request fetches them again.

Decision 4: get pinged instead of polling (WebSub)

In short: instead of polling, Google pings the server when a channel posts. Also zero units.

With a 30-minute RSS cache, the server knows nothing about new uploads while nobody has the site open. YouTube supports WebSub (PubSubHubbub): subscribe a channel at Google's hub, and the hub pings your server when a video is posted (or its title or description changes). The server then refetches that one channel's RSS. Also zero quota.

Anyone can POST to that endpoint, so the payload isn't trusted. Each channel gets its own secret, the signature is checked, and a valid ping only triggers an RSS refetch. The 30-minute cache stays as a backup for late or missing pings.

Decision 5: daily budgets on what strangers can spend

In short: every expensive operation that works without an account gets a daily cap.

Everything above assumes normal use. Plainfeed works without an account, which means the feed, channel lookup and comment endpoints are public. When I audited them, I found ways to drain the quota in a few hours just by cycling through channel URLs or comment page tokens.

So every expensive operation got a daily budget:

Operation Total per day Per client Over budget
Fetch a channel we've never seen 1,000 150 Treated as not fetched, retried later
Refetch a channel older than 30 min 20,000 2,000 Serve what's cached
Look up a channel by URL 1,500 100 Cached result, or "try again tomorrow"
Comments and replies 3,000 400 Same

A "client" is a keyed hash that changes every day; the IP itself is never stored.

Added up, comments and lookups alone could still spend 4,500 to 6,000 units on a bad day. That fits under 10,000, but without much room, so these are the first numbers I'll revisit as usage grows.

Two more limits:

try {
  feed = await fetchRss(id);                 // 0 units
} catch (e) {
  // Fall back only for known channels, at most 6 per request,
  // and not if the fallback itself failed in the last hour. Simplified.
  if (!known || fallbacks >= 6 || now - failedAt < 3600) throw e;
  fallbacks++;
  feed = await fetchUploads(id);             // playlistItems.list: 1 unit
}

What surprised me

1. The biggest quota user was the RSS fallback. Of the 701 units in the last 7 days, 309 (44%) went to playlistItems calls made because RSS failed. I assumed a free feed would rarely fail. The odd part: while writing this, I fetched the same 40 feeds from my home connection and all 40 loaded fine. My guess is that YouTube sometimes refuses requests from cloud IP ranges, but I haven't proven it.

2. A deleted channel cost a unit on every request. RSS fails, the fallback fails, and the next request tries again, forever. A channel that goes private does the same. Now a failed fallback records the time, and that channel isn't retried for an hour (failedAt above).

3. "1 unit per 50 videos" is the best case. My 40 channels post about 25 new videos a day. Add the ~440 cached non-Shorts that get re-checked every 7 days, and 50 IDs per call still works out to about 2 calls a day. videos.list was called 270 times in 7 days, about 39 a day. New videos tend to show up one or two at a time, and re-checking live (every 5 minutes) and upcoming streams adds calls, so most calls carry only a few IDs. I don't log a reason per call, so that one is an educated guess too.

4. My own design doc was out of date from day one. That's the search.list story above: the rule had changed months before I wrote it down. Lesson learned: re-read the docs right before publishing anything with numbers in it.

The actual numbers

These are request counts per method from the YouTube Data API metrics page in Google Cloud console, for the 7 days ending October 29, 2026. Every method here costs 1 unit per call, so requests equal units.

Method Units in 7 days Used for
playlistItems.list 309 RSS fallback
videos.list 270 Duration, live status
commentThreads.list 58 Comments
channels.list 32 Avatars, adding a channel by URL
subscriptions.list 21 Importing subscriptions from Google
comments.list 11 Replies
Total 701 About 100 a day, 1% of the quota

Something I hadn't realized: importing subscriptions runs in the browser with the user's own Google token, but the quota is still charged to Plainfeed's project.

Caveats: this is pre-launch, there's one real user (me), 40 channels, and part of it is my own testing. More users will mean bigger numbers. But the direction is fixed: more people following the same channels is nearly free; new channels and their uploads are what cost.

For the same 40 channels:

API units per day to follow 40 channels. Polling every 30 minutes costs 1,920. Plainfeed measured 100. The theoretical floor, with no RSS failures and every call filled to 50 IDs, is about 2.
The measured number includes comments and subscription imports.

What I haven't done, and what's next

Wrapping up

The trick wasn't calling the API more cleverly. It was a design where most questions never reach the API: RSS and pings say what's new, the API answers what RSS can't, and everyone shares the answers. The part that didn't go to plan, the RSS fallback, is where the next fix goes.

Next post: running the same server on Cloudflare's free plan, and keeping strangers from burning through D1's daily read and write limits.

All posts