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 .

  1. 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 through URL.createObjectURL(file) .
  2. 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). seekComp is 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 .
  3. 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 .

// 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 :

  1. When the leader picks a file, compute its fingerprint.
  2. When creating the room, register the fingerprint with the Lobby along with the name and duration.
  3. When a viewer joins the room, they get the fingerprint from the room metadata in the snapshot.
  4. 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

Park Doeun
You know the web app where we all line up the same choreography video on our own phones in the practice room? Every time we practice, someone has to start the laptop server, get everyone on the same Wi-Fi and pass around the new IP. It's such a hassle .
Jung Bada
Then let's move the server to the cloud. If it keeps serving the video, transfer adds up. With 15 people watching repeatedly, it came out to over 100GB a month. That's an estimate based on assumptions, though .
Jung Haneul
That math assumes the server is serving the video too. What we're trying to get rid of isn't transfer cost, it's the setup. Does the server really have to distribute the video?
Park Doeun
No, we can just share the video over KakaoTalk beforehand. The app doesn't need to stream it or offer downloads. What I don't want is spinning up a server every time and telling everyone the address .
Park Dohyun
Then all that's left on the server is small signals like playback position. Before looking for a cheaper way to transfer, we should've asked whether there was anything to transfer at all .
Jung Bada
But if everyone has their own copy, someone might show up with a different version. Then they'd get the same playback position and see a different scene .
Park Doeun
We decided to check that with a partial fingerprint that only reads 8MB from the start, middle and end . The really hard part was following the leader, though. In the local version the viewer caught up by nudging the playback rate, and apparently it stuttered and wobbled on iPhone .
Jung Bada
So the rate adjustment was the problem. Can't we lower the threshold and just seek? Drop it from 0.75 s to 0.35 s .
Jung Haneul
That works if seeking is instant. But we said a seek takes about 0.5 s to finish, so even after jumping you always land that far behind.
Park Dohyun
Then if you know you'll fall behind, jump ahead by that much. And have it re-learn the correction from whatever error is left after landing.
Jung Bada
Start at 0.45 s, add a 1-second cooldown, and measure the landing 0.8 s later. In simulation the error dropped from 0.5 s to 0.05 s .
Jung Haneul
That's a simulation number, though. Nobody re-measured it over WebSocket in the cloud version.

The leader and viewer were tested in two browser tabs.

Park Doeun
The leader's video plays. But the viewer that joined with the same file passed the fingerprint check and still won't budge from 0 s .
Jung Bada
The viewer's message handler probably threw. That code wraps exceptions so they don't surface, which is why it's silent .
Jung Haneul
That's a guess. First let's figure out whether the server is even sending the signal. Attach a raw WebSocket that doesn't go through the page code and listen .
Park Doeun
The raw connection gets the signals fine. The page's connection is alive too; it just isn't applying them. So I reloaded the viewer and now it's following the leader at 0.40, 0.82, 1.23 s? No console errors either
Jung Bada
Huh? If it had thrown, it should've frozen the same way after reloading… so my hypothesis is wrong.
Jung Haneul
Then the cause is more likely that the viewer joined after the leader had finished and got stale state, plus a race from us poking the state mid-test. That was the call at the time, not a confirmed repro .
Park Dohyun
It stopped at 1.39 s at the end because the test video is only 1.39 s long, and the real song is over 3 minutes, right? Even so, a clip this short tells us nothing about sync quality, so we need to check again on real phones .
Park Doeun
Right, so I connected to the HTTPS deploy from phones. iPhone and desktop work, but in Whale picking a file doesn't enable the button .
Jung Bada
Isn't the file picker just acting weird in Whale? Or the security API is missing, so the fingerprint calculation is blocked.
Park Doeun
The log I put on screen says crypto.subtle: true. But right at the top there's an error saying window.FormationFingerprint doesn't exist .
Jung Haneul
Then fingerprint.js didn't load. Desktop works fine at the same address, so it's a Whale-only issue .
Park Dohyun
So because the name has "fingerprint" in it, it got mistaken for a tracking script? Then rename it and see.
Park Doeun
I renamed it to mediacheck.js and got "It works!! It worked!" The button turns on in Whale now too .
Jung Bada
I thought it was the file picker, but it was one script not loading. That one script died and took everything down with it.
Park Dohyun
Taking video transfer out added more things to check on each phone. The lighter you make the server, the more you have to click through everything on real phones. That's the takeaway this time.