All projects

Case study · Live

Watch Together

Cross-site video sync as a browser extension

Role

Solo — design, server, extension, deploy

Period

Apr 2026 · shipping on Chrome Web Store

The Pitch

One extension, any streaming site, any number of devices, anywhere on Earth — sub-second playback sync over WebSocket.

The Problem

Watching a movie "together" over a call has always been broken. Someone says "play in 3, 2, 1" and within thirty seconds you're four seconds out of sync arguing about whether the next line was "I am your father" or "no, I am."

On paper the spec is one sentence: when one person plays, pauses, or seeks, do the same on every other browser. In practice, every word in that sentence is a trap. "Plays" depends on which streaming site — Netflix's player exposes nothing useful, YouTube has ads, JioHotstar reloads its video element on quality changes. "Seeks" fights with browser autoplay policies. "Every other browser" has to survive Chrome killing your service worker every 30 seconds.

The Approach

Three pieces

A WebSocket relay server on Render, a Manifest V3 extension with site-specific player adapters, and an injected in-player overlay that lives inside YouTube / Netflix / Disney+ controls so users never have to leave the video.

All state is in memory on the server — rooms are ephemeral with a 12-hour TTL. One Node.js process handles the whole thing.

The heartbeat drift problem

Sync events handle play / pause / seek, but they don't catch drift. Two browsers playing the "same" frame will diverge — different decoder pipelines, different buffer pressure, occasional dropped frames. Without correction, two viewers end up 2–3 seconds apart by the end of a 90-minute movie.

Solution: a 5-second heartbeat carrying the current playback position. Clients nudge themselves if they're more than 0.5 s off. The naive implementation has a fatal flaw — if every member broadcasts heartbeats, you get N² messages and the room melts at scale. So the server elects a single heartbeat leader per room. When the leader leaves, leadership transfers to the next member.

The other gotcha: heartbeats fight with sync events. If Alice seeks to 0:30 and Bob's heartbeat from 0:25 arrives 200 ms later, Bob yanks everyone back to 0:25. Fix: a 2-second cooldown on heartbeats after every sync event. Simple rule, eliminated an entire class of sync ping-pong bugs.

Surviving Manifest V3

MV3 service workers can be killed after 30 seconds of inactivity. Mid-movie, that's a disaster. The fix is paranoid state restoration: current room and user ID are mirrored to chrome.storage.local on every change. When the worker wakes back up, it reads storage, reconnects the WebSocket with exponential backoff, and rejoins. From the user's perspective, nothing happened.

Port management was another MV3 pitfall. Each tab connects multiple ports to the worker (one for the content script, one for the overlay). Naive keying by port name causes collisions across tabs. The fix: key ports by `tabId:portName`.

Site-specific player adapters

Every streaming site lies to you in a different way. Each site gets its own adapter module. The YouTube adapter watches for the `.ad-showing` class and pauses sync during ads. The JioHotstar adapter handles their habit of replacing the video element on quality changes — we re-attach listeners every time. Netflix needed custom play / pause buttons because their native API isn't exposed to extensions.

Key Decisions

WebSocket relay, not WebRTC

Built a WebRTC prototype first and killed it. For a few-bytes-per-event payload, peer-to-peer's latency advantage is meaningless — and WebRTC fails behind corporate firewalls and certain mobile carriers. A single TCP connection per client works behind every firewall, and lets the server enforce rules (host mode, rate limits, TTL) that you can't enforce in a P2P mesh.

Host-mode enforcement server-side, not client

Clients can't be trusted. Sync messages from non-hosts are rejected at the relay. When a host leaves, ownership transfers to the next member and mode switches back to "everyone."

Share links with query-param auto-join

`youtube.com/watch?v=xyz&wt_room=ABC123` — click and you're in. YouTube strips unknown params within milliseconds, so `auto-join-extract.js` runs at `document_start`, captures `wt_room`, writes to storage, and cleans the URL. Then `content.js` at `document_idle` reads and connects.

Metrics

Platforms

Chrome · Firefox · Safari

Sync drift

< 0.5s

heartbeat correction

Server tests

59

vitest

E2E tests

Puppeteer

two-tab sync + auto-join

Per-IP limits

10 / 20 msg·s⁻¹

abuse defence

Room TTL

12 hours

auto-cleanup

Stack

Server

Node.jsws (WebSocket)DockerRender

Extension

Chrome Manifest V3Service WorkerContent Scripts

Testing

Vitest (59 server tests)Puppeteer (browser e2e)

Sites supported

NetflixYouTubeDisney+Prime VideoJioHotstarHBO Maxany HTML5 video