Hey folks, I’m wiring up a drag-to-pan tilemap in a canvas app and I’m trying to keep it snappy with optimistic UI updates. It mostly works, but if I drag fast and let go, I sometimes see the last few deltas apply out of order, and memory usage creeps up after a few minutes.
const pending = new Map();
let seq = 0;
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const id = ++seq;
const delta = { dx: e.movementX, dy: e.movementY };
applyLocal(delta); // optimistic
pending.set(id, delta);
sendDelta(delta).then(() => {
// ack from server
pending.delete(id);
});
});
What’s a solid pattern to guarantee ordering and cleanup here (so late acks can’t reintroduce stale deltas, and pending doesn’t leak) without making the drag feel laggy?
This usually happens when old promises from a previous drag are still allowed to touch current state.
Give each drag its own session id, attach that to every delta, and ignore any ack that doesn’t match the active session. Then clear pending on drag end and on a timeout so a dropped request doesn’t sit there forever.
Late acks should only clean up. They shouldn’t be able to re-apply anything.
This is the “late async finishes” thing — like you erased the whiteboard and then someone walks in and redraws yesterday’s sketch from a screenshot.
Session id is the right fix, and I’d tack on a per-drag sequence number so you only apply deltas/acks newer than whatever you’ve already applied for that session. Even within one drag, out-of-order acks happen, and this keeps a late one from nudging the position backward.
I buy the per-drag sequence number more than a session id, honestly. A session id stops “yesterday’s sketch, ” but it doesn’t stop “five seconds ago” from landing after “one second ago” inside the same drag and snapping you back a few pixels.
When you say “snapping you back a few pixels”, is that happening even though you’re tracking a lastApplied per drag and dropping any event with seq <= lastApplied on the consumer side? might be wrong here.
Yeah I’ve seen that “snap back” even with seq gating when there’s a separate “commit/end” event that lands late and re-applies an older position from a different codepath/state snapshot. In my case the fix was making sure every write (move + end) goes through the same reducer and uses the same monotonic token, not just the move events.
I buy that diagnosis — the “end/commit” path is basically a second writer with different incentives, and seq-gating only the move stream doesn’t protect you from a late authoritative-looking write. Forcing both paths through one reducer with one monotonic token is less about correctness in the happy path and more about preventing that accidental “priority override” when the pipeline gets congested.