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:
- Units: points the YouTube API deducts per call. 10,000 per project per day, refilled at midnight Pacific.
- RSS: each channel's public feed of new videos. Free, no units.
- WebSub: Google pings your server when a channel posts.
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:
- Duration. Some short videos don't have a
/shorts/link, so you still need the length to catch them all. - Live status. Whether a video is live, upcoming or a finished stream, and when it starts.
- Anything older than the latest 15. Channels that post a lot push older videos out.
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.

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:
- When RSS fails, the server falls back to the uploads playlist (playlistItems.list, 1 unit), but only for channels it has fetched successfully before, and at most 6 per request. Otherwise you could send a list of made-up channel IDs and make me pay for each one: even invalid requests cost a unit.
- Cloudflare's free plan allows 50 outbound calls per request, which is why the app asks for channels 30 at a time.
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:

What I haven't done, and what's next
- I haven't asked for more quota. It requires an audit, so I'll apply when usage gets close to the limit.
- No extra projects. One API client, one project, per the policy.
- A separate daily cap for RSS fallbacks. Right now fallbacks are capped per request (6) and by the refresh budget, but not by units, so a day when RSS is badly blocked could spend a lot. I found that hole while writing this post.
- Moving duration lookups to videos.batchGetStats. It arrived in June 2026 with its own 10,000-unit bucket and returns video length, but no live status, so streams would stay on videos.list. I only found it while fact-checking this post.
- Searching older videos, if people ask for it: fetch uploads playlists 50 at a time (1 unit) and cache them for 30 days. Still no search.list.
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.