JavaScript · Node.js · Tutorial
How to Get YouTube Transcripts in JavaScript and Node.js
npm packages that read YouTube's caption endpoints work on your laptop and break on servers. How to fetch transcripts in Node.js with plain fetch: languages, formats, errors, retries, and many videos at once.
· 3 min read · YTAPI
There are two ways to get a YouTube transcript in Node.js: an npm package that reads the captions YouTube's own player loads, or a hosted API. This post shows both, and where the first one stops working.
Option 1: an npm package
youtube-transcript is the common choice:
npm install youtube-transcriptimport { fetchTranscript } from "youtube-transcript";
const lines = await fetchTranscript("dQw4w9WgXcQ");
console.log(lines.map((l) => l.text).join(" "));It's free and it works on your machine. Its README is upfront that it uses an unofficial YouTube API that can break over time. Packages like this call YouTube's internal caption endpoints from wherever your code runs, so they have two weak spots:
- Servers get blocked. From AWS, Google Cloud, Vercel or any other hosting provider, YouTube refuses or challenges the requests after a while, sometimes from the first one. The Python library has the same problem; here's why, and on Vercel, Render and Railway.
- YouTube changes things. The endpoints aren't public, so when YouTube changes them, the package breaks until someone ships a fix.
Option 2: a hosted API with fetch
Node 18 and later have fetch built in, so there's nothing to install:
const API = "https://api.ytapi.dev/v1/transcripts";
export async function getTranscript(videoId, { languages, format = "segments" } = {}) {
const res = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.YTAPI_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ video_id: videoId, format, ...(languages && { languages }) }),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
// {"error": {"code": "captions_disabled", "message": "..."}}
const { error } = await res.json().catch(() => ({}));
const err = new Error(error?.message ?? `HTTP ${res.status}`);
err.status = res.status;
err.code = error?.code;
err.retryAfter = Number(res.headers.get("retry-after")) || 0;
throw err;
}
return format === "segments" ? res.json() : res.text();
}
const t = await getTranscript("dQw4w9WgXcQ");
console.log(t.language, t.track_kind, t.segments.length);
for (const s of t.segments.slice(0, 3)) console.log(s.start, s.text);segments gives JSON: language, track_kind (manual for creator captions, asr for auto-generated), duration_seconds, and segments, each with text, start, end and duration in seconds.
Languages
Without languages you get the video's own language, the one spoken in it. To prefer another, pass a list; "*" at the end falls back to the original:
await getTranscript("dQw4w9WgXcQ", { languages: ["es", "*"] });The free basic info endpoint lists which languages a video has, and whether each is creator-made or auto-generated.
Other formats
text, markdown, srt and vtt come back as the text itself, which is why the function above returns res.text() for them:
import { writeFile } from "node:fs/promises";
await writeFile("captions.srt", await getTranscript("dQw4w9WgXcQ", { format: "srt" }));markdown groups the captions into paragraphs under timestamp headings, which is a good shape for an LLM.
Errors
A video without captions is 404 with captions_disabled; a language you asked for that doesn't exist is 404 with language_not_found; a private or removed video is 404 with video_unavailable. None of them is billed, and retrying won't change them. 429 means you're over your rate limit: wait for the number of seconds in Retry-After and try again. Server errors (5xx) are worth a retry with a pause too:
async function getTranscriptWithRetry(videoId, opts, attempts = 3) {
for (let i = 0; ; i++) {
try {
return await getTranscript(videoId, opts);
} catch (err) {
if (err.status !== 429 && err.status < 500) throw err;
if (i + 1 >= attempts) throw err;
const seconds = err.retryAfter || 2 ** i;
await new Promise((r) => setTimeout(r, seconds * 1000));
}
}
}Many videos
A few concurrent requests are enough to go through a list quickly. This keeps five in flight without any dependency:
async function mapLimit(items, limit, fn) {
const results = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
results[i] = await fn(items[i]).catch((err) => ({ error: err.code ?? err.message }));
}
}
await Promise.all(Array.from({ length: limit }, worker));
return results;
}
const ids = ["dQw4w9WgXcQ", "jNQXAC9IVRw", "9bZkp7q19f0"];
const transcripts = await mapLimit(ids, 5, (id) => getTranscriptWithRetry(id, { format: "text" }));For a whole channel or playlist, list the video IDs and send them as batches of 100 instead.
Which one to use
| npm package | Hosted API | |
|---|---|---|
| Cost | Free | Per transcript (200 free credits to start) |
| On your laptop | Works | Works |
| On a server or serverless | Often blocked | Works |
| When YouTube changes something | Wait for a package update | The provider fixes it |
Use the package for a script on your own machine. Use an API when the code runs on a server, on a schedule, or for users who expect it to work every time.
FAQ
Can I call it from the browser?
Not with your API key: anyone could read it from the page. Call it from your server, or from a route in your framework, and have the browser call that route.
Does it work in serverless and edge functions?
Yes. It's one fetch call, so it works in Node, Vercel Edge Functions, Cloudflare Workers and Deno. Set the timeout below your platform's own limit.
How do I get only the text without timestamps?
Pass format: "text" and you get the transcript as plain text, which you can send straight to a model or a search index.