← Back to blog

Extract YouTube Subtitles in Node.js: A Practical Guide

David Boulen · 6/23/2026 · 8 min read

Extract YouTube Subtitles in Node.js: A Practical Guide

Extract YouTube Subtitles in Node.js: A Practical Guide

YouTube uploads roughly 500 hours of video every minute. Somewhere in all that content are transcripts your app needs—for a summarizer, an LLM dataset, a search index, or an accessibility pipeline. The problem is that scraping captions directly is brittle: YouTube rotates IP addresses, throttles aggressive crawlers, and regularly breaks unofficial libraries.

This guide shows you how to pull subtitles reliably using YouTube Transcriber, convert the JSON response to SRT or VTT, and batch multiple requests without getting throttled—all in modern Node.js with native fetch and async/await.

Developer working at a computer with code on screen


1. Setting Up Your Project and Bearer Token

You need Node.js 18 or later. Native fetch has been stable and unflagged since v18, so no extra HTTP library is required.

node --version   # should print v18.x.x or higher
mkdir yt-subtitles && cd yt-subtitles
npm init -y

Get your Bearer token from getyoutubetranscriber.com—100 free credits, no credit card. Store it in an environment variable, never in source code:

# .env (add this file to .gitignore)
TRANSCRIBER_API_KEY=your_token_here

Load it at the top of every script:

// config.js
export const API_KEY = process.env.TRANSCRIBER_API_KEY;
if (!API_KEY) throw new Error("Missing TRANSCRIBER_API_KEY env var");

export const BASE_URL = "https://api.getyoutubetranscriber.com";

Best practice: Use dotenv in development (npm i dotenv) and inject the env var from your hosting platform (Railway, Fly, Vercel, etc.) in production. Never commit tokens.


2. Fetching Subtitles with fetch and async/await

The transcript endpoint accepts a YouTube video ID and an optional lang parameter. It returns a JSON array of timed segments.

// transcript.js
import { API_KEY, BASE_URL } from "./config.js";

export async function getTranscript(videoId, lang = "en") {
  const url = `${BASE_URL}/transcript?videoId=${videoId}&lang=${lang}`;

  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
  });

  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    throw new TranscriptError(res.status, body.code, videoId);
  }

  return res.json(); // { segments: [{ start, duration, text }, ...] }
}

class TranscriptError extends Error {
  constructor(status, code, videoId) {
    super(`Transcript fetch failed for ${videoId}: HTTP ${status} (${code})`);
    this.status = status;
    this.code = code;
    this.videoId = videoId;
  }
}

Call it:

import { getTranscript } from "./transcript.js";

const { segments } = await getTranscript("dQw4w9WgXcQ");
console.log(segments[0]);
// { start: 0.0, duration: 4.2, text: "We're no strangers to love" }

Each segment gives you start (seconds), duration (seconds), and text (the caption line). That's everything you need for SRT, VTT, or feeding an LLM.


3. Handling Errors, Timeouts, and Rate Limits Gracefully

Three things will go wrong in production: network timeouts, missing transcripts (404), and rate limits (429). Handle each explicitly.

Timeouts

Native fetch accepts an AbortSignal:

