Dev Investigation Notes
Sync the same video across multiple phones without a video server — relay only playback position over WebSocket and match files with a partial hash
This is a translated article. Read the original in Korean ↗
Have everyone save the video to their phone ahead of time, and have the server use WebSocket to relay only the leader's play, pause and playback position within each room. That's how a practice web app called formation was moved off a laptop. In formation, several people watch the same choreography video on their own phones and overlay formations and movement paths on it. With the server running on Cloudflare Workers and Durable Objects at an HTTPS address, nobody has to start the laptop server, get everyone on the same Wi-Fi and pass around the new IP before every practice .
Conclusion
The design has three parts. Video bytes never pass through the server. The server is a Cloudflare Worker plus one Durable Object (DO) per room. Phones compute file fingerprints with crypto.subtle, so the app has to be deployed over HTTPS .
- Playback: The leader (the person in control) and the viewers (the people following along) all pick their pre-downloaded file with
<input type=file>and play it throughURL.createObjectURL(file). - Sync: The leader sends playback state as WebSocket messages, and the server (one Durable Object per room) broadcasts them to the viewers in that room. Viewers normally just play at 1x and seek only when they drift past a threshold from the leader. The threshold is
RESYNC_THRESHOLD(0.30 s, the last value confirmed in the local version).seekCompis a correction that requests a position ahead by roughly the time a seek takes to complete. It starts at 0.45 s and auto-tunes from the landing error . - Same-file check: The leader and each viewer independently compute a partial hash (fingerprint) that reads only 8MB each from the start, middle and end of the file, then compare the results .
The files themselves still have to be shared beforehand by some other means. The sections below cover these three parts in order.
Situation
In this web app, the leader controls play, pause and playback position, and viewers match the video on their own phones to that state. If you attach a formation file (formation.json, formation data over time), the app draws the formations on top of the video. Marks left by viewers are also forwarded to the leader. Previously, a laptop server served both the video and the SSE signals. SSE is a one-way protocol in which the server keeps pushing events to the browser .
The cloud version looks like this. In this design, a DO (Durable Object) is the unit that manages per-room state and connections .
[Leader / viewer browsers] ─ video is each device's local file
│ WebSocket: playback state, formations, marks
▼
[Cloudflare Worker] ─ serves pages + room creation/list API
├─ one Lobby DO : list of active rooms
└─ per-room Room DO : stores room state + forwards to connected participants
The formation file is optional. If the leader attaches one, the Room DO stores it and forwards it to viewers, so viewers don't have to pick it themselves .
Symptom
The problem was the setup that had to be repeated every session. Before each practice, someone had to start the laptop server, get everyone onto the same Wi-Fi and tell them the server's IP .
At first the discussion turned to the cost of streaming video from the cloud. Storage and VM options were considered on the assumption that a 720p, 2Mbps, 194-second video played many times by 15 people would come to over 100GB of transfer per month. That figure came from assumptions made during the session. It is not measured usage or an actual bill .
Cause
The friction came from having video delivery and playback-signal relaying tied to a single laptop server. The turning point was clarifying the requirement: "the app doesn't need to stream the video or let people download it." Video could be shared ahead of time through KakaoTalk or similar, and a server at a fixed address only had to handle sync .
With video transfer gone, the data to relay shrinks to JSON signals measured in KB . Don't read that as a guarantee that the whole server costs nothing, though. Durable Objects are billed on requests, execution time, storage usage and so on. What this post confirms is a design that removes video relaying, not a measurement of long-term operating cost. Cloudflare pricing docs
The change creates a new problem. If people prepare videos with different encodings, lengths or start points, they'll see different scenes even when they receive the same playback-position signal .
Fix
1. Play the video from a locally selected file
Take the File chosen with <input type=file> and attach it to <video> via URL.createObjectURL(file). This project had previously loaded the whole server-delivered video into memory as a Blob, hit the iOS memory limit and dropped that approach. An object URL for a selected File reads only the ranges it needs from disk, so it was judged to work differently. Still, the design has no server-video fallback, so on-device testing was made the top priority .
Automated desktop testing confirmed the ready state readyState=4. Later came confirmation from real use: "it works on iPhone.. and on desktop too." This record alone doesn't show that memory usage on long videos, or behavior on every mobile browser, has been verified .
2. Playback-position sync: the server only relays, the viewer decides
Server side. When the Room DO receives a control message from the leader, it remembers the latest state and broadcasts the message to every socket except the leader's. When a new viewer joins, it sends that latest state (a snapshot) first. During the session, browser WebSocket tests confirmed the following: snapshot delivery, broadcast that excludes the leader, viewer→leader mark forwarding, participant count going up and down, and room deletion when the leader leaves . The message format stayed the same as in the local SSE version, {type:'sync'|'formation'|'mark'|'reload'}, so the client change came down to swapping the transport wrapper .
Below is example code to explain the structure. It isn't copied from the repository, and the field names and code shape were chosen for this example.
// Minimal Room DO skeleton (example using the WebSocket Hibernation API)
export class Room {
constructor(ctx) { this.ctx = ctx; }
async fetch(req) {
const pair = new WebSocketPair();
this.ctx.acceptWebSocket(pair[1]); // keep the connection even if the DO hibernates while idle
const last = await this.ctx.storage.get('lastSync');
if (last) pair[1].send(last); // send the latest state to the new participant
return new Response(null, { status: 101, webSocket: pair[0] });
}
async webSocketMessage(ws, msg) {
const m = JSON.parse(msg);
if (m.type === 'sync') {
await this.ctx.storage.put('lastSync', msg);
await this.ctx.storage.setAlarm(Date.now() + 3600_000); // alarm after 1 hour with no control input
}
for (const s of this.ctx.getWebSockets()) if (s !== ws) s.send(msg);
}
async alarm() { /* close sockets, clear storage, then notify the Lobby of deletion */ }
}
// sync message sent by the leader (example)
// { type: 'sync', playing: true, time: 12.4, rate: 1, sentAt: 1735000000000 }
If a message carries whether the video is playing, the playback position, the playback rate and the send time, the viewer can work out roughly where the leader is right now. In the session, this calculation was called expectedTime() (the estimated leader time) .
Two things can't be confirmed from the session. First, there's no turn to cite for the exact formula behind expectedTime() or for how clock differences between the leader's and viewer's devices are corrected. The only thing on record is a mention that the viewer smooths the offset from the samples it receives with an EMA (exponential moving average) . The sentAt above only illustrates the format, and you'll have to design clock correction yourself. The simplest form is expected = time + (playing ? (now - sentAt) / 1000 * rate : 0). That's this post's own example, which assumes both devices' clocks agree; it isn't a formula from the session. On a phone with a skewed clock this value is itself wrong, so you need a correction such as smoothing the offset from received samples. Second, send timing is confirmed only for the local version. The leader sent sync periodically every 200ms (5 times per second), and the viewer's correction decisions ran in a separate loop . Messages weren't sent only on user actions, so drift is caught by this periodic send plus the viewer's seek threshold. There's no record of this interval or RESYNC_THRESHOLD being re-measured over WebSocket in the cloud version.
Viewer side. The rules the local version arrived at after several failed attempts were reused unchanged in the cloud version .
- During playback,
playbackRateisn't touched and always stays at the leader's rate (usually 1). Catching up by nudging the playback rate actually caused stutter and oscillation on iOS . - Seek only when the gap from the estimated leader time exceeds
RESYNC_THRESHOLD. This value went from 0.75 s down to 0.35 s, and became 0.30 s after the correction below was added . - A seek took about 0.5 s to complete, so playback always landed about that far behind. So the seek requests
exp + seekComp, ahead of the target, and after landing the remaining error is used to re-learnseekComp. Starting at 0.45 s, simulated landing error dropped from 0.5 s to 0.05 s. A 1-second cooldown prevents back-to-back seeks, and the landing is measured 0.8 s later .
// Viewer correction flow (illustrative pseudocode)
const exp = expectedTime(); // estimated leader time
if (video.playbackRate !== leader.rate) video.playbackRate = leader.rate;
if (Math.abs(video.currentTime - exp) > RESYNC_THRESHOLD && cooldownOver()) {
video.currentTime = exp + seekComp; // request ahead by the seek delay
// 0.8 s later, update seekComp from the residual (video.currentTime - expectedTime())
}
These values came from measurements on the user's iPhone (mentioned in the session as a 12 Pro) . Seek latency can differ by device, so seekComp is learned automatically. On-device confirmation went as far as "it works!", and no final error figure is recorded in the session .
Testing pitfall. In one automated two-tab browser test, the viewer stuck at 0 s. The server was broadcasting sync correctly, and once the viewer was reloaded it followed the leader (0.40→0.82→1.23 s). The test clip was 1.39 s long, so stopping at the end was also an artifact unrelated to the real song (over 3 minutes). The freeze was attributed to a viewer joining late, after the leader had finished, combined with state being manipulated during the test . A short clip can't tell you anything about sync quality, so test with a video of realistic length.
3. Match files with a partial hash
In the session, this fingerprint was implemented as an "iOS-memory-safe partial hash (start, middle and end 8MB)" . Here's why. crypto.subtle.digest can't take streaming input; it needs the whole input at once (MDN). Hashing an entire video of several hundred MB would mean loading the whole file into memory, which could bring back the iOS memory problem from before. That reasoning comes from how the API works, not from the session conversation. So only start, middle and end chunks, cut using file.size and file.slice(), are hashed.
The flow is :
- When the leader picks a file, compute its fingerprint.
- When creating the room, register the fingerprint with the Lobby along with the name and duration.
- When a viewer joins the room, they get the fingerprint from the room metadata in the snapshot.
- When the viewer picks their own file, compute it with the same algorithm and compare in the viewer's browser. If they differ, show "리더와 다른 영상 파일이에요" (This video file is different from the leader's) and block the viewer from joining.
The following is an example for explaining the design. It is not code copied from the repository. Only the approach of reading 8MB from the start, middle and end is from the session . The small-file cutoff, size serialization, middle offset and choice of SHA-256 were all decided for this example.
const CHUNK = 8 * 1024 * 1024; // 8 MiB in this example
async function partialFingerprint(file) {
const size = file.size;
const offsets = size <= CHUNK * 3
? [0]
: [0, Math.floor(size / 2 - CHUNK / 2), size - CHUNK];
const parts = [new TextEncoder().encode(String(size))];
for (const off of offsets) {
const end = size <= CHUNK * 3 ? size : off + CHUNK;
parts.push(new Uint8Array(await file.slice(off, end).arrayBuffer()));
}
const joined = new Blob(parts);
const digest = await crypto.subtle.digest('SHA-256', await joined.arrayBuffer());
return [...new Uint8Array(digest)]
.map(b => b.toString(16).padStart(2, '0')).join('');
}
This example reads at most 24MiB of video bytes. Treating the session's "8MB" as MiB is this example's choice. The chunks and the joined buffer can be in memory at the same time, so peak memory use isn't capped at 24MiB. If the leader's and viewer's algorithms differ by even one character, they'll never match, so both sides must run the same code.
There are limits. Two files that differ only in the unread ranges can't be told apart. If everyone gets the same file through KakaoTalk or similar, this post's judgment is that the check is good enough in practice. There's no record of this limit being discussed in the session. The API is only available in a secure context, so phone testing has to happen over HTTPS. The session also confirmed that crypto.subtle isn't available when connecting to a PC IP over plain HTTP .
Automated testing fed identical video bytes to both tabs and confirmed that the fingerprints matched and the viewer could join. Blocking a different file was left as a manual test item, and the session doesn't state its result .
4. DO built-in storage instead of a separate DB
"No external DB needed" and "no state to store" aren't the same thing. The Lobby DO keeps the room list, and the Room DO keeps room metadata, formations, marks and the latest sync, each in its own built-in storage. Rooms disappear after an hour with no control input, so no data needs to outlive a room. That's why there's no separate DB service .
Room cleanup uses alarms. Every time the leader acts, the alarm is reset with setAlarm(now + 1 hour). When it fires, the Room DO closes its sockets, clears its storage and tells the Lobby to delete it. Rooms that die without sending that notice are cleaned up later by the Lobby when the list is queried . The implementation report at the time listed checking that the alarm actually fires after a real hour as a post-deploy item. This post doesn't claim that expiry behavior was verified in this session .
Bonus — a script init error that looked like a file-picker failure
In the Whale browser on Android, picking a file didn't enable the button, and the problem persisted after the HTTPS deploy. The on-screen debug log showed crypto.subtle: true, but it also showed an exception saying window.FormationFingerprint was undefined. When the page script dies on its first line, the file handler, the WebSocket connection and button enabling all stop with it .
Since fingerprint.js wasn't loading, the conclusion was that Whale's tracking protection had mistaken the filename for a browser-fingerprinting tracker script and blocked it. After renaming the file to mediacheck.js, the user confirmed: "It works!! It worked!" There's a comparison showing desktop worked under the same conditions, but no record confirming the actual blocking rule. So this isn't generalized into a rule that certain filenames should be avoided in every browser .
Reporter's Notes — not where to send the video, but whether to send it at all
The leader and viewer were tested in two browser tabs.
crypto.subtle: true. But right at the top there's an error saying window.FormationFingerprint doesn't exist .fingerprint.js didn't load. Desktop works fine at the same address, so it's a Whale-only issue .mediacheck.js and got "It works!! It worked!" The button turns on in Whale now too .