leptons1618@anishgiri.dev:~/case-studies/streamfusion less writeup.md
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
- WebRTC data channels carry latency-sensitive sync events peer-to-peer, so a tick never has to round-trip through the server.
- A `ws` signal server shares a single Express port via `noServer: true`, rather than opening a second listener.
- `RoomManager` indexes rooms three ways — by id, by short join code and by peer — so membership is a map read in every direction.
- Every room tracks `lastActivity` alongside `createdAt`, which is the field an idle-room reaper needs.
- A `RoomPeer` retains its socket and join time, so a dropped session can be reconstructed rather than restarted.
- Zustand holds playback position, shared queue and membership in a single store instead of per-component state.