Download YouTube and TikTok Videos from Node.js Without Installing yt-dlp

May 22, 2026 · RenderIO

child_process.spawn() doesn't belong in your deployment

The standard Node.js approach to yt-dlp looks like this:

import { spawn } from "child_process";

const ytdlp = spawn("yt-dlp", ["-o", "%(title)s.%(ext)s", url]);
ytdlp.stdout.on("data", (data) => console.log(data.toString()));
ytdlp.on("close", (code) => console.log(`Exited with code ${code}`));

Works locally. Fails the moment you deploy to Lambda or Cloudflare Workers, which don't run arbitrary binaries. And even on a VPS that has the binary, you're on the hook for updating it whenever a platform breaks, managing proxies to avoid IP bans, and wiring up your own retry logic.

The alternative is one fetch() call.

Setup

Node.js 18+ has built-in fetch. No extra dependencies. Older versions can drop in node-fetch.

const API_KEY = process.env.RENDERIO_API_KEY;
const BASE_URL = "https://renderio.dev/api/v1";

Basic download

async function downloadVideo(url) {
  const submitRes = await fetch(`${BASE_URL}/ytdlp-download`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-KEY": API_KEY,
    },
    body: JSON.stringify({
      input_urls: { in_video: url },
    }),
  });

  if (!submitRes.ok) {
    const err = await submitRes.json();
    throw new Error(`Submit failed: ${err.message}`);
  }

  const { command_id } = await submitRes.json();
  return pollUntilDone(command_id);
}

async function pollUntilDone(commandId, intervalMs = 3000, timeoutMs = 300000) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const res = await fetch(`${BASE_URL}/commands/${commandId}`, {
      headers: { "X-API-KEY": API_KEY },
    });

    const result = await res.json();

    if (result.status === "SUCCESS") return result;
    if (result.status === "FAILED") throw new Error(`Download failed: ${result.error}`);

    await new Promise((resolve) => setTimeout(resolve, intervalMs));
  }

  throw new Error(`Timed out waiting for command ${commandId}`);
}

Usage:

const result = await downloadVideo("https://www.youtube.com/watch?v=dQw4w9WgXcQ");
const fileUrl = result.output_files.out_video.storage_url;

The output key follows the input key: in_videoout_video, in_clipout_clip. Whatever you put after in_ becomes the out_ key in the response.

Download from any platform

Same endpoint, any yt-dlp-supported URL:

// YouTube, TikTok, Reddit, Instagram, Vimeo — all use the same call
const result = await downloadVideo("https://www.tiktok.com/@user/video/1234567890");
console.log(result.output_files.out_video.storage_url);

Tagging jobs with metadata

Pass up to 10 key/value pairs in metadata to track which user triggered the download, or link it back to your own job system:

async function downloadWithMeta(url, userId, jobId) {
  const res = await fetch(`${BASE_URL}/ytdlp-download`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-KEY": API_KEY,
    },
    body: JSON.stringify({
      input_urls: { in_video: url },
      metadata: {
        user_id: userId,
        job_id: jobId,
        requested_at: new Date().toISOString(),
      },
    }),
  });

  const { command_id } = await res.json();
  return command_id;
}

Metadata comes back in poll responses and webhooks.

Download and process in one call

If you need to transcode the video right after downloading it, use /run-ytdlp-command. It downloads first, then runs your FFmpeg command on the result:

async function downloadAndExtractAudio(url) {
  const res = await fetch(`${BASE_URL}/run-ytdlp-command`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-KEY": API_KEY,
    },
    body: JSON.stringify({
      input_urls: { in_video: url },
      ffmpeg_command: "-i {{in_video}} -vn -c:a libmp3lame -q:a 2 {{out_audio}}",
      output_files: { out_audio: "audio.mp3" },
    }),
  });

  const { command_id } = await res.json();
  const result = await pollUntilDone(command_id);
  return result.output_files.out_audio.storage_url;
}

{{double_braces}} for placeholders, output_files maps each output key to a filename. More recipes in the download and process guide.

Full client class

class VideoDownloader {
  constructor(apiKey, options = {}) {
    this.apiKey = apiKey;
    this.baseUrl = "https://renderio.dev/api/v1";
    this.pollInterval = options.pollInterval ?? 3000;
    this.timeout = options.timeout ?? 300000;
  }

  async download(url, metadata = {}) {
    const commandId = await this.#submit(url, metadata);
    return this.#poll(commandId);
  }

