CASE STUDY

Synchronized Watch Party (LLD)

3 min read·469 words·Intermediate

Asked at

1 candidate report in Jun 2026

How to use this case study

SDE-2 / Mid

Design classes for a party session, participants and playback state, and how play/pause/seek from one user reaches everyone.

SDE-3 / Senior

Keep clients in sync despite network delay (server timestamps, drift correction), handle late joiners and conflicting actions, and choose host-only vs anyone controls.

Staff / Principal

Discuss scaling many parties (session servers, WebSockets), reconnects, chat alongside video, and testing timing behavior.


0) Problem Restatement

Salesforce asked a low-level, object-oriented design: build a watch party, where several people watch the same video together. When someone (the host, or anyone, depending on the rules) plays, pauses or seeks, everyone's player follows. People who join late start at the current position. There's chat alongside. The difficulty is keeping everyone in sync despite network delays and slightly different device clocks.


1) Core Idea: Shared Playback State

The server keeps the authoritative playback state for each party:

PlaybackState { video_id, status: PLAYING|PAUSED, position_at_ref (seconds), ref_server_time, rate (1.0), version }

The current position when playing = position_at_ref + (server_now − ref_server_time) × rate. When paused, it's just position_at_ref. So we don't need to broadcast the position every second: clients can compute it from this state and the server time.


2) Classes

Architecture Diagram

classDiagram
    class PartyService {
        +createParty(hostId, videoId) Party
        +join(partyId, userId) Snapshot
        +handleCommand(partyId, userId, cmd) void
    }
    class Party {
        +String id
        +String hostId
        +ControlPolicy policy
        +PlaybackState state
        +List participants
        +apply(cmd, serverNow) PlaybackState
        +broadcast(event) void
    }
    class PlaybackState {
        +Status status
        +double positionAtRef
        +long refServerTime
        +double rate
        +int version
        +currentPosition(now) double
    }
    class Participant { +String userId +Connection conn +boolean isHost }
    class ControlPolicy { <<interface>> +canControl(participant, cmd) bool }
    class HostOnlyPolicy
    class EveryonePolicy
    PartyService --> Party
    Party --> PlaybackState
    Party --> Participant
    Party --> ControlPolicy
    ControlPolicy <|.. HostOnlyPolicy
    ControlPolicy <|.. EveryonePolicy

3) Handling a Command

import time

class PlaybackState:
    def __init__(self):
        self.playing, self.pos, self.ref, self.rate, self.version = False, 0.0, time.time(), 1.0, 0
    def current(self, now):
        return self.pos + (now - self.ref) * self.rate if self.playing else self.pos

class Party:
    def __init__(self, host, policy):
        self.host, self.policy, self.state, self.members = host, policy, PlaybackState(), {}
    def apply(self, user, cmd, arg=None, client_version=None):
        if not self.policy(user, self.host, cmd):
            raise PermissionError("not allowed")
        if client_version is not None and client_version != self.state.version:
            return self.state                       # stale action (someone acted first): client resyncs
        now, s = time.time(), self.state
        s.pos, s.ref = s.current(now), now          # freeze the current position at this server time
        if cmd == "play":  s.playing = True
        elif cmd == "pause": s.playing = False
        elif cmd == "seek":  s.pos = max(0.0, float(arg))
        s.version += 1
        self.broadcast({"type": "state", "playing": s.playing, "pos": s.pos, "ref": s.ref,
                        "rate": s.rate, "version": s.version})
        return s
    def broadcast(self, event):
        for conn in self.members.values():
            conn.send(event)

host_only = lambda user, host, cmd: user == host
  • Ordering: the server applies commands one at a time per party (a single owner per party), so there's no race. Two simultaneous seeks → the first one wins, the second is stale (its version is older) and that client resyncs.
  • The version lets clients ignore old or out-of-order messages.


4) Staying in Sync on Clients

  • Clock offset: each client estimates server_time − local_time with a few ping exchanges (NTP-style, halving the round-trip time).
  • On a state message, the client computes the target position = pos + (server_now_estimate − ref) × rate, and seeks there if it's more than ~0.5 s off.
  • Drift correction: every few seconds, compare the local position with the computed target. For small differences, adjust the playback rate slightly (e.g., 0.95x–1.05x) instead of jumping. For big ones, seek.
  • Buffering: if one client is buffering, options are "wait for everyone" (pause the party) or "let them catch up". This is a policy choice.
  • Late joiners: join() returns a snapshot (the state + chat history), and the client seeks to the computed position.


5) Scaling and Reliability

  • Parties live on session servers. All members of a party connect (WebSocket) to the server that owns it (routing by party ID). State is also written to Redis so another server can take over after a crash.
  • Reconnect: the client rejoins, gets the latest snapshot, and resyncs.
  • Chat uses the same connection (messages with timestamps).


6) Wrap-Up

Keep one authoritative playback state per party on the server, {status, position at a reference server time, rate, version}, from which anyone can compute the current position. Apply play, pause and seek sequentially per party through a pluggable control policy (host-only or everyone), bump the version, and broadcast. Clients estimate their clock offset, seek or gently adjust playback rate to stay within tolerance, late joiners start from a snapshot, and session servers own parties with Redis-backed state for failover.

More Case Studies

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with our AI interviewer.

Start System Design Interview →