Dev Investigation Notes
When iPhone video sync keeps jumping and stays 0.5 seconds behind
This is a translated article. Read the original in Korean ↗
We were building a web app (formation) for dance rehearsals: a leader's laptop plays a choreography video with music, while members use their own phones to follow the same point in the video and the formation (movement paths). On an iPhone 12 Pro, the video would repeatedly sync with the leader, then stutter and fall behind. Working from the hypothesis that the leader advances while a seek (an instant jump to a playback position) completes, we added compensation to the seek target and received confirmation that it worked. However, this was not an experiment that measured seek duration itself or fully ruled out device performance as a cause.
Conclusion
If an iPhone's <video> keeps drifting out of sync with the leader and stuttering, playback-rate correction and preloading the stream did not help in this case. What worked was moving the seek target ahead from the leader's estimated current playback time, exp, to exp + seekComp. Switching to preloading (blob) left the stuttering unchanged , and removing rate correction or changing it to ±6% still left stuttering or uncorrected drift . When the error observed with ?debug=1 stayed at 0.45–0.5 seconds, we hypothesized that the delay until each seek finished was leaving the viewer behind every time. After implementing compensation that started at 0.45 seconds and updated from the error remaining after each seek, we received an "It worked!" from the iPhone user.
Expressed as formulas, the core change looks like this. This summarizes the implementation description rather than reconstructing the actual source.
이전 seek 목표 = exp
변경 seek 목표 = exp + seekComp
seekComp 초기값 = 0.45초
이후 seek 착지 잔차로 seekComp 갱신
exp is the leader's current playback time as estimated by the viewer from sync messages. The record only mentions smoothing received samples with an EMA (exponential moving average); it does not preserve the coefficient . The sketch below is an example for anyone implementing the same idea, not code reconstructed from the record. You will need to tune the coefficient 0.5 and the measurement timing yourself.
// Example (not a reconstruction): estimate leader time + learn seekComp
// In applySync: smooth the offset using received leader time t and receipt time now (seconds)
offset = offset === null ? t - now : offset * 0.8 + (t - now) * 0.2;
const exp = () => performance.now() / 1000 + offset; // Estimated current leader time
function hardSeek() {
video.currentTime = exp() + seekComp;
landAt = performance.now() + 800; // Measure landing error 0.8 seconds after the request (assumption)
}
// When not paused, once after landAt:
const resid = exp() - video.currentTime; // Positive = still behind
seekComp = Math.max(0, seekComp + resid * 0.5);
0.47 seconds was not a direct measurement of seek duration. What we actually observed was a range of sync errors; interpreting it as seek delay was a hypothesis. The actual post-fix Δ and comp values were not recorded. We therefore cannot claim that the error fell to 0.05 seconds on the physical device.
Situation
We were building real-time rehearsal-room synchronization for formation, a dance formation and movement-path editor. The setup has three parts.
- Leader page: The leader's laptop plays a choreography video with music and sends its current playback time and state to the server every 200ms (five times per second).
- Server: A Node server running on the laptop. It relays the leader's sync messages to all viewers over SSE (a mechanism for the server to continuously push events to browsers) and serves the video file.
- Viewer page (
viewer.html): Runs on members' phones. It estimates the leader's current playback time (exp) from received sync messages and aligns the phone's<video>with that time. A canvas draws the formation (movement paths) over the video. There was also a mode that turned off the video and showed only movement paths.
The problem was in the viewer's correction logic for aligning <video> with the leader. There are two tools available when the video drifts away from the leader. One is to adjust playbackRate (playback speed) slightly to catch up gradually; the other is to seek by changing currentTime, jumping to the target position.
Symptom
Even a 360p streaming video stuttered intermittently on the iPhone 12 Pro. Preloading it before playback made no difference, while the mode that removed the video and showed only movement paths was smooth. The recurring symptom was: "Just when it seems in sync, it stutters again and falls behind."
We initially suspected rebuffering during streaming seeks and tried preloading. But the very next test reproduced the problem. This was only a clue that streaming alone could not readily explain the symptom; it was not an experiment that tested and ruled out decoding, memory, and device performance individually. The investigation shifted toward video sync correction.
Even then, each change to the correction method left a different problem.
| Correction attempt | Result on the physical device |
|---|---|
| Remove fine playback-rate correction and seek only when the gap reaches 0.75 seconds or more | The jumps stopped, but the drift was not corrected. |
| Apply hysteresis to a fixed ±6% playback-rate adjustment | Fewer rate changes, but "very choppy." |
| Remove rate correction again and lower the seek threshold to 0.35 seconds | Kept jumping. |
Observations and hypotheses that narrowed down the cause
The turning point was the question, "Can we check whether the gap is growing or shrinking?" In response, we added a ?debug=1 display showing the error Δ, whether it was growing, shrinking, or holding steady, the playback rate, and the seek threshold. Under the definition used at the time, a positive Δ meant the viewer was behind the leader. We also added a guard against seeking while a seek was already in progress, and a condition that allowed seeking only when the gap was growing.
The value seen on the iPhone "kept going back and forth between 0.45 and 0.5." The observation was that the error stayed within a fixed range rather than accumulating. We interpreted that range as an error floor left by seek delay.
Here is that interpretation as a simple model at 1× playback speed. Suppose a seek to 10.00 seconds is requested when the leader is at that position, and the viewer takes 0.47 seconds to resume playback there. Meanwhile, the leader has reached 10.47 seconds. The viewer has reached the target specified when the request was made, but by the time it arrives, it is already 0.47 seconds behind again. This is an assumption used to explain the cause, not a result measured with a separate timer.
In this model, the 0.35-second threshold is smaller than the remaining error, so correction repeats indefinitely. Raising the threshold, on the other hand, reduces jumps without closing the gap. A change raising the threshold to 1.0 seconds was reported first, with the expectation that playback would stay smooth while remaining 0.5 seconds behind. No separate result from a physical device was reported for this step.
There was also a proposal to delay the leader by 0.5 seconds. It was rejected on the grounds that delaying the reference would leave the viewer lagging behind that reference again, preserving the relative gap. What was considered here was delaying the broadcast reference or playback as a whole; this did not establish that delaying only the audio would also be impossible. The final goal also became clear: a 0.5-second offset in movement paths was acceptable, but the music and the movements in the video had to match, and half a second is a large difference in a dance video.
The observation that playback-rate correction caused stuttering also needs to be read within its limits. The physical-device test with hysteresis failed, but it did not establish that "anything other than 1× speed always stutters on an iPhone." An experiment with preservesPitch=false, mentioned as an alternative, was not carried out during this fix either.
Fix
We changed the target from the leader's position when the seek is requested to the position the leader is expected to reach when the seek finishes. According to the implementation description, seekComp was added to the target in hardSeek, and the compensation was then learned from the residual error after landing. It was designed to account for that residual rather than apply a fixed 0.5 seconds to every device.
The settings at the time were as follows. These numbers summarize that implementation; they were not verified as optimal for other devices.
| Item | Setting at the time |
|---|---|
Initial seekComp | 0.45 seconds. |
RESYNC_THRESHOLD | 0.30 seconds. |
| Seek cooldown | 1 second. |
| Landing measurement | 0.8 seconds after the seek (the reference point was not recorded). |
| Learning while paused | Reset the measurement. |
| Saving compensation | Not saved separately; starts at 0.45 seconds each session. |
All the record preserves is the description "learn seekComp from the landing residual" and the timing "measure 0.8 seconds after the seek." The actual update coefficient, bounds, and reference point for those 0.8 seconds (the seek request or the seeked event) were not preserved . The sketch above fills in that gap by using the request time as its reference. To implement the same approach, you need to log the seek request time and the residual 0.8 seconds later yourself, then choose an update coefficient. Implementers also need to check whether the residual could be distorted if a slow device does not finish seeking within 0.8 seconds. This is a caveat, not a fact established at the time.
Looking only at the arithmetic, if the actual delay is 0.5 seconds and seekComp is 0.45 seconds, the first seek leaves a residual of 0.05 seconds.
In the simulation, setting the delay to 0.5 seconds and applying 0.45 seconds of compensation produced a first-seek residual of 0.05 seconds. The "10× improvement" was a simulation result. Confirmation on the actual device was "It worked!"; post-fix Δ and comp values were not recorded. The Δ +0.03s and comp 0.47 shown in the example debug display were not measurements either.
Afterward, the decision was made to remove the movement-paths-only button because there no longer seemed to be a need to turn off the video. That supports the judgment that the issue felt resolved, but it does not quantify the remaining error or prove the accuracy of simultaneous playback across multiple devices. The confirmed outcome in this case goes as far as receiving confirmation that the compensation resolved the problem.
Reporter's Notes — arrived, but late again
The script below reconstructs the investigation through four characters.
<video> follow the playback time it receives from the leader. When it drifts, we adjust the speed slightly or seek straight to that position. <video> and compare. playbackRate, the playback speed. Let's remove rate correction and jump only when the gap exceeds 0.75 seconds. Δ, and its trend appear at the bottom of the screen when you append ?debug=1 to the URL. A positive Δ means the phone is behind. exp, we jump to exp + seekComp, where the leader should be when the jump finishes. Start seekComp at 0.45 seconds, then keep updating it from the error left after arrival. Δ after the fix. seeked event.