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.
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.
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.
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.
// 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.
Both boards are the same Rust binary, behind the two transports. The full source is on GitHub.