If you resolve a YouTube handle to a channel ID by grabbing the first "channelId" in the page HTML, you are probably getting a different channel. In my sample it was wrong 15 times out of 16 — and the one time it was right is the most dangerous case of all.
I found this the expensive way, after it had silently invalidated a measurement I had already published (since withdrawn).
The check
For each handle I fetched https://www.youtube.com/@name, pulled the first "channelId":"UC..." out of the HTML, and separately pulled the ID from the page's <link rel="canonical">. Then I asked YouTube's public feed what each ID is actually called.
| Handle | First "channelId" resolves to |
canonical resolves to |
|---|---|---|
@veritasium |
Veritasium en Français | Veritasium |
@mkbhd |
The Studio | Marques Brownlee |
@NASA |
Learn With NASA | NASA |
@TED |
TEDx Talks | TED |
@hikakin |
HikakinTV | HIKAKIN |
@3blue1brown |
Mathologer | 3Blue1Brown |
@kurzgesagt |
Nightshift – Kurzgesagt After Dark | Kurzgesagt – In a Nutshell |
@Fireship |
Beyond Fireship | Fireship |
Eight for eight. I then ran eight more, including the channels that came back as the wrong answers above:
@Computerphile first → Numberphile canonical → Computerphile
@ThePrimeagen first → The PrimeTime canonical → ThePrimeagen
@BillieEilish first → BillieEilishVEVO canonical → Billie Eilish
@LinusTechTips first → ShortCircuit canonical → Linus Tech Tips
@beyondfireship first → Fireship canonical → Beyond Fireship
@Mathologer first → Mathologer 2 canonical → Mathologer
@nightshift_... first → Kurzgesagt – In a Nutshell canonical → Nightshift – …
@LearnWithNASA first → Learn With NASA canonical → Learn With NASA ← same
15 of 16 wrong. And read that last line carefully, because it is the trap inside the trap: on a channel with no featured-channels shelf, the naive method returns the right answer. Test your resolver against one of those and you will conclude your code is fine.
Why this is worse than a loud bug
Look at what it hands you instead. @mkbhd gives you The Studio, whose own description says it carries behind-the-scenes content from the MKBHD studio. @kurzgesagt gives you Nightshift – Kurzgesagt After Dark. @Fireship gives you Beyond Fireship, described as "even more highly-focused blazingly-fast coding tutorials from Fireship". @veritasium gives you Veritasium en Français, which calls itself an official Veritasium channel.
But not always a relative: @3blue1brown gives you Mathologer, an independent channel run by a maths professor at Monash University with no organisational connection to 3Blue1Brown at all. The rule is not "you get a sibling channel". The rule is you get whichever channel the page happens to feature first, and on a channel page that is almost always something on-topic.
So nothing throws. Your rows fill up. Your row count is right. The titles look like the titles you expected. If you are aggregating across channels, you will never see it.
That is exactly what happened to me. I ran a caption survey across twelve channels, verified every number in the write-up against the saved JSON, had the draft adversarially reviewed twice, and published. All of that checked whether the article matched the data. None of it checked whether the data matched the channels I had named. It took an unrelated log line — @veritasium → Veritasium en Français — to notice, hours later.
Where the wrong ID actually lives
I checked the position of that first "channelId" on three pages. On all three it sat inside a gridChannelRenderer in a horizontalListRenderer — a featured-channels shelf — between 37% and 84% of the way through a 1.7–2.5 MB document:
"content":{"horizontalListRenderer":{"items":[{"gridChannelRenderer":{"channelId":"UC…"
Which also explains @LearnWithNASA: no such shelf on the page, so the first "channelId" it finds is the channel's own.
What to use instead
Five markers point at the channel itself, and in this sample all five agreed with each other on every channel I checked:
export function extractChannelId(html) {
const t = String(html ?? '');
for (const re of [
/<link[^>]+rel="canonical"[^>]+href="https:\/\/www\.youtube\.com\/channel\/(UC[\w-]{22})"/i,
/<meta[^>]+property="og:url"[^>]+content="https:\/\/www\.youtube\.com\/channel\/(UC[\w-]{22})"/i,
/<meta[^>]+itemprop="identifier"[^>]+content="(UC[\w-]{22})"/i,
/"externalId"\s*:\s*"(UC[\w-]{22})"/,
/"rssUrl"\s*:\s*"[^"]*channel_id=(UC[\w-]{22})"/,
]) {
const m = t.match(re);
if (m) return m[1];
}
return null; // ← not a fallback to "channelId"
}
That last line is the important one. The temptation is to fall back to "channelId" when none of the five match, on the theory that a wrong answer beats no answer. It does not. null is a row you can label "could not resolve this channel". A featured channel's ID is a row that lies to you.
On {22}: it is a validity check, not an over-match guard. Every pattern above is terminated by a closing quote, and " is in neither \w nor -, so UC[\w-]+ would capture exactly the same characters. What {22} buys you is that a token of the wrong length fails the pattern instead of being handed back as an ID. The cost is that if YouTube ever changes the format, all five stop matching and you get null — which, given the rule above, is the failure I want.
Confirming a resolution costs one request
Whatever you extract, the public feed will tell you what it is:
const feed = await (await fetch(`https://www.youtube.com/feeds/videos.xml?channel_id=${id}`)).text();
const name = feed.match(/<title>([\s\S]*?)<\/title>/)?.[1];
console.log(`${handle} → ${name}`); // this line is the entire fix
The first <title> in that XML is the channel's own name. Print it, compare it to what you asked for. It is one line, and it would have stopped me publishing an article about the wrong channels.
That feed is also where the videos are — up to the 15 most recent, as <yt:videoId> entries — so if resolving the channel was a step towards listing its uploads, you are already there.
About the sample
Sixteen channels, all of them ones I picked by hand, all large, all fetched on 2026-08-29 from one IP with hl=en&gl=US. That is enough to show the naive method is unreliable and that the five markers agree with each other; it is not enough to tell you the exact failure rate on channels unlike these.
The short version
- The first
"channelId"in a YouTube channel page usually belongs to a different channel — 15 of 16 in my sample. - It sits in the page's featured-channels shelf, so the wrong channel is on-topic and looks right.
- Sometimes it is the same creator's other channel; sometimes it is an unrelated channel they feature. Do not assume a relationship.
- A channel with no featured shelf makes the naive method look correct. Do not validate your resolver on one.
- Use
canonical,og:url,itemprop="identifier","externalId"or"rssUrl". Returnnullrather than falling back. - Print the resolved channel's own name once. Verifying the numbers is not the same as verifying the subject, and only one of those catches this.
Where this came from
I hit this while building YouTube Channel Transcript Scraper, which takes a channel handle and returns transcripts for its recent videos. Resolution now uses the five markers above, and a channel it cannot resolve produces a row saying which one and why, rather than quietly substituting another.
Re-running the survey that started all this, against the correct channels: 180 videos across 12 channels produced 163 with a caption track, 15 with none at all, and 2 that could not be played — three outcomes, not two. Of the 163, 120 (73.6%) offered only auto-generated captions. Human captions cluster hard: six of the twelve channels had none across their last 15 uploads, while Marques Brownlee (12) and NASA (11) accounted for over half of the 43 that did.
Written with AI assistance. Every handle, ID, channel name, position and ratio above came from live requests executed on 2026-08-29 before publishing.
Top comments (0)