Slonke
Slonke · blog
← all posts

Why your watch party is out of sync: the engineering behind drift

July 5, 2026 · Slonke

If you landed here because your watch party is out of sync right now: pause the video, wait two seconds, press play. Every sync tool converges on an explicit action. If you want to know why it drifted in the first place, keep reading, because the answer is more interesting than "bad internet."

Two players, two pipelines

A <video> element's currentTime advances only while the decode pipeline has frames to emit. Your player and your friend's player are fed by different CDN edge nodes, over different last-mile connections, through different adaptive bitrate ladders. Netflix might serve you 4.5 Mbps from an edge node inside your ISP's network while your friend pulls 1.8 Mbps from a node two networks away.

The moment one player rebuffers for 2 seconds and the other doesn't, you have a 2 second offset that nothing will fix on its own. A stalled player resumes exactly where it stalled; playback never catches up by itself. Drift is not noise around zero. It accumulates, which means it's a state divergence problem, and the network only supplies the trigger.

Quieter contributors stack on top. Seeks snap to keyframes, so "seek to 612.000" lands at 611.6 on one machine and 612.3 on the other. Browsers throttle timers in backgrounded tabs. Some platforms inject ads on one side of the party and not the other. None of these is large on its own, and all of them are cumulative.

The architectures that don't work

Event forwarding is what everyone builds first: when the host plays, pauses or seeks, forward that event and have both players execute it. This synchronizes intent and misses state entirely. Buffering fires no user event, so when player A stalls for 2 seconds, nothing crosses the wire and the states diverge in silence. After an hour of a movie, the two sides agree perfectly on what the user did and disagree by several seconds on where the playhead is.

The shared clock model says position equals start time plus elapsed wall time, with everyone synced to server time. Its core assumption, that playback advances at exactly 1 second per second, is precisely the assumption rebuffering violates. As a bonus it imports every clock skew problem NTP exists to manage, and now those live between you and the movie.

Then there's timestamp polling, typically every 500ms: each client reports currentTime and whoever disagrees gets seeked. The error signal in this loop is positions sampled at different moments, transported over variable latency, so it carries jitter on the order of your RTT. Correct on that noise and you seek constantly. Each seek can trigger a rebuffer, each rebuffer creates genuine drift, and the loop feeds itself. Users experience the result as rubber-banding, the playhead stuttering backward every few seconds while the tool insists it is helping.

The hidden variable: DRM players won't be scripted

On a plain HTML5 video, an extension sets currentTime and the element obeys. On Netflix it doesn't. The stream runs through EME and MSE inside Netflix's own player (the same DRM stack that gives screen-sharers black screens), and that player treats direct writes to the media element as noise. It reverts them, because its internal state machine recorded no seek.

The only reliable way to seek Netflix is to ask Netflix, which means injecting a script into the page context and driving their internal player API: get the video player session, call seek(ms) on it. That works, and it ships its own failure modes. Player sessions go stale after an episode change, and a seek issued against a stale session is a silent no-op, so a sync engine that cached the wrong session ID will issue corrections forever while the video ignores every one of them. Right after a bridged seek, the reported currentTime is coarse, so a tolerance tuned for YouTube will read the seek you just performed as fresh drift and correct again. Uniform treatment of platforms guarantees oscillation on exactly the platforms people most want to watch together.

An architecture that holds

Slonke (our extension, and the case study I can speak to honestly) separates the planes. Voice and video are the fat flows, so they run peer-to-peer over WebRTC with TURN fallback, and the server never touches media. Sync is the opposite kind of traffic, tiny and rare, tens of bytes per user action, so it runs through a dumb WebSocket relay holding rooms in memory. That is a deliberate decision against putting sync on a WebRTC data channel: the control plane must not die because ICE negotiation failed or a TURN quota ran out. A 40-byte JSON message does not need peer-to-peer transport. It needs ordering.

Ordering is the relay's one real job. Every explicit action (play, pause, seek, rate change) receives a server-assigned, strictly increasing revision number. Clients apply only strictly newer revisions and drop the rest, which kills echoes and settles races deterministically: two people grabbing the scrubber at once resolves to last-action-wins on both sides, no tiebreak logic on the clients. The server acks the sender with the assigned revision, so both clients converge on the same applied revision without ever comparing clocks.

One explicit action gets a total order via the relay. Heartbeats handle passive drift, and go silent during rebuffering.
One explicit action gets a total order via the relay. Heartbeats handle passive drift, and go silent during rebuffering.

Explicit actions form the skeleton; passive drift is handled by a symmetric heartbeat. Every few seconds each side reports position and play state without bumping the revision, and a receiver corrects only when the gap exceeds a per-platform tolerance: tight where currentTime is trustworthy, wide on bridged-seek platforms like Netflix. Corrections are throttled after any seek, so the loop cannot chase its own settling time. There is no fixed host in this scheme, either side corrects, which also means the party survives the "host's laptop went to sleep" failure that kills leader-based designs.

The rule that matters most is buffer health awareness: heartbeats are suppressed while a player is buffering or held. A stalled player reporting its frozen position would drag the healthy player backward to match, which is the wrong direction, since the stalled side resumes and closes the gap on its own. Tools that skip this rule produce the familiar symptom where one person's bad Wi-Fi degrades playback for everyone in the room.

Frame-perfect sync turns out to be an architecture property, not a bandwidth property: a total order for intent, a jitter-tolerant loop for state, per-platform adapters for players that refuse to be scripted, and the restraint to not correct while the ground is moving.

engineeringwebrtcsync
© 2026 Slonke · slonke.com · RSS