Real-time collaboration techniques: ordering, sync and large lists

The engineering behind screens many people change at once: ordering with sparse positions instead of re-numbering, optimistic updates that roll back, row versions, leases and three-way merges for conflicts, granular sync compared with full reloads and CRDTs, virtualized lists, roll-ups over trees and history from soft-deleted rows - step by step, with worked examples.

In short

When several people work on one list at the same time, three hard problems appear. Order: moving one item must not rewrite every other row. Conflicts: two people change the same thing, and nobody's work may silently disappear. Freshness: every open screen should show a change within a moment, without reloading thousands of items. {{brand}} answers them with a small set of reusable techniques - sparse positions, optimistic updates, version checks and merges, granular sync and virtualized lists - and this lesson teaches each one so you can use it in any project.

After this lesson you can Store a user-defined order so that a move writes one row; make a write feel instant and roll it back safely; choose between last write wins, version checks, locks, merges and CRDTs for a given kind of data; and keep a list of thousands of items fast and current on every open screen.

Words you need

Ordering a list: positions with gaps

The obvious way stores the order as 1, 2, 3 ... N. Moving the last card to the top then rewrites all N rows: N writes, N row locks held in one transaction, N change records and N items every other screen must fetch again. On a column of 400 cards one drag becomes 400 updates, and two people dragging at once fight over the same rows.

Sparse positions fix this by leaving gaps: cards sit at 0, 1024, 2048. A card dropped between 0 and 1024 takes the midpoint, 512 - one row written, however long the column is. Only when two neighbours have no whole number left between them is the column re-spaced once, and that is rare.

Always order by position, then by id Two items can end up with the same position - two people add an item to one list in the same instant. Sorting by position and then by id makes the order deterministic everywhere, so equal positions are harmless and an insert needs no lock or strict transaction. A move that must never leave an item in two lists is different: run it in a stricter transaction (see the conflicts section).

Optimistic updates: show it now, let the server win

A drag that waits 300 ms for the server feels broken. An optimistic update changes the data on screen at once and sends the request behind it; a pessimistic update waits for the answer first. Optimistic is right when the server almost always says yes and the change is easy to reverse: moves, toggles, pins, marking as read. In a React app the data on screen lives in a query cache - TanStack Query in {{brand}} - so an optimistic update is a careful edit of that cache.

Undo must be a real action An Undo offered after a move should send a real reverse request - the same move back to the neighbour the card sat beside - never just restore the old picture. If there is no way back on the server, offer no Undo. Keep the patch itself a pure function of the old data and the move, so the move and its rollback can be tested without a browser.

When two people change the same thing

Optimistic updates take care of your screen. Concurrency control decides what the database keeps when two people change the same record at the same moment. There is no single right answer - there is a right answer per kind of data, and {{brand}} uses five of them.

ORMs do the same for you: in EF Core a property marked as a concurrency token is added to the WHERE clause of every update, and zero rows updated raises a concurrency exception. In PostgreSQL the hidden system column xmin changes on every update, so it can serve as a free row version.

Over HTTP the same idea is an ETag returned with the document and an If-Match header on the save; a stale tag is answered with 412 Precondition Failed (many APIs use 409 Conflict instead).

Where do the blocks come from? From the editor. A browser's own editable area (contenteditable) produces different HTML in every browser, so serious editors do not trust it. A rich text editor such as Tiptap, built on ProseMirror, keeps its own document: a tree of nodes (document, paragraph, heading, list, list item, text) with marks such as bold on the text, checked against a schema that says what may contain what. Every keystroke or click becomes a transaction - a list of small steps like insert here, delete there, mark this range - which produces a new immutable document; the editor then changes only the page elements that differ, much as React does. Asking for the HTML gives a clean, predictable serialization of the tree, with the same block structure every time, which is exactly what a block-based diff needs.

