← Back to Projects

leptons1618@anishgiri.dev:~/case-studies/streamfusion less writeup.md

Full-Stack2023 September 16, 2023 2 min read

StreamFusion — Two Transports, Because Sync and Signalling Are Different Problems

Peer-to-peer data channels for playback ticks, one WebSocket for everything the room has to agree on centrally.

published
September 16, 2023
read
2 min
stack
7 tools
outcomes
6
media
—

01The Problem

Watch-party apps fail quickly when playback drift accumulates across devices, leading to broken group experiences and fragmented session state.

02The Solution

Built a room-based sync architecture using WebRTC data channels and WebSocket signaling, with reconciliation logic for playback state, queue coordination, and chat delivery.

What a watch party actually has to solve

Drop four people into a room and start a film, and the only interesting problem is that their clocks disagree. One device buffers ahead, another throttles, a third seeks when it reconnects — and after ten minutes the group is watching three different cuts of the same film and quietly resentful.

Everything else — the queue, the chat, the room code — is ordinary. Playback agreement is the product.

Two transports, on purpose

The design splits latency-sensitive traffic from everything else:

  • WebRTC data channels, via simple-peer, carry the sync events. They are peer-to-peer, so a sync tick does not have to make a round trip through the server, and the fan-out cost does not grow with the size of the room.
  • A WebSocket handles signalling, room membership and negotiation — the things that have to be centrally known.

The signal server is deliberately boring: ws, express, cors, and a RoomManager in server/src/rooms.ts. The WebSocket.Server is created with noServer: true and upgraded through the Express HTTP server, which is how you share one port between the two.

Rooms are three maps

private rooms        = new Map<string, Room>()   // id   -> room
private codeToRoomId = new Map<string, string>() // code -> id
private peerToRoom   = new Map<string, string>() // peer -> id

A Room carries an id, a short join code, a host, a peers map, and two timestamps — createdAt and lastActivity. The second one is the field that matters operationally: idle rooms are the difference between a long-running server and a slow leak, and it is what you would sort on when you need to work out why a room will not reconnect.

Each RoomPeer holds an id, a nickname, its socket and a join time, so a session can be reconstructed after a drop rather than restarted from nothing.

Client state is Zustand, and that is the point

The Expo client keeps playback position, the shared queue and room membership in a Zustand store rather than in component state. Synchronised playback is a distributed-systems problem wearing a video-player costume; the moment two components need to agree about the current position, per-component state stops being an option.

The repository also carries a Next.js app/ surface alongside the native client, with expo-video, expo-sqlite and react-native-video handling playback and local persistence per platform.

The honest gaps

The README describes a tree that is not the tree. It documents src/services/WebRTC, src/store/ and an app/room/[id].tsx screen; what is in the repository is a Next.js app/ plus server/src/. The Expo dependencies are real and present in package.json, so the intent is real too — but treat that file listing as a plan rather than a map.

Drift correction is the load-bearing feature and it is the least visible one. “Periodic sync reconciliation” is a single line of prose here. In a watch party, whether the client re-syncs on a drift threshold or merely on a timer is the difference between a group that stays together and one that gives up and restarts. It deserves a real explanation and a real test, and it currently has neither.

03Achievements

  1. WebRTC data channels carry latency-sensitive sync events peer-to-peer, so a tick never has to round-trip through the server.
  2. A `ws` signal server shares a single Express port via `noServer: true`, rather than opening a second listener.
  3. `RoomManager` indexes rooms three ways — by id, by short join code and by peer — so membership is a map read in every direction.
  4. Every room tracks `lastActivity` alongside `createdAt`, which is the field an idle-room reaper needs.
  5. A `RoomPeer` retains its socket and join time, so a dropped session can be reconstructed rather than restarted.
  6. Zustand holds playback position, shared queue and membership in a single store instead of per-component state.

04Stack

React NativeExpoTypeScriptWebRTCZustandWebSocketNode.js