Every developer has a tool they wish existed. Mine was a video downloader that wasn't a bloated Electron app, didn't require a terminal setup ritual, and didn't look like it was designed in 2008. So I built it.
YT Downloader is a desktop app built with Tauri 2, Rust, and React. It wraps yt-dlp — the gold standard for video downloading — in a clean native interface. Support for YouTube, Vimeo, Instagram, Twitter/X, and 1000+ other sites. Video up to 8K. Audio in MP3, FLAC, Opus, and WAV. SponsorBlock integration. Concurrent downloads with a real queue. A hand-drawn sketch aesthetic because why not.
This post isn't a tutorial on how to use it. It's about the engineering decisions and bugs I had to fight through to ship something that actually feels like a production app.
Why Tauri Instead of Electron
The honest reason is bundle size. An Electron app ships an entire Chromium browser — your "desktop app" is a 150MB zip file for the installer alone. Tauri uses the operating system's native WebView, which means the same app ships as a 4MB installer.
The tradeoff: you write a real Rust backend. The IPC boundary between JavaScript and the OS is typed and explicit. There's no require('fs') — every system call goes through a Tauri command handler. It's more work, but the result is an app that launches in under a second and uses roughly 30MB of RAM at idle. For a utility tool you open and close dozens of times a day, that matters.
The Stack
The backend is Rust with tokio for async, rusqlite (bundled SQLite) for history and settings persistence, reqwest for HTTP, and the Windows Job Objects API for process management. The frontend is React 18 with TypeScript, Zustand for state, MUI v5 for components, and Framer Motion for animations.
yt-dlp and ffmpeg are the actual download engines. The app manages them — spawning, monitoring, killing — rather than simply calling them and hoping for the best.
Problem 1: Making the App Work Without Any Prerequisites
Most users will never run pip install yt-dlp before opening a desktop app. They'll just double-click and expect it to work. If it doesn't, they'll close it and move on.
The solution was to make the app download its own tools on first run. On startup, the Rust backend checks for yt-dlp and ffmpeg in a local app data directory. If they're missing, a setup screen appears and streams the downloads directly from GitHub Releases — with a real progress bar, not a spinner.
#[tauri::command]
pub async fn download_ytdlp(app_handle: AppHandle) -> Result<String, String> {
let url = if cfg!(windows) {
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe"
} else if cfg!(target_os = "macos") {
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos"
} else {
"https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp"
};
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
file.write_all(&chunk)?;
emit_progress(&app_handle, "yt-dlp", pct, "downloading", &msg);
}
}
There was a subtle bug in the initial version: it only downloaded ffmpeg.exe. SponsorBlock segment removal also needs ffprobe to read video durations. Without it, cutting sponsors would silently fail. The fix was a single check at startup:
// A partial install breaks SponsorBlock. Treat it as needing full setup.
let partial_ffmpeg = ffmpeg.is_some() && ffprobe.is_none();
let needs_setup = ytdlp.is_none() || partial_ffmpeg;
Now if ffprobe is missing, the setup wizard re-runs and grabs both binaries together.
Problem 2: Killing a Child Process on Windows Is Harder Than It Sounds
When a user pauses or cancels a download, you need to stop yt-dlp. That seems straightforward — call .kill() on the child process. The problem is that yt-dlp spawns ffmpeg as its own child for merging video and audio streams. When you kill yt-dlp, ffmpeg keeps running in the background, holding file locks. The next download attempt fails with a cryptic "file in use" error.
On Linux and macOS, the fix is simple — send SIGKILL to the entire process group:
#[cfg(not(windows))]
fn kill_process_tree(pid: Option<u32>, _job: Option<()>) {
use nix::sys::signal::{killpg, Signal};
let _ = killpg(Pid::from_raw(pid as i32), Signal::SIGKILL);
}
Windows doesn't have Unix process groups. The tempting shortcut is taskkill /F /T, but that spawns yet another process — there's a race window where ffmpeg can escape the kill. The correct OS-level tool is a Windows Job Object.
#[cfg(windows)]
fn create_job_object_for_pid(pid: Option<u32>) -> Option<HANDLE> {
unsafe {
let job = CreateJobObjectW(None, PCWSTR::null())?;
// If our process dies unexpectedly, the OS kills the tree anyway
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(job, ...);
// Must happen immediately after spawn, before yt-dlp forks ffmpeg
AssignProcessToJobObject(job, proc_handle)?;
Some(job)
}
}
The critical detail: AssignProcessToJobObject must be called immediately after spawning yt-dlp, before it has a chance to fork ffmpeg. Any process spawned after the parent is assigned to a Job Object is automatically included. Calling TerminateJobObject then kills the entire tree atomically — no race, no orphaned processes.
Problem 3: A Race Condition Hidden Inside the Pause Feature
This was the most embarrassing bug to track down, because it only appeared under a specific timing condition.
The symptom: press Pause, the UI shows "Paused", press Resume, and... nothing happens. The download sits there forever.
The root cause was a 100ms poll loop checking a shared AtomicBool flag. When pause was triggered:
- The UI optimistically set the job status to
Paused. - The poll loop woke up on the next 100ms tick, killed the process, and the tokio task returned.
- The task cleanup removed the job handle from
active_handles.
But the resume handler called advance_queue() immediately, while step 3 was still running in the background. The handle hadn't been removed yet. advance_queue saw no free slots and left the job stuck in Queued forever.
The fix had two parts. First, replace the poll loop with a tokio::watch channel and tokio::select!, which reacts to signals immediately rather than waiting for a tick:
let control_watcher = tokio::spawn(async move {
loop {
watcher_rx.changed().await?; // wakes immediately on signal
match *watcher_rx.borrow() {
ControlSignal::Pause => {
kill_process_tree(child_pid, job_handle);
// Emit AFTER the process is dead — no race with progress events
app.emit("download://paused", PausedEventPayload { job_id });
break;
}
ControlSignal::Cancel => {
kill_process_tree(child_pid, job_handle);
cleanup_temp_files(&output_dir);
app.emit("download://cancelled", CancelledEventPayload { job_id });
break;
}
ControlSignal::Run => continue,
}
}
});
Second, the resume handler now takes the lingering task handle out of the map and waits for it in a background task — never holding the manager mutex across an await:
pub async fn resume_download(&mut self, job_id: &str, ...) -> Result<()> {
job.status = JobStatus::Queued;
let lingering_handle = self.active_handles.remove(job_id); // take it out
tokio::spawn(async move {
if let Some(handle) = lingering_handle {
// Wait for old task to finish cleanup (max 3 seconds)
let _ = tokio::time::timeout(Duration::from_secs(3), handle).await;
}
// Slot is now guaranteed free
let mut mgr = self_arc.lock().await;
mgr.advance_queue(app_handle, db, self_arc.clone());
});
Ok(())
}
Problem 4: --continue Causes ffmpeg to Hang at 99%
yt-dlp has a --continue flag designed to resume partial downloads. It sounds like exactly what a pause/resume feature should use. It isn't.
When a partial file is in an inconsistent state — network dropped mid-stream, user forced-closed the app — resuming with --continue hands ffmpeg a corrupt input. ffmpeg dutifully tries to process it, stalls, and never exits. The progress bar sits frozen at 99% indefinitely. This was the "long video stuck on a purple bar" problem mentioned in the README.
The counterintuitive fix: always restart downloads from scratch. Pass --no-part to prevent yt-dlp from writing .part files entirely, accept that pausing means a full restart, and in exchange get a merge step that is 100% reliable every time.
cmd_args.extend([
"--no-part", // no partial files = no corrupt partial files
"--retries", "5",
"--fragment-retries", "5",
// --continue intentionally absent
]);
Problem 5: Keeping the UI and Backend in Sync
The frontend subscribes to a stream of Tauri events: download://progress, download://paused, download://complete, and so on. This creates a classic distributed state problem. When a user pauses a download:
- The UI does an optimistic update and marks the job "Paused."
- A progress event that was already in-flight arrives from the Rust backend.
- The progress event overwrites the status back to "Downloading."
- The actual
download://pausedevent arrives and fixes it.
The UI flickered. Users thought the pause hadn't registered.
The fix was a queue://updated event — a canonical snapshot of all job states emitted from the backend after every state change:
fn emit_queue_updated(&self, app_handle: &AppHandle) {
let payload = QueueUpdatedPayload {
active_count: self.active_handles.len(),
jobs: self.jobs.values().map(|j| QueueJobDto {
job_id: j.job_id.clone(),
status: j.status.as_str().to_string(),
progress: j.progress,
// ...
}).collect(),
};
app_handle.emit("queue://updated", payload);
}
The frontend treats queue://updated as the authoritative source of truth for job statuses. Individual progress events update the progress bar, but status fields are only updated by the canonical snapshot. Since the snapshot is emitted after the process is confirmed dead, a stale in-flight progress event can never win a race against it.
A Few Other Things Worth Mentioning
Cancel vs. Pause cleanup: When a download is cancelled, yt-dlp's intermediate stream files (Video.f137.mp4, Video.f140.m4a) are deleted. On pause, they're left alone. The cleanup function uses a pattern match to identify stream fragments:
fn is_ytdlp_stream_fragment(path: &Path) -> bool {
// Matches "Title.f137.mp4" — stem ends with ".fNNN"
let stem = path.file_stem()?.to_str()?;
if let Some(dot_pos) = stem.rfind('.') {
let suffix = &stem[dot_pos + 1..];
return suffix.starts_with('f') && suffix[1..].chars().all(|c| c.is_ascii_digit());
}
false
}
Cookie handling on Windows: Chrome and Edge use App-Bound Encryption to protect cookies, which means yt-dlp's --cookies-from-browser chrome flag often fails on Windows. The app supports a cookies.txt fallback that users can export from a browser extension. The file-based path sidesteps DPAPI entirely and works universally across sites.
Security: URLs are sanitized before writing to log files. Download paths are validated against a base directory to prevent traversal. Job IDs are validated against a UUID pattern in every command handler that accepts one. yt-dlp is always spawned with an explicit argument array — never via shell interpolation.
What This Taught Me
Writing a desktop app with Tauri is rewarding, but the hard problems aren't in the Tauri API itself. They're in the gaps between layers: between the OS process model and your async runtime, between your backend state and your frontend state, between what a flag's documentation says and what it actually does in edge cases.
The biggest lesson: poll loops are almost always the wrong tool for reactive signaling. Every bug that started with "it works 90% of the time" in this project traced back to a poll loop with a tick delay. tokio::watch with tokio::select! is not significantly harder to write and eliminates an entire class of timing bugs.
Try It
The full source is on GitHub at github.com/Mrtracker-new/YT_Download.
If you have Node 18+ and Rust installed, getting it running takes about a minute:
git clone https://github.com/Mrtracker-new/YT_Download.git
cd YT_Download
npm install
npm run dev
yt-dlp and ffmpeg auto-download on first run. No prerequisites beyond the two build tools.
The project has had some interesting engineering decisions baked into it. If any of the problems above are familiar from your own work, or if you've solved them differently, I'd be curious to hear about it in the comments.