  async downloadAndProcess(url, ffmpegCommand, outputFiles, metadata = {}) {
    const res = await this.#request("/run-ytdlp-command", {
      input_urls: { in_video: url },
      ffmpeg_command: ffmpegCommand,
      output_files: outputFiles,
      metadata,
    });
    return this.#poll(res.command_id);
  }

  async #submit(url, metadata) {
    const res = await this.#request("/ytdlp-download", {
      input_urls: { in_video: url },
      metadata,
    });
    return res.command_id;
  }

  async #request(path, body) {
    const res = await fetch(`${this.baseUrl}${path}`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-KEY": this.apiKey,
      },
      body: JSON.stringify(body),
    });

    if (!res.ok) {
      const err = await res.json().catch(() => ({}));
      throw new Error(`API error ${res.status}: ${err.message ?? res.statusText}`);
    }

    return res.json();
  }

  async #poll(commandId) {
    const deadline = Date.now() + this.timeout;

    while (Date.now() < deadline) {
      const res = await fetch(`${this.baseUrl}/commands/${commandId}`, {
        headers: { "X-API-KEY": this.apiKey },
      });

      const result = await res.json();

      switch (result.status) {
        case "SUCCESS":
          return result;
        case "FAILED":
          throw new Error(`Command failed: ${result.error ?? "unknown error"}`);
        default:
          await new Promise((r) => setTimeout(r, this.pollInterval));
      }
    }

    throw new Error(`Timed out after ${this.timeout}ms waiting for ${commandId}`);
  }
}
const downloader = new VideoDownloader(process.env.RENDERIO_API_KEY);

const result = await downloader.download("https://www.youtube.com/watch?v=dQw4w9WgXcQ", {
  user_id: "usr_123",
});
console.log(result.output_files.out_video.storage_url);

const audioResult = await downloader.downloadAndProcess(
  "https://www.tiktok.com/@user/video/1234567890",
  "-i {{in_video}} -vn -c:a libmp3lame -q:a 2 {{out_audio}}",
  { out_audio: "audio.mp3" }
);
console.log(audioResult.output_files.out_audio.storage_url);

TypeScript

interface CommandResult {
  command_id: string;
  status: "QUEUED" | "PROCESSING" | "SUCCESS" | "FAILED";
  output_files?: Record<string, { storage_url: string; size_mbytes: number }>;
  error?: string;
  metadata?: Record<string, string | number | boolean>;
}

async function downloadVideo(url: string): Promise<CommandResult> {
  const API_KEY = process.env.RENDERIO_API_KEY!;
  const BASE_URL = "https://renderio.dev/api/v1";

  const submit = await fetch(`${BASE_URL}/ytdlp-download`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-API-KEY": API_KEY },
    body: JSON.stringify({ input_urls: { in_video: url } }),
  });

  const { command_id } = (await submit.json()) as CommandResult;

  while (true) {
    await new Promise((r) => setTimeout(r, 3000));

    const poll = await fetch(`${BASE_URL}/commands/${command_id}`, {
      headers: { "X-API-KEY": API_KEY },
    });

    const result = (await poll.json()) as CommandResult;

    if (result.status === "SUCCESS" || result.status === "FAILED") {
      return result;
    }
  }
}

Serverless

No binary to bundle, so this works on any platform that can make HTTP requests:

Cloudflare Workers:

export default {
  async fetch(request, env) {
    const { url } = await request.json();

    const res = await fetch("https://renderio.dev/api/v1/ytdlp-download", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-API-KEY": env.RENDERIO_API_KEY,
      },
      body: JSON.stringify({ input_urls: { in_video: url } }),
    });

    const { command_id } = await res.json();
    return Response.json({ command_id });
  },
};

AWS Lambda:

export const handler = async (event) => {
  const { url } = JSON.parse(event.body);

  const res = await fetch("https://renderio.dev/api/v1/ytdlp-download", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-API-KEY": process.env.RENDERIO_API_KEY,
    },
    body: JSON.stringify({ input_urls: { in_video: url } }),
  });

  const data = await res.json();
  return { statusCode: 200, body: JSON.stringify(data) };
};

FAQ

Do I need to keep yt-dlp updated?

No. Binary updates, extraction fixes, and platform changes all happen on our end.

What if a download fails?

The poll response returns status: "FAILED" with an error field. Usually the video is private, the URL is wrong, or the platform rate-limited the request. For the last one, retry after a few seconds.

Can I submit multiple downloads at once?

Yes — fire off multiple requests in parallel, each gets its own command_id. Check X-RateLimit-Remaining in response headers before large batches.