NEWSLayers closes first external funding round led by LOI VentureRead more
‹ All Articles

How We Made Drag-and-Drop Sorting Faster with Fractional Weights

Francis Kinyanjui7 min read

Originally posted on Medium.

Read the original

Every time a user dragged a product item to a new position in our Layers dashboard, we were re-sorting and renumbering the entire list. For a list of 100 items, that meant touching all 100 records on every single move, and it showed. Reorder operations were sluggish, the UI felt unresponsive, and the backend was doing far more work than it needed to.

We needed a data structure that would let us reorder items with constant-time updates instead of linear-time renumbering. The solution: fractional indexing, a well-known technique where each item’s sort order is represented by a numeric weight, and repositioning an item is just a matter of computing a midpoint. Here’s how we implemented it and what we learned along the way.

Diagnosing the problem

The issue surfaced during routine profiling of our dashboard interactions. Dragging items in long lists produced noticeable frame drops. The root cause wasn’t the drag-and-drop library itself; it was our positioning logic.

The Old Way: Renumbering Everything

Before weights, moving an item meant renumbering every item in the list. Here’s what that looked like:

const updatedPinnedDocuments = prevData
  .filter((pin) => pin.id !== id)
  .map((pin, index) => ({ ...pin, position: index + 1 }))

This removes the item with the given id and renumbers everything from scratch. There's no smart repositioning — the item is gone and the rest close the gap. Moving an item was similarly expensive:

const updatedPinnedDocuments = [...currentPinsWithoutMovedDocs, swapPin]
  .sort((a, b) => a.position - b.position)
  .map((pin, index) => ({ ...pin, position: index + 1 }))

This removes the moved item, inserts it at its new position, then renumbers the entire list. It works — but it touches every single record on every single move.

Here’s a full example of the old approach:

const items = [
  { id: 1, position: 1 },
  { id: 2, position: 2 },
  { id: 3, position: 3 },
  { id: 4, position: 4 },
]

let currentItems = [...items]
const setItems = (updater) => {
  currentItems = updater(currentItems)
}

const moveItem = (id, newPosition) => {
  if (newPosition > currentItems.length + 1) return
  setItems((prevData) => {
    const newItems = [{ id, position: newPosition - 1 }]
    const itemsWithoutMoved = prevData.filter(
      (item) => !newItems.some((newItem) => newItem.id === item.id),
    )
    const updatedItems = [...itemsWithoutMoved, ...newItems]
      .sort((a, b) => a.position - b.position)
      .map((item, index) => ({ ...item, position: index + 1 }))
    return updatedItems
  })
}

Every move = O(n). Re-sort, re-index, update every record. For a list of 100 items, that’s 100 writes to state and potentially 100 PATCH requests to the backend, you can imagine .

Alternatives We Considered

Before landing on fractional weights, we evaluated a couple of other approaches:

  • Linked-list ordering — each item stores a nextId pointer. Repositioning only updates a few pointers, but reading the list in order requires traversing the chain, which makes rendering and querying harder. It also doesn't play well with database ORDER BY clauses.
  • String-based fractional indexing — libraries like fractional-indexing by Rocicorp use lexicographic strings (e.g., "a0", "a0V") instead of numbers to represent order. This sidesteps floating-point precision entirely and is especially popular in CRDT-based systems. We opted for numeric weights because our lists were bounded in size and we wanted the simplest possible implementation — but string-based indexing is the more robust choice for collaborative or unbounded lists.
  • CRDT fractional indexing — a more formal version of the above, designed for conflict-free replication across peers. Evan Wallace has an excellent visual explainer of how this works. Overkill for our use case, but worth understanding if you’re building multiplayer features.

The New Way: Fractional Weights

Instead of storing sequential positions (1, 2, 3, 4...), we store fractional weights that represent order. The key insight: to move an item between two others, compute the midpoint of their weights. No renumbering, no touching other items just one update with one move (adding, updating position, deletion).

Setting Up Weights

Start with a base weight of 1000 for the first item. Each subsequent item gets base + 1000, base + 2000, and so on, the initial items are spaced by the "base" in this case 1000:

item 1: weight 1000
item 2: weight 2000
item 3: weight 3000
...

The large initial gap is deliberate and it gives you room to insert many items between any two neighbors before the gap gets dangerously small.

Moving an Item

