DEV Community

Cover image for How I Built a Desktop Movie Streamer with Electron and WebTorrent
Qazi Absaar
Qazi Absaar

Posted on

How I Built a Desktop Movie Streamer with Electron and WebTorrent

I built CinePeer, a desktop streaming app: a Prime Video-style interface where you browse with TMDB metadata, click play, and the video starts while the torrent is still downloading.

The UI took a weekend. The part that took actual thinking was this:

How do you make <video> play a file that doesn't exist yet?

The problem

An HTML5 video element needs an HTTP URL. It seeks by sending Range headers — "give me bytes 1048576–2097151". A normal file server reads those bytes from disk and returns a 206 Partial Content.

But the bytes of a torrented video don't live on disk yet. They arrive, out of order, from peers. WebTorrent gives you a File object with a createReadStream({ start, end }) — so the question becomes: how do I bridge HTTP range requests to that stream?

The answer is a tiny local HTTP server per torrent.

The bridge: an HTTP server per torrent

When the user hits play, the Electron main process does three things:

  1. Adds the magnet URI to a WebTorrent client.
  2. Picks the largest file in the torrent (the video).
  3. Spins up an http.createServer on a random port that answers range requests against that file.
const file = torrent.files.reduce((a, b) => a.length > b.length ? a : b)

const server = http.createServer((req, res) => {
  const range = req.headers.range
  const fileSize = file.length

  if (range) {
    const parts = range.replace(/bytes=/, '').split('-')
    const start = parseInt(parts[0], 10)
    const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1

    res.writeHead(206, {
      'Content-Range': `bytes ${start}-${end}/${fileSize}`,
      'Accept-Ranges': 'bytes',
      'Content-Length': end - start + 1,
      'Content-Type': 'video/mp4',
    })

    file.createReadStream({ start, end }).pipe(res)
  } else {
    res.writeHead(200, {
      'Content-Length': fileSize,
      'Content-Type': 'video/mp4',
    })

    file.createReadStream().pipe(res)
  }
})

server.listen(0, () => {
  const port = server.address().port
  // hand `http://localhost:${port}` to the renderer
})

Enter fullscreen mode Exit fullscreen mode


markdown
That's the whole trick. file.createReadStream blocks until the requested pieces arrive, so the video player thinks it's talking to a regular — if occasionally slow — file server. Seeking works, buffering works, and WebTorrent prioritizes the pieces the player is asking for.

The renderer's <video src="http://localhost:35217"> never knows a torrent exists.

Why the server lives in the main process

Browsers can't run a standard torrent client. Running WebTorrent directly in the renderer would restrict it to WebRTC peers only (no TCP/UDP), meaning most of the swarm would be unreachable.

Because of this, the architecture splits:

┌──────────────────────┐   IPC   ┌──────────────────────────┐
│   RENDERER           │ ◄─────► │   MAIN PROCESS           │
│   React + Vite       │         │   Node.js + WebTorrent   │
│   UI, TMDB calls     │         │   Torrent engine         │
│   <video> player     │         │   Local HTTP stream srv  │
└──────────────────────┘         └──────────────────────────┘

Enter fullscreen mode Exit fullscreen mode

The renderer asks the main process to add a torrent over IPC (exposed through contextBridge), gets back { streamUrl, infoHash }, and points the player at streamUrl. Progress polls — torrent.progress, downloadSpeed, numPeers — come back over the same IPC channel and feed the download manager UI.

Gotcha: WebTorrent is ESM-only

Electron's main process still uses CommonJS by default, and WebTorrent ships as pure ESM. A standard require('webtorrent') fails. The fix is a lazy dynamic import:

let WebTorrent = null

async function getWebTorrent() {
  if (!WebTorrent) {
    WebTorrent = (await import('webtorrent')).default
  }
  return WebTorrent
}

Enter fullscreen mode Exit fullscreen mode

Lazy loading matters here — it keeps the import out of the app's startup path, ensuring the window paints before the torrent engine initializes.

What I'd do differently

  • Sequential piece priority is doing heavy lifting. Default torrent behavior downloads rarest-first, which is wrong for streaming — you want the next pieces. WebTorrent's stream API handles this when you read sequentially, but explicitly mapping torrent.select() ranges would give the player better lookahead when scrubbing.
  • One server per torrent leaks. A Map keyed by infoHash with an explicit destroyTorrent() cleanup is necessary. Forget that, and ports pile up across a long session.
  • The 30-second connection timeout on client.add matters more than it looks. Dead magnets with zero seeders will hang the UI promise forever if unhandled. Always time out torrent connections.

Try it

git clone [https://github.com/QaziAbsaar/CinePeer.git](https://github.com/QaziAbsaar/CinePeer.git)
cd CinePeer
npm install
npm run dev

Enter fullscreen mode Exit fullscreen mode

You'll need a free TMDB API key for metadata — the app walks you through it on first launch.

The project is open source (MIT) and built for learning. The streaming bridge pattern (local HTTP server + range requests over an in-progress download) applies to any progressive-download source, not just torrents.


I'm Qazi Absaar — AI/ML Engineer & IoT Developer. I build AI agents, RAG pipelines, and edge AI on Jetson and ESP32. More at qaziabsaar.dev.

Top comments (0)