A shared whiteboard with data-star, and where SSE runs out

A few weeks ago I watched a data-star talk at a meetup and came away impressed. It drives a reactive UI straight from the server over one long-lived stream, in a declarative HTML style similar to HTMX, and One Billion Checkboxes running on it was striking. What kept nagging at me was latency. In games, and in collaborative editors like a Miro board, you want motion to feel smooth and immediate, so I asked about client-side prediction.

The board, the data-star way

In the data-star’s model the server owns the state and pushes HTML patches down the Server Sent Event stream, and the browser morphs them into the DOM. Input goes back up as ordinary HTTP requests. For the board that means a stream that patches the cursor and box elements, and a POST for every pointer move.

The nice thing is that the code is very declarative using mostly data attributes, hence the name data-*.

<div
  id="board"
  data-on:pointerenter="$bl = el.getBoundingClientRect().left, $bt = el.getBoundingClientRect().top"
  data-on:pointermove="$mx = evt.clientX - $bl, $my = evt.clientY - $bt, @post('/cursor')"
>
  <div id="boxes"><i id="box-anchor" style="display:none"></i></div>
  <div id="cursors"><i id="cur-anchor" style="display:none"></i></div>
</div>

On the server, one handler holds the stream open and, thirty times a second, renders every cursor and box to HTML and patches it in. A new viewer is correct on the first frame it receives.