export async function getTranscriptWithTimeout(videoId, lang = "en", timeoutMs = 8000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const url = `${BASE_URL}/transcript?videoId=${videoId}&lang=${lang}`;
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${API_KEY}` },
      signal: controller.signal,
    });

    if (!res.ok) {
      const body = await res.json().catch(() => ({}));
      throw new TranscriptError(res.status, body.code, videoId);
    }

    return res.json();
  } finally {
    clearTimeout(timer);
  }
}

Rate Limits and Exponential Backoff

When you hit HTTP 429, back off and retry—don't hammer the API:

export async function fetchWithRetry(videoId, lang = "en", maxRetries = 4) {
  let attempt = 0;

  while (attempt <= maxRetries) {
    try {
      return await getTranscriptWithTimeout(videoId, lang);
    } catch (err) {
      if (err.status === 429 && attempt < maxRetries) {
        const delay = Math.min(1000 * 2 ** attempt + Math.random() * 500, 30_000);
        console.warn(`Rate limited. Retrying in ${Math.round(delay)}ms...`);
        await new Promise((r) => setTimeout(r, delay));
        attempt++;
      } else if (err.status === 404) {
        // No transcript available—skip, don't retry
        console.warn(`No transcript for ${videoId} (${err.code})`);
        return null;
      } else {
        throw err; // unrecoverable, propagate up
      }
    }
  }
}

Gotcha: Exponential backoff with jitter (the Math.random() * 500 part) prevents a thundering herd when multiple workers hit the limit simultaneously. Always add jitter.


4. Converting JSON Transcripts to SRT and VTT

The JSON segments are the raw material. Converting to SRT or VTT is a straightforward formatting exercise—no third-party library needed.

JSON to SRT

SRT timestamps use HH:MM:SS,mmm (comma before milliseconds):

function secondsToSrtTime(totalSeconds) {
  const h = Math.floor(totalSeconds / 3600);
  const m = Math.floor((totalSeconds % 3600) / 60);
  const s = Math.floor(totalSeconds % 60);
  const ms = Math.round((totalSeconds % 1) * 1000);
  return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":") +
    "," + String(ms).padStart(3, "0");
}

export function segmentsToSrt(segments) {
  return segments
    .map((seg, i) => {
      const start = secondsToSrtTime(seg.start);
      const end = secondsToSrtTime(seg.start + seg.duration);
      return `${i + 1}\n${start} --> ${end}\n${seg.text}`;
    })
    .join("\n\n");
}

JSON to VTT

WebVTT (the format browsers use for <track> elements) is nearly identical but uses a dot before milliseconds and requires a WEBVTT header:

function secondsToVttTime(totalSeconds) {
  const h = Math.floor(totalSeconds / 3600);
  const m = Math.floor((totalSeconds % 3600) / 60);
  const s = Math.floor(totalSeconds % 60);
  const ms = Math.round((totalSeconds % 1) * 1000);
  return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":") +
    "." + String(ms).padStart(3, "0");
}

export function segmentsToVtt(segments) {
  const cues = segments
    .map((seg) => {
      const start = secondsToVttTime(seg.start);
      const end = secondsToVttTime(seg.start + seg.duration);
      return `${start} --> ${end}\n${seg.text}`;
    })
    .join("\n\n");
  return `WEBVTT\n\n${cues}`;
}

Usage:

import { writeFileSync } from "fs";
import { getTranscript } from "./transcript.js";
import { segmentsToSrt, segmentsToVtt } from "./formats.js";

const { segments } = await getTranscript("dQw4w9WgXcQ");
writeFileSync("output.srt", segmentsToSrt(segments));
writeFileSync("output.vtt", segmentsToVtt(segments));

5. Batching Requests Without Getting Throttled

Processing a list of video IDs? Don't fire all requests in parallel—that's how you burn credits and hit rate limits fast.

Controlled Concurrency with a Semaphore

// semaphore.js
export function createSemaphore(limit) {
  let active = 0;
  const queue = [];

  const acquire = () =>
    new Promise((resolve) => {
      const tryAcquire = () => {
        if (active < limit) { active++; resolve(); }
        else queue.push(tryAcquire);
      };
      tryAcquire();
    });

  const release = () => {
    active--;
    if (queue.length > 0) queue.shift()();
  };

  return { acquire, release };
}
// batch.js
import { fetchWithRetry } from "./transcript.js";
import { segmentsToSrt } from "./formats.js";
import { createSemaphore } from "./semaphore.js";
import { writeFileSync } from "fs";

const videoIds = [
  "dQw4w9WgXcQ",
  "9bZkp7q19f0",
  "OPf0YbXqDm0",
  // ... as many as you need
];

async function batchTranscripts(ids, concurrency = 5) {
  const sem = createSemaphore(concurrency);

  const results = await Promise.allSettled(
    ids.map(async (id) => {
      await sem.acquire();
      try {
        const result = await fetchWithRetry(id);
        if (result) {
          writeFileSync(`${id}.srt`, segmentsToSrt(result.segments));
          console.log(`✓ ${id}`);
        }
        return { id, ok: true };
      } catch (err) {
        console.error(`✗ ${id}: ${err.message}`);
        return { id, ok: false, error: err.message };
      } finally {
        sem.release();
      }
    })
  );

  const failed = results
    .filter((r) => r.value?.ok === false)
    .map((r) => r.value.id);

  if (failed.length > 0) {
    console.log(`\nFailed IDs (retry manually): ${failed.join(", ")}`);
  }
}

await batchTranscripts(videoIds, 5);

Best practice: A concurrency of 5 is a reasonable starting point. Increase it gradually while monitoring 429 responses. Log failed IDs to a file so you can retry just the failures—don't reprocess the whole batch.

If you'd rather use a library, p-limit on npm does the same thing in one line: import pLimit from 'p-limit'; const limit = pLimit(5);.

Code editor showing JavaScript code on a monitor


6. Putting It All Together

Here's a minimal end-to-end script that fetches a transcript, prints a preview, and writes both SRT and VTT files:

// index.js
import { fetchWithRetry } from "./transcript.js";
import { segmentsToSrt, segmentsToVtt } from "./formats.js";
import { writeFileSync } from "fs";

const VIDEO_ID = process.argv[2] ?? "dQw4w9WgXcQ";

const result = await fetchWithRetry(VIDEO_ID, "en");

if (!result) {
  console.log("No transcript available for this video.");
  process.exit(0);
}

const { segments } = result;
console.log(`Fetched ${segments.length} segments.`);
console.log("First segment:", segments[0]);

writeFileSync(`${VIDEO_ID}.srt`, segmentsToSrt(segments));
writeFileSync(`${VIDEO_ID}.vtt`, segmentsToVtt(segments));
console.log(`Wrote ${VIDEO_ID}.srt and ${VIDEO_ID}.vtt`);

Run it:

node index.js dQw4w9WgXcQ
# Fetched 47 segments.
# First segment: { start: 0, duration: 4.2, text: "We're no strangers to love" }
# Wrote dQw4w9WgXcQ.srt and dQw4w9WgXcQ.vtt

Key Gotchas and Best Practices

Environment variables, not hardcoded tokens. If a token leaks in version control, rotate it immediately and audit usage in your dashboard.

Check res.ok before calling res.json(). Error responses may return HTML or malformed JSON; always wrap the parse in a .catch(() => ({})).

Don't ignore null returns. Some videos—private, age-restricted, or live streams—won't have a transcript. Your batch job should skip them gracefully, not crash.

Use Promise.allSettled, not Promise.all, for batches. allSettled lets every request finish (or fail) before you inspect results, so one bad video ID doesn't abort the whole job.

The JSON format is LLM-ready. If you're building a summarizer or RAG pipeline, you can pass segments directly as context. See YouTube Transcript API: Build a Video Summarizer with GPT for a full walkthrough with prompt engineering.

OSS libraries vs. a managed API. The popular youtube-transcript npm package and Python's youtube-transcript-api both work until they don't—YouTube's anti-bot measures block them regularly. If you need reliability in production, a managed API that handles proxies and retries for you is worth the cost. For context on why this happens, see Why youtube-transcript-api Gets Blocked (and What to Do).

Multi-language support. Pass lang=es, lang=de, lang=ja, or any of the 125+ supported language codes to get subtitles in other languages. This is useful for localization pipelines and multilingual datasets.


Next Steps

The code in this guide is production-ready as a starting point. From here you can:

  • Pipe segments into an LLM summarization chain (see How to Get a YouTube Transcript as JSON in 5 Minutes for a quick-start).
  • Extend batchTranscripts to read video IDs from a CSV or a database query.
  • Add a webhook or cron job using the channel-monitoring endpoint to auto-fetch transcripts as new videos are published.

Get your free API token and run the first example in under five minutes—no credit card, no Google Cloud project, 100 free credits included.

Extract YouTube Subtitles in Node.js: A Practical Guide | YouTube Transcriber