Because transactions are small and explicit, they are also what live co-editing is built on: ProseMirror's collaboration module sends steps through a central server that puts them in order and rebases the late ones (the idea of operational transformation), and Tiptap can also sit on a CRDT such as Yjs for offline-capable co-editing. {{brand}} does not edit a note live in two places at once. It checks the version on save and merges blocks, as above: far simpler to run, at the price of an occasional clash a person must settle. Stored HTML from an editor must still be sanitized again before it is shown, because the schema protects only what passes through the editor (see the sign-in lesson).

The same idea keeps two systems in step Two-way sync with an outside tracker keeps, for every field, the last agreed value of both sides - the base. A side changed a field only when its value differs from its own base, so the sync's own write coming back is never mistaken for a change, which stops echo loops. When both sides changed one field, the later change wins and both values are written into the card's history.

Keeping every open screen current

After a save, other people's screens must catch up. How the message travels - WebSockets through SignalR - is the subject of the real-time lesson. Here the questions are what to send and what each screen does with it, so that the cost grows with the change and not with the size of the board.

In the example, hasMore is true while later pages are not loaded yet; a card that now sorts past the end of the loaded part waits for its page instead of being added.

The browser must sort exactly like the server Folding works only if the browser orders items the same way the database does - every sort, including the final tie-break by id. Otherwise a refreshed card lands one place off and jumps when the next page loads. Keep the client's sort next to a test that mirrors the server's ORDER BY.

The options in the market

What {{brand}} chose, and why

Another choice is right in other situations. Pick string keys such as LexoRank when a list is huge and re-spacing it would be slow, or when clients create items offline. Pick a CRDT such as Yjs for a rich-text editor or a whiteboard that many people type into in the same second, or for an offline-first app. Push the data when messages are small, final and addressed to people the server has already chosen.

Thousands of items: virtualized lists

A browser slows down when it holds thousands of complex elements: layout, styles and memory all grow with the count. A virtualized list keeps the data of every loaded item but creates elements only for the rows in view, plus a margin called overscan so a quick scroll never shows a gap. Two spacers above and below take the height of the rows not drawn, so the scrollbar behaves as if every row were there.

Switch it on only when it pays Short lists gain nothing from virtualization and still pay for it - measuring, spacers, harder focus handling. Turn it on past a threshold, and wrap every row in the same element either way, so crossing the threshold does not recreate every row on screen.

A row that is not drawn cannot hold focus The browser's own Find and the keyboard cannot reach a row that has no element. When a keyboard action sends an item out of sight, scroll it into view first, wait for it to be drawn, then move focus to it.

Roll-ups over a hierarchy

A parent card's progress comes from its children, and theirs from their children. Computing that on read means walking the tree every time a board opens - and boards are read far more often than they change. {{brand}} computes it on write instead, a form of denormalization: after a change, the touched card is recalculated, and the walk climbs to its parent only while something changed.

A roll-up can use weights: here each child has a share, and the parent's figure is the weighted average. Shares can start as an even split that totals exactly 100 - three children read 34, 33 and 33 - so the sum never drifts. Anything that depends on the clock, such as how late a task is, cannot be stored on write: compute it when the item is served.

When a board's calculation rules change, every card is recalculated once, leaves first - a post-order walk of the tree - so each parent reads children that are already up to date.

Guard the tree before you roll it up A roll-up over a tree must refuse cycles - an item placed under its own descendant - and cap the depth, or one bad link loops forever. Refuse cycles when you create a link by walking the full chain of ancestors, and check the stored tree, not only what the current screen shows.

History from soft-deleted memberships

A card is in a column because a membership row joins the two. When the card leaves, {{brand}} does not delete that row; it stamps the time it left - a soft delete. The live rows say where cards are now, and all the rows together are each card's history: entered when the row was created, left when it was deleted. Measures such as the time spent in a stage are read from these rows instead of from a separate event log.

Two rules keep it correct A unique index must cover live rows only, otherwise an item could never join the same group twice. And when you read deleted rows on purpose, never widen who may see them: first find the records the person may see with the normal query, then read the history of those ids only.

Try it yourself

You need PostgreSQL (or SQLite) and, for the second half, a small React app with TanStack Query. Each step lets you meet one problem from this lesson.

Common mistakes

Interview questions

Related