How to Build Multiplayer Cursors and Selections
Multiplayer cursors and selections are high-frequency ephemeral state. Throttle them, never persist them, and broadcast only to the room to keep editing feel live.
The little colored cursors gliding around a shared document, showing where your collaborators are pointing and what they have selected, are what make multiplayer editing feel alive. They are also the feature people most often build wrong, because they look like data and are actually something else entirely. Cursor position and selection are high-frequency, ephemeral, throwaway state. They should be throttled hard, never written to your database, and broadcast only to the people in the same room. Treat them like real data, persisting every movement and durably delivering it, and you will build something that hammers your storage, floods your network, and still feels laggy. Treat them as disposable signals and they become cheap and smooth.
Cursor state is ephemeral, not durable
A cursor position is true for a fraction of a second before it moves again. A selection lasts a little longer but is still gone the moment the user clicks elsewhere. None of this has any value once superseded. Nobody will ever query "where was this user's cursor forty minutes ago." Yet the naive implementation writes every cursor update somewhere durable, which is like recording every position of a moving mouse to disk. The write volume is staggering and the data is worthless the instant the next update arrives.
Cursor and selection state belong in memory on the real-time layer, broadcast live and then forgotten. This is exactly the same category as typing indicators and presence: ephemeral signals that should never touch application storage. The rule generalizes. If a piece of state is only interesting while it is current and has no historical value, keep it out of the database entirely and let it live and die on the real-time layer.
Throttle at the source, hard
Cursors move continuously, so a naive client emits an update on every pointer move, which is dozens of events per second per user. Put ten people in a document and you have hundreds of cursor updates a second flying around, most of them describing a position that is already stale by the time it arrives. That is a self-inflicted flood.
Throttle at the client before anything goes on the wire. Sending cursor position ten to twenty times a second is smooth to the human eye; faster is imperceptible and pure waste. The server should also bound the rate it accepts and rebroadcasts, so one client cannot force excessive traffic, which ties into rate-limiting real-time connections. And because cursor state is superseded, not accumulated, the server should coalesce: if updates back up for any client, send the latest position and drop the intermediates, the same coalescing discipline that keeps live dashboards cheap. Old cursor positions are garbage; never spend bandwidth delivering them.
Broadcast only to the room, and only the movers
Cursor updates are interesting only to people looking at the same content, so they should go only to that room's channel and nowhere else. This is a straightforward application of pub/sub channel granularity: the document is the channel, cursor updates publish to it, and only the handful of people currently in that document receive them. You never broadcast cursor movement app-wide; that would be an enormous flood to deliver something almost no one cares about.
Scope it further where you can. If your content is large and paginated, a user only needs to see cursors near what they are viewing, so you can narrow delivery to the relevant region rather than the whole document. That keeps the fan-out proportional to what is actually visible, which matters most in exactly the busy, crowded documents where cursor traffic is highest.
Separate cursors from the edits themselves
One distinction worth holding firmly: the cursor is not the edit. The actual content changes, the inserts and deletes, are durable and must be delivered reliably and in order, because losing an edit corrupts the document. That is a completely different contract, handled by the ordered, durable delivery and, for true concurrent text editing, the merge logic I compared in CRDT vs last-write-wins. Cursors ride alongside edits but under opposite rules: edits are precious and reliable, cursors are disposable and best-effort. Mixing the two, either persisting cursors or treating edits as droppable, breaks both. Keep them on separate tracks.
Let the platform carry the ephemeral track
Ephemeral, throttled, room-scoped, best-effort broadcast is precisely what a real-time layer does well and what a database does terribly. So I put cursors and selections where they belong: on the real-time layer of AltoHost, broadcast to the room and never stored, while the durable edits go through a separate reliable path. That split keeps my storage sized for real content instead of for a firehose of pointer positions, and it keeps the cursors smooth because they are not fighting durability guarantees they never needed.
The test is to open a document with several people, move cursors wildly, and then check two things: your database write volume and the smoothness on screen. If cursor movement is generating database writes, you persisted something disposable. If it feels laggy, you probably failed to throttle or coalesce. Done right, cursors glide, storage stays quiet, and the room feels genuinely shared, which is the entire point of the feature.