async fn sse(State(app): State<App>) -> impl IntoResponse {
    let (id, color, name) = app.world.lock().unwrap().new_client();
    let world = app.world.clone();
    Sse::new(stream_fn(move |mut y: Yielder<Result<Event, Infallible>>| async move {
        let _leave = Leave { world: world.clone(), id };

        // First event: who you are.
        let ident = format!(r#"{{"me":{id},"color":"{color}","name":"{name}"}}"#);
        y.yield_item(Ok(PatchSignals::new(ident).write_as_axum_sse_event())).await;

        // Then 30 Hz of server-rendered cursors and boxes, morphed into the DOM.
        let mut tick = interval(Duration::from_millis(TICK_MS));
        loop {
            tick.tick().await;
            let (cursors_html, boxes_html) = {
                let w = world.lock().unwrap();
                (render_cursors(&w, id), render_boxes(&w, id))
            };
            let p1 = PatchElements::new(cursors_html).selector("#cursors").mode(ElementPatchMode::Inner);
            y.yield_item(Ok(p1.write_as_axum_sse_event())).await;
            let p2 = PatchElements::new(boxes_html).selector("#boxes").mode(ElementPatchMode::Inner);
            y.yield_item(Ok(p2.write_as_axum_sse_event())).await;
        }
    }))
}

The boxes are rendered server-side, data-on handlers and all. The browser never builds them, it receives them ready to use. This is the part that feels genuinely different coming from a client framework: the interactive markup is a format! on the server.

fn render_boxes(world: &World, viewer: u32) -> String {
    let viewer_s = viewer.to_string();
    let mut s = String::from(r#"<i id="box-anchor" style="display:none"></i>"#);
    for b in &world.boxes {
        // Outline + name only when someone else is holding it.
        let held_other = match &b.held_by {
            Some(h) if *h != viewer_s => Some(h.as_str()),
            _ => None,
        };
        let outline = if held_other.is_some() { ";outline:2px solid #fff;outline-offset:1px" } else { "" };
        let label = held_other
            .and_then(|h| world.cursors.values().find(|c| c.id.to_string() == h))
            .map(|c| c.name.as_str())
            .unwrap_or("");
        s.push_str(&format!(
            r##"<div class="box" id="box-{id}"
                 style="transform:translate({x:.1}px,{y:.1}px);width:{w:.1}px;height:{h:.1}px;background:{color}{outline}"
                 data-on:pointerdown="$dragId='{id}', $resize=false, $grabx=evt.offsetX, $graby=evt.offsetY, @post('/grab')">
              <div class="handle" data-on:pointerdown__stop="$dragId='{id}', $resize=true, @post('/grab')"></div>
              <div class="held">{label}</div>
            </div>"##,
            id = b.id, x = b.x, y = b.y, w = b.w, h = b.h, color = b.color
        ));
    }
    s
}

Here it is running. Open it in a second tab and your cursors chase each other around.

data-star / SSE board. Open in a second tab to see presence (your cursor on one tab, theirs on the other).

For presence, this is lovely, and it is almost no code. Then you look closely, on a fast screen, and the first problem shows up.

The screen runs faster than the network

My monitor refreshes 240 times a second. The server patches 30 times a second. Bind one straight to the other and every cursor holds still for eight frames and then jumps, which on a high-refresh display reads as a visible stutter. Phones are 120 Hz now as a baseline, so this is not an edge case.

When I raised this, the answer was a CSS transition, and it is a good answer. One line, and each patched position eases toward its target instead of snapping.

.cursor {
  position: absolute;
  pointer-events: none;
  will-change: transform;
  z-index: 50;
  /* ease each 30 Hz patch to its target instead of snapping */
  transition: transform 0.045s linear;
}

That fixes the stutter for cursors. But it is an ease toward the latest value, not interpolation. It adds a sliver of delay, overshoots on direction changes, and does nothing for a box you are dragging, where easing toward a lagged position just smears the lag. We will come back to the difference.

Your own actions wait for the server

Turn the latency dial on the board up to 250 ms and drag a box. It does not stick to your pointer. It trails a quarter second behind and catches up when you stop.

That is not lag in the network sense, it is the model. The box is a server-owned element. It moves only once your POST is applied and the next patch echoes the new position back, and there is no declarative way to move it sooner. You can drag it client-side and POST only the final position, which feels instant to you but hides the drag from everyone else. Or you can predict it from a local signal and stop the server overwriting it, which works but means you have left the model and started rebuilding what comes next by hand.

The only way up is another request

SSE goes one direction. So every pointer move is its own POST, and data-star cancels any in-flight request to the same URL when a new one starts. A fast drag fires a stream of POSTs that mostly cancel each other, and under real latency the server sees only a fraction of them.

None of this is data-star doing something wrong. It is SSE being what it is: a great pipe downward, nothing upward, and a framework that owns the DOM so there is no seam left for you to predict or interpolate in. Which is exactly the seam the other approach is built around.

data-star · SSE down, POST up Browser Server SSE stream · ~30 patches / sec one POST per pointer move only the latest survives, the rest cancel WebSocket · one duplex socket Browser Server frames both ways, one connection
SSE only flows one way, so input has nowhere to go but a separate request per event. A WebSocket is a single channel both ways, which is the seam prediction and interpolation need.

The game way: a socket, prediction, interpolation

Real-time games solved this a long time ago, and the canonical writeups still hold up: Glenn Fiedler’s Gaffer On Games and Gabriel Gambetta’s client-server series. The shape is always the same. A channel that goes both ways, a client that acts on its own input immediately, smooth interpolation of everything it does not control, and reconciliation against the server’s authoritative truth. Same board, a WebSocket in place of SSE-plus-POST.

The socket carries everything both ways. Input goes up it, and a 30 Hz snapshot of the whole world comes down it, the same full-state-every-tick model the SSE board used, just on a duplex pipe.

async fn handle_ws(socket: WebSocket, app: App) {
    let (id, color, name) = app.world.lock().unwrap().new_client();
    let (mut sink, mut stream) = socket.split();

    let init = serde_json::json!({
        "t": "init", "id": id, "color": color, "name": name,
        "board": { "w": BOARD_W, "h": BOARD_H, "min": MIN_BOX }
    })
    .to_string();
    if sink.send(Message::Text(init.into())).await.is_err() {
        app.world.lock().unwrap().remove_client(id);
        return;
    }

    // Down: forward every 30 Hz snapshot to this socket.
    let mut rx = app.tx.subscribe();
    let send_task = tokio::spawn(async move {
        while let Ok(frame) = rx.recv().await {
            if sink.send(Message::Text(frame.into())).await.is_err() {
                break;
            }
        }
    });

    // Up: apply input, after an optional {t:"lat"} simulated latency.
    let mut cur_lat = 0u64;
    while let Some(Ok(msg)) = stream.next().await {
        if let Message::Text(text) = msg {
            let s = text.as_str();
            if let Ok(v) = serde_json::from_str::<serde_json::Value>(s) {
                if v.get("t").and_then(|t| t.as_str()) == Some("lat") {
                    cur_lat = v.get("ms").and_then(|n| n.as_u64()).unwrap_or(0);
                    continue;
                }
            }
            let (color2, name2, text2) = (color.clone(), name.clone(), s.to_string());
            schedule(&app, cur_lat, move |w| apply_world(w, id, &color2, &name2, &text2));
        }
    }

    app.world.lock().unwrap().remove_client(id);
    send_task.abort();
}

The box you drag renders from your own pointer, so it is instant at any latency. The catch is the release. The server is still a round trip behind, so dropping the prediction right then snaps the box back to where the server still thinks it is. That was a real bug in my first version. The fix is to hold it where you left it until the server catches up, then hand off once they agree. That hold is reconciliation, the same idea that keeps a predicted character from rubber-banding.

your pointer with prediction · instant without prediction · waits for the server
the lower box lags your pointer by a round trip
With prediction the box you drag is drawn from your own pointer, so it never lags. Without it the box can only move once your input has reached the server and the new position has come back.
function renderBoxes(s0, s1, f) {
  const src = s1 ?? s0;
  if (!src) return;
  for (const [id, b] of src.boxes) {
    // Where the server says this box is, interpolated in the past.
    const a = s0 && s0.boxes.get(id);
    const b1 = s1 && s1.boxes.get(id);
    const interp = a && b1
      ? { x: lerp(a.x, b1.x, f), y: lerp(a.y, b1.y, f), w: lerp(a.w, b1.w, f), h: lerp(a.h, b1.h, f) }
      : b;

    let geo;
    if (drag && drag.id === id) {
      geo = drag.box;             // dragging: follow my pointer
    } else if (pending.has(id)) {
      geo = pending.get(id);      // released: hold until the server catches up
      const close = (p, q) => Math.abs(p - q) < 0.5;
      if (close(interp.x, geo.x) && close(interp.y, geo.y) && close(interp.w, geo.w) && close(interp.h, geo.h)) {
        pending.delete(id);       // caught up, no jump: hand back to interpolation
      }
    } else {
      geo = interp;
    }
    let rec = boxEls.get(id);
    if (!rec) rec = makeBox(id, b.color);
    const { el, held } = rec;
    el.style.transform = `translate(${geo.x}px, ${geo.y}px)`;
    el.style.width = `${geo.w}px`;
    el.style.height = `${geo.h}px`;
    const holder = b.heldBy;
    const holderName = holder && [...src.cursors.values()].find((c) => String(c.id) === holder);
    const mineHeld = me && holder === String(me.id);
    el.style.outline = holder && !mineHeld ? '2px solid #fff' : 'none';
    held.textContent = holder && !mineHeld && holderName ? holderName.name : '';
  }
}

Everyone else’s cursors and boxes are drawn about 100 ms in the past, on the line between the two snapshots that bracket that moment. It is the real version of the CSS transition: a line between two positions the server actually sent, not an ease toward the latest. Same trick whether the moving thing is a spaceship or someone else’s mouse.

snap to the latest update (choppy) interpolate between updates (smooth)
watch the top dot jump and the bottom dot glide
Both dots follow the same path. The top one snaps to each update as it lands, so it steps. The bottom one draws a little behind, on the line between the last two updates, so it stays smooth at any refresh rate.
// The two snapshots bracketing a time in the past, to interpolate between.
function bracket(renderTime) {
  let s0, s1;
  for (const s of snaps) { if (s.t <= renderTime) s0 = s; else { s1 = s; break; } }
  return [s0, s1];
}
const lerp = (a, b, f) => a + (b - a) * f;

function frame() {
  const now = performance.now();

  // One send per displayed frame, never the full 240 Hz of the screen.
  if (mouse && me) send({ t: 'cursor', x: mouse.x, y: mouse.y });
  if (drag) send({ t: 'box', id: drag.id, ...drag.box });

  // Render INTERP_MS in the past, interpolated between the two snapshots there.
  const renderTime = now - INTERP_MS;
  const [s0, s1] = bracket(renderTime);
  const f = s0 && s1 ? Math.max(0, Math.min(1, (renderTime - s0.t) / (s1.t - s0.t || 1))) : 0;

  renderCursors(s0, s1, f);
  renderBoxes(s0, s1, f);
  requestAnimationFrame(frame);
}

function renderCursors(s0, s1, f) {
  const seen = new Set();
  const src = s1 ?? s0;
  if (src) {
    for (const [id, c] of src.cursors) {
      if (me && id === me.id) continue; // your own cursor is the real OS one
      seen.add(id);
      const a = s0 && s0.cursors.get(id);
      const b = s1 && s1.cursors.get(id);
      const x = a && b ? lerp(a.x, b.x, f) : c.x;
      const y = a && b ? lerp(a.y, b.y, f) : c.y;
      let el = cursorEls.get(id);
      if (!el) { el = makeCursor(c); cursorEls.set(id, el); board.appendChild(el); }
      el.style.transform = `translate(${x}px, ${y}px)`;
    }
  }
  for (const [id, el] of cursorEls) if (!seen.has(id)) { el.remove(); cursorEls.delete(id); }
}

Here is the WebSocket board, same latency dial. Turn it to 250 ms and drag a box, and it stays under your pointer.

WebSocket board. Open in a second tab to see presence (your cursor on one tab, theirs on the other).

Both boards are the same Rust binary, behind the two transports. The full source is on GitHub.