DEV Community

Cover image for What I learned building synchronized YouTube playback for Moka-fi
Alef Felix
Alef Felix

Posted on AI-assisted

What I learned building synchronized YouTube playback for Moka-fi

I built Moka-fi as a cozy online café where friends can study, talk, play and spend time together. One of the newest rooms is Watch Together, where a group can watch a YouTube video while chatting or staying on a video call.

The interface looked simple on paper: a player, an Up Next queue and a chat. The synchronization behind it was not simple at all.

Watch Together room in Moka-fi with a synchronized player, Up Next queue and call panel

The player cannot be the source of truth

My first important decision was to separate the YouTube player from the shared state. The iframe is only a local view. The room owns the real state:

type WatchState = {
  videoId: string | null
  playing: boolean
  positionSeconds: number
  updatedAt: number | null
  hostId: string | null
  queue: WatchQueueItem[]
  history: WatchQueueItem[]
}
Enter fullscreen mode Exit fullscreen mode

The backend validates every mutation and persists this state with the room. Supabase Realtime delivers the updated room state to everyone connected. This means a late joiner can reconstruct the session from one snapshot instead of hoping they received every previous event.

Fast intent, reliable reconciliation

Waiting for a full write and database notification before responding to Play feels slow. Broadcasting every player tick is noisy and expensive. I ended up using two paths.

Playback intents such as play, pause and seek travel immediately through the private room channel. The interface reacts as soon as the intent arrives. The same action also goes through the API and becomes part of the authoritative room state.

The fast path makes the room feel responsive. The persisted path corrects missed events, reconnects and out of order updates. Neither path has to solve the entire problem alone.

Do not compare clocks across devices

A shared timestamp sounds useful until one laptop is a few seconds ahead of another. I saw viewers snap backwards because the expected position was calculated using clocks that did not agree.

The fix was to anchor incoming state to the local receipt time. When a client receives a position, it stores three values locally: the position, whether the video is playing and when that device received the update. From then on it extrapolates using only its own clock.

Conceptually:

expectedPosition = anchor.positionSeconds +
  (anchor.playing ? elapsedSinceLocalReceipt : 0)
Enter fullscreen mode Exit fullscreen mode

This removed an entire class of jumps without requiring clock synchronization between clients.

Small drift is better than constant correction

Perfect numerical equality is not the same as a good viewing experience. If every tiny difference triggers a seek, the video stutters and the player spends its time fighting network jitter.

Moka-fi currently tolerates up to 1.5 seconds of drift. A follower seeks only after crossing that limit. While playback continues, the controller sends a fresh anchor every 15 seconds so late joiners and drifting viewers converge.

Moka-fi Watch Together player showing the shared controller and synchronized playback controls

Those values are deliberately boring. They are easy to understand, easy to observe and good enough to make the room feel shared.

One controller keeps feedback loops under control

Only the current controller publishes player state changes. Followers mirror the shared state silently. Otherwise a forced pause on one viewer could echo back as a new command, which could trigger another update on every other viewer.

People can request control, the current controller can accept or decline, and control can be recovered when the previous host is no longer in the room. This turned out to be as much a product decision as a technical one. Everyone needs to understand who is driving.

The queue is part of the session

The Up Next queue is stored with the watch state, including who added each item. When a video ends, every viewer sees the same countdown, but only the controller advances the authoritative state. Played videos move into session history.

I also had to handle the less exciting cases: malformed links, videos that cannot be embedded, private or removed videos, age restrictions and autoplay being blocked by the browser. A synchronized player that gets stuck on one unavailable video is not really synchronized.

Responsive design changed the architecture of the screen

On wide screens, the queue can sit below the player while chat and people remain visible. On phones, keeping all of that on screen makes every area unusable. The mobile version keeps the player prominent and switches the lower shelf between the queue, chat and call.

The same state model powers both layouts. Only the presentation changes. That separation made it possible to refine mobile behavior without creating a second Watch Together implementation.

What I would keep if I built it again

I would keep the hybrid approach: immediate ephemeral intents for feel, persistent server validated state for recovery, and a modest drift tolerance instead of continuous correction.

The main lesson was that synchronized playback is not primarily about pressing Play at the same millisecond. It is about making several imperfect clients converge without allowing the corrections themselves to ruin the experience.

Watch Together is available inside Moka-fi. I am still refining the transitions, mobile experience and recovery paths, and I would genuinely like to hear how other developers have approached shared media state.

Transparency: I wrote this from my own implementation and experience, with AI assistance for structuring and editing the article.

Top comments (0)