I wanted to save X/Twitter videos on my phone, but there was a catch: X has no download button, and the download sites that fill that gap are ad-stuffed, break whenever X rotates its API auth, and you hand every tweet URL to a stranger. So I built my own: a self-hosted web app where you paste a tweet link and get a preview, a quality picker, and a Download button that saves the MP4 to your phone.
The whole thing is 463 lines of Node with zero npm dependencies, plus 224 lines of vanilla JS for the UI. It is open source (MIT):
X/Twitter Downloader (xdl)
Paste any x.com / twitter.com tweet link → preview → download. A small, self-hosted web app for saving videos (and GIFs/photos) from X. Mobile-first, works great on phones.
You host it yourself — there’s no hosted instance. It runs on your own server (or just your laptop), so it’s free, private, and no third-party download site is involved.
Features
- 📱 Mobile-first UI (dark theme, add-to-home-screen friendly)
- 🎬 Quality picker with file sizes (270p → 720p+); picks the best available
- 🎞️ Handles videos, GIFs, photo-only tweets, and mixed video+photo tweets
- 🔗 1080p HLS-only tweets are merged server-side via ffmpeg automatically
- ⚡ Direct CDN streaming for downloads (no big server-side buffering), inline preview plays straight from X’s CDN
- 🛡️ Per-IP rate limits, concurrency caps, CDN host allowlist (no SSRF)
- 🔌 Zero npm dependencies — only
node,ffmpeg,yt-dlp
How it works
- Extraction: yt-dlp is…
Two backends, because X breaks things
The server never talks to X’s private API itself. That is the part that breaks every few months, so I delegated it:
-
yt-dlp (primary). One call,
yt-dlp -J --no-playlist --skip-download <url>, returns every available format with file sizes. yt-dlp handles Twitter’s guest-token flow and rotating auth, and it keeps working when X changes things because it is actively maintained. Extraction takes 1 to 2 seconds. -
FxTwitter API (fallback).
api.fxtwitter.com/status/{id}, free, no auth, answers in 200 to 300 ms. It steps in when yt-dlp hits a guest-token rate limit, and it covers what yt-dlp does not return at all: photo-only tweets and GIFs.
If one backend dies tomorrow, the app degrades instead of dying with it.
The format puzzle
Twitter serves each video in several parallel formats. A real 15-second clip came back like this:
format_id kind resolutionhls-audio-*
HLS audio only
32/64/128 kbps
http-288, http-832, http-2176
progressive MP4
272p / 364p / 728p
hls-191, hls-343, hls-716
HLS video only
272p / 364p / 728p
Two useful facts fell out of staring at that list. Progressive MP4 (one file, video plus audio, plays anywhere) exists up to 720p for most tweets. Anything above 720p is HLS only, so for 1080p the server lets yt-dlp download and merge the streams with ffmpeg, waits for the temp file, streams it to the phone, and deletes it. The merge of a 15-second clip took 1.8 seconds.
Then the gotcha that cost me the most debugging time. yt-dlp’s JSON reports vcodec and acodec as null for exactly those progressive formats, not as h264/aac. My first classifier dropped anything without a known codec, which silently threw away every good single-file MP4 and sent every request to the fallback. The fix is to classify by protocol instead of trusting the codec fields:
// kind: 0=mix, 1=video only, 2=audio only
function classifyFormat(f) {
const a = f.acodec && f.acodec !== 'none';
if (f.vcodec === 'none') return 2;
if (a || String(f.protocol).startsWith('http')) return 0; // progressive single-file = mix even when codecs unknown
return 1;
}
Enter fullscreen mode Exit fullscreen mode
protocol: "https" means a progressive single file. vcodec === 'none' (the string, not null) means audio only. Everything else is an HLS video track that needs a merge.
The phone part
The preview costs the server nothing. X’s CDN (video.twimg.com) serves videos with access-control-allow-origin: * and plain GET, no cookies, so the <video> element points straight at the CDN through a 302 redirect. Playback bandwidth: zero.
The download is the opposite: it streams through the server on purpose, with Content-Disposition: attachment and a filename built from the tweet (author - title (720p).mp4). That header is what makes iOS and Android reliably offer “save to Files” instead of opening the video in a tab. Node pipes the CDN response through with the original Content-Length, nothing is buffered in RAM.
The UI is one index.html: dark theme, big touch targets, quality pills with file sizes (“720p · 4.0 MB”), and the meta tags for add-to-home-screen, so on the phone it launches fullscreen like an app.
Internet-facing means paranoid
A downloader is a proxy, and a naive proxy is an SSRF hole. Three guards, all in those 463 lines:
- The server only fetches URLs whose host, re-parsed server-side, is
video.twimg.comorpbs.twimg.com, https only. The client cannot point it anywhere else. - Per-IP rate limits: 6 extracts and 10 downloads per minute, in-memory sliding window.
- Concurrency caps: 1 extraction and 2 downloads at a time. It is a personal tool on a small VPS, not a public service.
Run it yourself
The repo ships a generic deploy.sh: VPS_HOST=user@server ./deploy.sh rsyncs the app, creates a Python venv for yt-dlp, and installs a systemd unit. The venv matters: Ubuntu’s apt version of yt-dlp is months old, and with this tool old means broken.
My own instance never touches the public internet. The DNS record points at a Tailscale address, the reverse proxy binds to the tailnet interface, and the firewall drops everything else. HTTPS still works with zero open ports, because Let’s Encrypt supports the DNS-01 challenge: the cert is issued through a DNS API token, no port 80 required. My phone runs Tailscale, so the app works from anywhere and is still invisible to everyone else.
One note on open-sourcing a personal tool: before the repo went public I found my own server details in three places (the deploy script, the README, and one commit message). The first two were rewritten; for the third the history had to be squashed into a single clean commit, because a secret in an old commit is still a published secret.
Wrapping up
The key insight: don’t fight X’s API yourself. Let yt-dlp absorb the auth churn, keep a free second backend for the day it hiccups, and spend your own lines on the parts that make it yours: the streaming, the filenames, the SSRF guard, and a UI your thumb likes. Mine came to 463 lines, and there is not a single ad on it.