Skip to content
Yassir Halaoui

Solo SaaS · 6 min read

Synchronizing one moment across every screen

By Yassir HalaouiPublished

In short

Simultaneity across devices is an offset problem, not a latency problem: each client estimates its own offset from the server clock the way NTP does, using round-trip time, then schedules the event against the corrected clock instead of counting down locally. Send the absolute server timestamp of the moment rather than a remaining duration, re-sample the offset periodically, and keep the render path warm so the last frame before the event costs nothing.

RevealTogether has one job: a moment happens at the same instant on every screen watching it. A grandparent in another time zone must not see the result before the people standing in the room.

That sounds like a latency problem. It is not. It is an offset problem, and the distinction changes the whole design.

Why a countdown does not work

The obvious implementation sends a duration and lets each client count down.

// Wrong, in three separate ways.
socket.on("reveal-in", (seconds) => {
  setTimeout(showResult, seconds * 1000);
});

Network latency is baked in and never removed. The message took some time to arrive. That time is different for every client and is now permanently part of their countdown.

Timer drift accumulates. setTimeout is a lower bound, not a promise. Over a long countdown, on a busy tab, on a phone throttling background work, it drifts — and it drifts differently per device.

A reconnect restarts everything. A client that drops and reconnects gets a fresh duration computed at a new moment, from a server that has to guess how much of the original countdown it has already seen.

Every one of these is a different amount on every device. The result is a smear rather than a moment.

Send the timestamp, not the duration

The fix is to make the event an absolute point in time on a single clock, and have every client convert that point into its own local frame.

// The server publishes when the moment happens, on its clock.
socket.on("reveal-scheduled", ({ revealAtServerMs }) => {
  const localMs = revealAtServerMs - clockOffsetMs;
  scheduleAt(localMs, showResult);
});

clockOffsetMs is the difference between this device's clock and the server's. Now a late message does not shorten the wait — it just means the client has less time to prepare, which is a different and much more tractable problem. A reconnecting client recomputes the same absolute instant and lands in the same place.

Device clocks are wrong by seconds routinely and by minutes occasionally. A user who has manually set their clock, or whose phone has drifted, is not an edge case at the scale of "everyone at a party".

Estimating the offset

This is the NTP algorithm, in about fifteen lines. The client timestamps a request, the server timestamps its receipt, and the round trip gives you enough to solve for the offset.

clock-offset.js
async function sampleOffset(socket) {
  const t0 = Date.now();
  const { serverMs } = await socket.request("time");
  const t1 = Date.now();
 
  const roundTrip = t1 - t0;
  // Assume the response spent half the round trip in flight.
  const offset = serverMs + roundTrip / 2 - t1;
 
  return { offset, roundTrip };
}
 
async function estimateOffset(socket, samples = 5) {
  const results = [];
  for (let i = 0; i < samples; i++) {
    results.push(await sampleOffset(socket));
  }
 
  // The sample with the lowest round trip is the least contaminated
  // by queueing, so trust it over the mean.
  results.sort((a, b) => a.roundTrip - b.roundTrip);
  return results[0].offset;
}

Two details carry most of the accuracy.

Take several samples and keep the best, not the average. The error in the offset estimate is bounded by half the round-trip time. Averaging includes your worst samples; taking the minimum-RTT sample takes the measurement least polluted by queueing and scheduling delay.

Re-sample periodically. A device that sleeps and wakes, or switches from Wi-Fi to cellular, comes back with a different offset. Re-estimating every thirty seconds is cheap and removes a class of failure that is otherwise impossible to reproduce.

Budget the last second

Getting the schedule right is not enough if the render is slow. The last second before the moment should be doing nothing but waiting.

  • Preload everything. Images, fonts, animation assets, audio — all fetched and decoded well before the moment, not at it.
  • Pre-warm the render path. Mount the reveal component hidden and paint it once, so the browser has already done layout and style work. Toggling visibility is far more predictable than mounting a subtree.
  • Do not fetch the result at reveal time. The client should already hold everything it needs, released by a local timer. A network round trip inside the moment reintroduces exactly the variance the design removed.
  • Use requestAnimationFrame for the final approach. setTimeout resolution is coarse. Schedule the timeout to land slightly early, then wait out the last few frames.

The rule of thumb I settled on: if the spread across devices is under about 200 ms, nobody perceives an order. Above that, someone in the room notices that a phone lit up before the television did, and the illusion is gone.

The security constraint that shapes everything

There is a constraint here that has nothing to do with timing: the client must not hold the answer before the moment.

Sending the result early and letting each client reveal it locally is by far the simplest way to hit the timing budget. It is also completely broken — anyone can open developer tools and read it, and for this product the surprise is the product.

So the result is pushed at the moment, not before, and everything else — the animation, the assets, the layout — is preloaded so that the only thing arriving late is the one field that has to.

That is the tension the design lives in: preload everything except the thing that matters.

What reconnection has to do

Connections drop. A phone locks, a train enters a tunnel, a laptop sleeps. The reconnect path is not an edge case, it is the common case for a thirty-minute gathering.

On reconnect the client re-estimates its offset and asks for the current state. The server answers with the same absolute schedule it gave everyone else. If the moment has already passed, it answers with the result directly.

That last branch matters more than it looks: a guest who reconnects two seconds after the reveal should see the result immediately, not a countdown that has gone negative. Handling "the moment is in the past" as a first-class state, rather than as a timer that expired, removes a whole family of bugs.

What I would keep

The single decision worth carrying to anything else with a synchronized moment: publish an absolute instant on one clock and let each client translate it. Everything else here — the sampling, the preloading, the reconnect path — is implementation detail hanging off that one choice.

The other constraint that shaped this product was cost rather than time; that one is written up in the free-tier quota that took my product down for two days.

Written by Yassir Halaoui, Senior Fullstack Java / React Developer in Paris. Nine years across banking, payments and insurance, and three products built and operated solo. Get in touch.

More on solo saas