Drag a card, write one row - fractional indexing on the issues board
How Rezee persists Linear-style drag reordering - the lexicographic midpoint algorithm behind issues.rank, measured key growth under adversarial dragging, why float ranks die at exactly 53 moves, and a 5,000x difference in rows written.
Aug 4, 2026 · 11 min read · Kash Gohil
Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs. Its issues board is the Linear-shaped kind: columns of cards you drag, and the order you leave them in is the order everyone sees. Persisting that order sounds trivial until you write the obvious version - position integer - and realize every drag renumbers half the column. This post is about the three-line algorithm that makes a drag cost exactly one written row, what it looks like under torture, and the measured reasons behind every choice - including why the obvious clever alternative, float ranks, fails at precisely 53 drags.
The benchmark harness is scripts/bench/fractional-rank in our repo; the algorithm the numbers describe is the same one quoted below.
What's actually stored?
One text column, from packages/power/src/schema.ts:
// Manual ordering within a board column (lexicographic fractional index).
rank: text("rank").notNull().default("n"),and one ORDER BY, from the issues module:
.orderBy(issues.rank, issues.createdAt)That's the entire storage story. The rank is an opaque string ordered by ordinary text comparison; createdAt breaks ties (more on why ties exist later). The default is "n" for a reason that summarizes the whole design: n is the midpoint of the alphabet. A new issue lands in the middle of the key space with maximum room on both sides.
The client computes ranks and the server stores them - the API takes rank as an optional string on update. This is a deliberate trust split: card order is workspace-cosmetic state with no authorization or integrity consequences, so the server's only job is to store what the client saw fit. The heavy validation you'd want for, say, a state transition would be waste here; the worst a buggy client can do is misorder its own board, and the fix is another drag.
How does the midpoint algorithm work?
The whole trick is doing to strings what you'd naively do to numbers. To insert between two keys, find a string strictly between them - treating each character as a digit, a = 0 through z = 25:
const ALPHABET = "abcdefghijklmnopqrstuvwxyz";
const BASE = ALPHABET.length; // 26
export function rankBetween(lo: string | null, hi: string | null): string {
const a = lo ?? "";
const b = hi ?? "";
let out = "";
for (let i = 0; ; i++) {
const l = i < a.length ? ALPHABET.indexOf(a[i]) : 0;
const h = i < b.length ? ALPHABET.indexOf(b[i]) : BASE;
if (h - l > 1) {
return out + ALPHABET[Math.floor((l + h) / 2)];
}
// Gap of one (or zero digits left): copy the low digit, go deeper.
out += ALPHABET[l];
}
}Worked examples make the shape obvious:
- Insert above
"n"(top of column): bounds arenulland"n", so digits 0 and 13 - midpoint 6 →"g". - Insert between
"n"and"o": the gap between digits 13 and 14 is 1, so no character fits between them at this position. Copy the low digit (n), recurse one position deeper with fresh bounds 0..26 → midpoint 13 →"nn". Text comparison puts"nn"strictly between"n"and"o", because"n" < "nn"by the shorter-prefix rule. - Insert between
"a"-adjacent keys: the two virtual pads do the edge work -loextends witha(digit zero) andhiextends with a virtual digit 26, one pastz. That asymmetry is also why no generated key ever ends ina: a trailingais a trailing zero, adding nothing, and the algorithm structurally can't emit one - when a gap closes it copies and recurses rather than returning.
Two invariants fall out: the result is always strictly between the bounds (so ordering never has to renumber), and key length grows only when you split a gap of 1 - which is the interesting cost, so we measured it.
How fast do keys actually grow?
Three insert patterns, 500 inserts each, key length sampled continuously:
- Random positions - the real workload - is flat. After 10,000 random-position inserts, the average key is 3.9 characters, p99 is 6, and the longest key on the board is 8. Real boards don't have a hundred issues, let alone ten thousand.
- Adversarial patterns grow linearly, slowly. Dropping a card into the same gap 500 consecutive times produces a 101-character key - almost exactly 5 inserts per character, which is the information-theoretic prediction: each character carries log2(26) ≈ 4.7 bits of bisection. Always-inserting-at-the-top is marginally worse (126 chars) because the top gap keeps shrinking toward the
awall. - The keys are self-describing about the abuse. A long rank isn't corruption; it's a record that someone kept dragging into the same slot. It still compares correctly, still needs exactly one row per move, and any later drag out of the hot gap gets a short key again.
We deliberately don't rebalance. The arithmetic says we don't need to: at 5 inserts per character, a pathological user dragging into one gap once a minute for an entire workday grows a key to ~100 characters - about the length of a URL, in a text column that could hold a gigabyte. Rebalancing machinery (rewriting a column's keys to short strings) would exist to protect against a cost we measured and found ignorable. If telemetry ever shows real boards with 50-character ranks, a rebalance-on-write is a 20-line addition; until then it's complexity with no customer.
Why not a float column? (the 53-drag cliff)
The tempting alternative: rank double precision, midpoint = (lo + hi) / 2. It's the same algorithm with hardware arithmetic, and it has a failure mode you can predict from the IEEE 754 spec sheet and confirm in the benchmark: a float64 mantissa has 52 bits, so repeated same-gap bisection runs out of representable midpoints after ~52 halvings. Measured: the 53rd same-gap insert returns a midpoint equal to one of its bounds. Insert 53 and 54 get the same rank, ordering becomes nondeterministic, and no further insert in that gap can ever fix it - the representable space is simply gone.
Fifty-three sounds like a lot until you remember it's cumulative per gap for the board's lifetime, invisible until it happens, and unrepairable without a renumbering migration - the exact operation the scheme existed to avoid. Strings degrade by getting longer; floats degrade by silently lying. That asymmetry, not elegance, is the argument.
What does a drag cost the database?
The measured comparison, on a 10,000-row board column in Postgres, moving a card to the middle 500 times:
| strategy | rows written per move | 500 moves total | wall time |
|---|---|---|---|
position integer renumbering |
5,002 | 2,501,000 rows | 6,388 ms |
rank midpoint |
1 | 500 rows | 92 ms |
The integer column's 5,002 rows per move is structural: everything between the old and new slot shifts by one. And each of those writes is a full MVCC row version with dead-tuple cleanup behind it - the churn analysis from our queue post applies here at 5,000x the volume, on a table users are actively reading. The rank update touches one row, and because rank isn't indexed (the board reads a whole column and sorts in the query), the update stays HOT-eligible when pages have headroom - the same page-pruning behavior we measured there.
Wall time follows the rows: 92ms vs 6.4 seconds for the same 500 user actions. The fine print: the integer numbers are per move, in a transaction, on localhost - production adds lock contention between concurrent draggers, which the rank scheme structurally cannot have (two drags touch two unrelated rows).
What about two people dragging at once?
Concurrency is where the scheme's honest limitation lives. If two users concurrently drop different cards into the same gap, both clients compute the same midpoint - same bounds, deterministic algorithm - and two rows end up with equal ranks. The schema's answer is the tiebreak you saw at the top: ORDER BY rank, createdAt. Equal ranks render in creation order, identically for every viewer, no flicker, no error. The next drag involving either card computes against on-screen neighbors and re-separates them.
This is last-write-wins semantics chosen on purpose. The alternatives - server-computed ranks (a read-modify-write per drag, serialized per column) or real conflict resolution (CRDT-grade machinery for a kanban column) - buy consistency no one can perceive. A board is not a merge queue; the cost of being briefly, deterministically tied is zero.
Try it
The same algorithm, five cards, live rank strings. The torture button is the same-gap experiment from the growth chart:
This is an interactive demo - it needs JavaScript. Reorder five issues with arrow buttons and watch each move compute a new rank between its neighbors (one row written per move, against the running count an integer column would have written). A torture button drops a card into the same gap twenty times so you can watch the key grow at about one character per five inserts.
How do you reproduce this?
docker compose -f scripts/bench/compose.yml up -d --wait
bun scripts/bench/fractional-rank/run.tsrank.ts in that directory is the reference implementation; run.ts runs the growth curves in-process and the write-cost comparison against real Postgres (the rank side mirrors the real client flow - neighbors come from the board already in memory, so the database sees exactly one UPDATE per move; the integer side uses the park-shift-place three-step so positions never collide mid-move).
What doesn't this prove?
- The write-cost gap is workload-shaped. Moving cards to the middle of a large column is integer renumbering's worst case; moving a card one slot is its best (3 rows). The rank scheme writes 1 row in every case - the point is the variance, not just the ratio.
- 10,000-row columns are not real boards. Real columns hold dozens of issues; at that size even renumbering is fast. The scheme's value at real scale is the concurrency story (no shift-range lock conflicts) and the single-row semantics, not raw speed.
- The growth numbers assume this alphabet. Base-26 gives 4.7 bits/char; a base-62 alphabet (add A-Z, 0-9) gives ~5.9 and grows ~25% slower, at the cost of case-sensitivity footguns in collations and URLs. We took lowercase-only for boringness, and the measurements say the margin doesn't matter.
FAQ
Why not LexoRank-style buckets like Jira?
LexoRank adds bucket rotation - a background rebalance that migrates keys between three buckets - to bound key length. That's real machinery: a scheduled job, a migration state machine, and ordering semantics that depend on which bucket is active. Our measurements put the problem it solves ("keys might get long") at ~100 characters after 500 adversarial same-gap inserts, in a text column where that costs nothing. We'd rather carry a known-ignorable cost than an unbounded amount of rebalancing code.
Why does the client compute the rank instead of the server?
Because the client is the only party that knows what the user meant - "between these two cards as rendered on my screen." Sending intent ("move issue 12 above issue 15") and computing server-side means the server re-reads neighbors, opening the read-modify-write race the client-side version doesn't have, and serializing concurrent drags per column. Sending the computed rank makes the write a blind single-row UPDATE - the same idempotent, race-free shape as the claim guard - at the cost of trusting the client with its own board order, which is a safe thing to trust it with.
Doesn't ORDER BY on a text column get slow?
Text comparison is more expensive per-compare than integer comparison, but a board column sorts dozens of short strings per render - nanoseconds of difference. At the scale where it mattered you'd index (workspace_id, status, rank) and accept the HOT-update trade-off we measured for the queue table. Today the honest answer is: unindexed, unsorted-on-disk, and unmeasurable against everything else a board render does.