When you move item 81 to position 62 for example, you don’t care about item 81’s old weight. You focus entirely on where it’s going — the weights of items currently at positions 61 and 62:

new weight = (weight[61] + weight[62]) / 2

Assign this new weight to the moved item. Done. One update, zero renumbering.

Edge cases:

  • Moving to first position: halve the weight of the current first item
  • Moving to last position: add the base weight (1000) to the last item's weight
function getNewWeight(prevWeight, nextWeight, baseWeight = 1000) {
  // moving to first position
  if (prevWeight === null) return nextWeight / 2

  // moving to last position
  if (nextWeight === null) return prevWeight + baseWeight

  // moving between two items
  return (prevWeight + nextWeight) / 2
}

The mathematical computation is O(1) — instead of touching every record, you update exactly one.

The Floating-Point Catch

Here’s the part most implementations skip over, and where things can silently break.

JavaScript uses 64-bit floating-point (IEEE 754) for all numbers. When you keep halving the gap between two weights, you eventually run out of precision and two values become exactly equal. At that point, your sort order collapses. The IEEE 754 specification guarantees about 15–17 significant decimal digits for doubles, which determines exactly how many halvings you get.

With a base of 1000 and gaps starting at 1000, you get roughly 40+ halvings before adjacent weights collapse near the low end — and fewer near the high end of a large list (e.g., weights up to ~100,000).

For a list of 100 product items where people are actively reordering, this is more than enough for typical use. But if someone repeatedly inserts into the exact same gap, you’ll hit the precision wall.

The Fix: Rebalance Before Collapse

When two adjacent weights get too close, rebalance — redistribute all weights evenly across the list.

The question is: what threshold should trigger a rebalance? With a base of 1000, using 1 as the threshold is a safe, practical choice:

1000 → 500 → 250 → 125 → 62.5 → 31.25 → ... → 0.97
                                                  ↑
                                    triggers rebalance at gap  ({
      ...item,
      weight: BASE_WEIGHT * (index + 1)
    }))
}

The Results

Here’s how the two approaches compare in terms of complexity:

Here’s how the two approaches compare in terms of complexity:

Operation     Old approach                  Fractional weights
---------------------------------------------------------------
Move item     O(n) — re-sort + renumber     O(1) — update 1 item
Delete item   O(n) — renumber all after     O(1) — update 0 items
Add item      O(n) — renumber all after     O(1) — update 1 item
Rebalance     Every operation (implicit)    Rare, on-demand — O(n)

We benchmarked both approaches by running 500 operations per list size, averaged across 4 runs. The computation speedup alone is significant , 11x faster for moves and up to 17x faster for adds at scale:

Operation   List Size   Old (ms)   New (ms)   Speedup
------------------------------------------------------
Move        100         12.37      6.12       2.0x
Move        500         54.05      5.63       9.6x
Move        1,000       100.22     8.85       11.3x
Add         100         20.95      1.33       15.7x
Add         500         44.95      2.53       17.8x
Add         1,000       73.29      4.42       16.6x

But the real win isn’t computation time, it’s how many records you touch. Each write is a state update and potentially a backend API call:

Operation   List Size   Old Writes   New Writes   Reduction
------------------------------------------------------------
Move        500         250,000      750          333x
Move        1,000       500,000      500          1,000x
Delete      1,000       374,750      0            ∞
Add         1,000       625,250      500          1,250x

Fewer writes means fewer re-renders, less network overhead, and a snappier UI. Rebalances — the one scenario where the new approach does touch every record — occurred 0 times across 500 random operations at a list size of 1,000.

Tradeoffs

This approach isn’t free. A few things to be aware of:

  • Rebalancing is O(n) and requires updating every item — including on the backend. You’ll want to batch this into a single API call rather than firing N individual updates.
  • Weights are opaque — unlike sequential positions, fractional weights don’t have human-readable meaning. Debugging “why is item X at weight 1536.5?” is harder than “why is it at position 3?”.
  • Concurrency — if two users simultaneously insert into the same gap, they might compute the same midpoint. For our use case (single-user dashboard), this wasn’t a concern. For collaborative editing, look into string-based fractional indexing or CRDTs.

What We Learned

We overlooked item ordering for months because it seemed trivial. It wasn’t. A better data structure was all it took to fix our issue.

If you’re dealing with ordered lists that get rearranged frequently, fractional indexing is a solution worth looking into.

References