Ordering items in a list is a problem that sounds trivial until you’re in a
multiplayer environment. When multiple users can drag, insert, and delete items
at the same time, simple array indexing falls apart quickly.
Alice
Fix dropdown alignmentLB-242
Add keyboard shortcutsLB-238
Rewrite onboarding copyLB-229
Resolve memory leakLB-221
Major launchLB-215
Bob
Fix dropdown alignmentLB-242
Add keyboard shortcutsLB-238
Rewrite onboarding copyLB-229
Resolve memory leakLB-221
Major launchLB-215
Drag-and-drop items in this multiplayer list
Fractional indexing is an elegant solution to this problem, often used in
CRDT-based platforms; it’s how collaborative applications like Figma and Linear
order realtime lists, and it’s the way we designed our
conflict-free sync engine.
red
*
purple
O
blue
u
Let’s take a look at how it works and some challenges we solved.
In a single-player app, ordering a list isn’t a problem. You have an array, each
item has an index, and when an operation changes the list, the indices update
accordingly. Let’s say you have a list of three items.
["red", "purple", "blue"]
red
0
purple
1
blue
2
Picture inserting an item into position 1. Every item after it has to adjust
its index by 1 to make room for the new value. For example purple goes from
index 1 to 2. A single insert affects every item after its inserted
position.
This also occurs when reordering. Drag an item to a new position and multiple
items need a new index.
red
0
yellow
1
purple
2
blue
3
Try dragging boxes to new positions
In the log, you can see exactly which items updated on each change.
Regular arrays work fine locally, but in multiplayer every shifted index is a
change that has to travel over the network, greatly increasing the amount of
data that needs to be synched and sent.
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Insert an item
Additionally, when users simultaneously edit the same list, their changes can
conflict, causing the list to diverge.
Picture Alice inserting an item while Bob deletes another. Each user works from
their own local state, and when the two clients sync, they end up applying each
other’s operations on top of different arrays, resulting in a conflict.
Aliceonline
purple
0
blue
1
Bobonline
purple
0
…
…
Alice inserts and Bob deletes
The same thing happens when two users simultaneously add an item. Both clients
“insert at index 1”, but once the operations merge, there’s no canonical order
to agree on.
Aliceonline
purple
0
Bobonline
purple
0
…
…
Alice and Bob both add an item
In a multiplayer environment, every client needs to converge to the same list,
regardless of the order in which concurrent edits arrive. Each item needs a
stable position of its own, independent of other items.
Fractional indexing flips the model. Instead of an index being derived from an
item’s position in a sequence, each item carries its own position value—a value
that naturally sits between its neighbors. It’s easiest to think about these
fractional indices as decimal numbers between 0 and 1. Below, red’s index
is 0.1.
"red" → 0.1; "purple" → 0.5; "blue" → 0.9
red
0.1
purple
0.5
blue
0.9
Because indices are fractions, an infinite number of values can fit between any
two existing indices—you can always add another point of precision, e.g. between
0.5 and 0.6 you can pick 0.55, then 0.555, and so on. This means you
only update a single index at a time, even when moving an item, because there’s
always room for a new value between any two existing indices.
When inserting an item, calculate its index by picking the halfway point between
its neighbors. For example, when adding an item between 0.1 and 0.5, pick
0.3.
Click on the empty box
To insert an item at the start, pick the halfway point between 0 and the first
item. The same applies in reverse for inserting at the end, with the last item
and 1.
Click on the empty box
You’ll notice that when an item is added or removed, every other item is
unaffected. Moving items works the same way, drag an item to a new position and
only one index updates. All others items keep their original position.
Because each item’s position is independent, fractional indexing merges
consistently, unlike regular array indexing. Operations are short and
self-contained, only needing a single message to be sent.
…24
…18
…97
…60
…13
…31
…80
…66
…50
…05
…49
…84
…02
…48
…34
…54
…43
…66
…24
…51
…89
…68
…94
…05
…16
Insert an item
Additionally, both clients can replay each other’s operations and end up with
the same result.
There is an edge case, however. What happens when two users independently pick
the exact same index? Both see the new item locally, but after sync each side
orders its own insert before the other’s, and the lists end up in a different
order. We have a conflict.
Aliceonline
purple
0.5
Bobonline
purple
0.5
…
…
Alice and Bob both insert at the same spot
A solution for this is to assign each user a unique ID, and append this to their
indices. For example, if Alice’s ID is 001, and Bob’s is 002, pushing an
item to index 0.75 would result in 0.75-001 for Alice and 0.75-002 for
Bob.
Aliceonline
purple
0.5
Bobonline
purple
0.5
…
…
Alice and Bob insert with unique suffixes
Because no two clients share an ID, no two indices can ever be truly equal, and
both sides converge on the same order. We won’t mention this in the rest of the
article, as it makes explanations more complex, but bear in mind that it’s part
of the solution.
While decimals work as a mental model, they break down in practice. Floating
point numbers have limited precision, and after enough subdivisions, two
positions quickly become indistinguishable due to rounding. It only takes about
50 inserts at the end of the list to hit the precision limit, breaking the
system.
red
0.99…062
purple
0.99…921
blue
0.99…292
"blue" → 0.9999999999999292
"purple" → 0.9999999999953921
"red" → 0.9999999999919062
If these digits are instead stored as strings, the system is much more robust,
and the precision limit won’t be hit.
red
0.99…062
purple
0.99…921
blue
0.99…292
"blue" → "0.9999999999999292"
"purple" → "0.9999999999953921"
"red" → "0.9999999999919062"
While there’s no longer a limit, you’ll notice another problem—indices quickly
become large when repeatedly pushing items to the end of a list. We can fix
this.
At Liveblocks, we provide conflict-free lists to companies, which makes us
uniquely positioned to understand how realtime arrays are used in production. We
discovered that customers often repeatedly inserted items into the same position
in the list, causing index lengths to spiral.
By far, the most commonly used array operation used is push, specifically
pushing items to the end of a list. This can be quite a problem in some apps, as
you can end up with indices that are as long as the values themselves, if not
longer.
If you have a list with thousands of items, a significant chunk of your data
will be indices, and downloading it is going to slow down your app. Previously,
we’d pick the midpoint for new indices, but for this situation, we use a
different approach.
When users push items to the end of the list, we use a different algorithm to
calculate indices—as soon as the last item in the list needs an extra digit, we
grow the index by 3 characters at once, then only increment one step at a time,
instead of using the halfway point.
For example, after 0.9, we jump straight to 0.9001, and then increment by
only 0.0001 at a time for new items.
red
0.3
purple
0.5
blue
0.8
"blue" → "0.8"
"purple" → "0.5"
"red" → "0.3"
Using this new solution, we can keep pushing to the same position without
worrying about the index size. Once the index needs anotehr character, we jump
toi another viewport, for example from 0.999 to 0.999001.
red
0.996
purple
0.997
blue
0.998
"blue" → "0.998"
"purple" → "0.997"
"red" → "0.996"
Note that we skip past .9000 because trailing zeros aren't allowed—they’d
represent the same value as .9.
The difference between the two approaches quickly becomes apparent when you
start pushing tens of items to the end of the list. The viewport-based approach
stays reasonable, whereas the naive approach quickly becomes impractical.
Naive growth
red
0.3
purple
0.5
blue
0.8
yellow
0.9
"0.9"
Viewport-based growth
red
0.3
purple
0.5
blue
0.8
yellow
0.9
"0.9"
4 total items
Here’s a comparison showing the amount of additionally data used for keys in
each approach.
Base 95 is a numeral system made up of the 95 printable
ASCII characters, starting at the space
character and ending at tilde ~.
Hover over a character
Instead of using decimals as indices, you can use base 95 values instead, such
as ! and %.
"red" → !; "purple" → %; "blue" → )
red
!
purple
%
blue
)
After convering a few values from decimals, the real benefit of base 95 becomes
apparent—only the first 10 characters of the alphabet are used, the same 10
digits decimal gave us.
Input0.1234567
Output""
!
"
#
$
%
&
'
(
)
*
+
,
-
.
/
0
1
2
3
4
5
6
7
8
9
:
;
<
=
>
?
@
A
B
C
D
E
F
G
H
I
J
K
L
M
N
O
P
Q
R
S
T
U
V
W
X
Y
Z
[
\
]
^
_
`
a
b
c
d
e
f
g
h
i
j
k
l
m
n
o
p
q
r
s
t
u
v
w
x
y
z
{
|
}
~
The real benefit of using base 95 is when you start using the extra 85 chars,
and which you can slot between decimal values. This way we can represent
values that weren’t expressible in decimal, without having to grow the length of
the string.
The above base 95 representation is an elegant solution for indices because
ASCII values can simply be lexicographically compared to define their natural
ordering. Try it with 2 values below.
A<[
Most languages support string comparison without a custom sort function.
Let’s try the earlier demos with base 95. Reordering items works the same way;
we’re just storing the base 95 value instead of the decimal. You’ll notice that
the indices are much shorter.
red
!
yellow
#
purple
%
blue
)
Try dragging boxes to new positions
Pushing new items at the end is no different: each press computes a position
that will be lexicographically greater than the last item. This demo uses the
viewport-based growth algorithm from earlier, now rendered in base 95.
At Liveblocks, we use fractional indexing inside
Liveblocks Storage,
specifically LiveList, which
provides a realtime multiplayer array you can use in your app.
// Create a listconst squares =newLiveList(["red","blue"]); // Run conflict-resolved operationssquares.move(0,1);squares.insert("yellow",1);squares.delete(2);
You can also nest complex data structures inside LiveList, such as
LiveObject and
LiveMap—conflict-resolved
objects and maps.
// Create a listconst squares =newLiveList(); // Create a complex conflict-resolved objectconst square =newLiveObject({ color:"red", coords:newLiveMap({ x:100, y:50,}),}); // Add it to the listsquares.push(square);
Make a change to any part of your data structure, and it’ll be merged
automatically across all clients.
Building a realtime list of issues, like in the example below, becomes a trivial
task with Liveblocks Storage.
Alice
Fix dropdown alignmentLB-242
Add keyboard shortcutsLB-238
Rewrite onboarding copyLB-229
Resolve memory leakLB-221
Major launchLB-215
Bob
Fix dropdown alignmentLB-242
Add keyboard shortcutsLB-238
Rewrite onboarding copyLB-229
Resolve memory leakLB-221
Major launchLB-215
Drag-and-drop items in this multiplayer list
First, define your data structure in TypeScript, in this case each issue has a
title, a ref, a priority, and an assignee.
import{LiveList,LiveObject}from"@liveblocks/client"; // Define an issue typetypeIssue=LiveObject<{ title:string; ref:`LB-${number}`; priority:string|null; assignee:string|null;}>; declare global {interfaceLiveblocks{Storage:{// Add a LiveList of issues to root storage issues:LiveList<Issue>;};}}
In React, add useStorage to
fetch the realtime issues, and render them on the page.
import{ useStorage }from"@liveblocks/react/suspense"; functionIssuesList(){// Get a list of issuesconst issues =useStorage((root)=> root.issues); return(<main>{/* Render the list of issues */}{issues.map((issue)=>(<divkey={issue.ref}><span>{issue.title}</span><span>{issue.priority}</span>{/* ... */}</div>))}</main>);}
useMutation allows you to
modify your data structures, for example to create a new issue.
import{ useStorage, useMutation }from"@liveblocks/react/suspense";import{LiveObject}from"@liveblocks/client"; functionIssuesList(){// Get a list of issuesconst issues =useStorage((root)=> root.issues); // Create a new issue and add it the listconst newIssue =useMutation(({ storage })=>{const issues = storage.get("issues"); const newIssue =newLiveObject({ title:"Untitled", ref:`LB-${issues.length +1}`, priority:null, assignee:null,}); issues.push(newIssue);},[]); return(<main>{/* Render the list of issues */}{issues.map((issue)=>(<divkey={issue.ref}><span>{issue.title}</span><span>{issue.priority}</span>{/* ... */}</div>))}{/* Button to create a new issue */}<buttononClick={newIssue}>➕ New issue</button></main>);}
You can even create mutations that modifies deeply nested data structures, for
example one that updates the priority of a specific issue.
import{ useStorage, useMutation }from"@liveblocks/react/suspense";import{LiveObject}from"@liveblocks/client"; functionIssuesList(){// Get a list of issuesconst issues =useStorage((root)=> root.issues); // Create a new issue and add it the listconst newIssue =useMutation(({ storage })=>{const issues = storage.get("issues"); const newIssue =newLiveObject({ title:"Untitled", ref:`LB-${issues.length +1}`, priority:null, assignee:null,}); issues.push(newIssue);},[]); // Update the priority of an issueconst updatePriority =useMutation(({ storage }, issueRef:string, priority:string)=>{const issues = storage.get("issues");const issue = issues.find((issue)=> issue.ref=== issueRef); issue.set("priority", priority);},[]); return(<main>{/* Render the list of issues */}{issues.map((issue)=>(<divkey={issue.ref}><span>{issue.title}</span><Selectvalue={issue.priority}onChange={(priority)=>updatePriority(issue.ref, priority)}/>{/* ... */}</div>))} {/* Button to create a new issue */}<buttononClick={newIssue}>➕ New issue</button></main>);}
Fractional indexing is a tiny idea with an outsized impact. By giving each item
its own positional value instead of a deriving it from a sequence, you get a
list that merges cleanly across clients, sends minimal data over the network,
and allows concurrent edits to converge without conflicts.
If you’d rather not build fractional indexing from scratch,
try Liveblocks Storage, as we’ve already done
it for you—focus on your app instead of the ordering problem underneath it.