Designing Instagram's Grid Reordering: User-Defined Order in a System Built on Chronology
Instagram began rolling out grid reordering to everyone in June 2026. Using it as a case study, this article designs user-defined ordering for a large service that has always been chronological: the data model, sort-key algorithms, concurrent updates, pagination, caching, and backward compatibility, with code I ran to check the key numbers.
On June 8, 2026, Instagram began rolling out a feature that lets everyone rearrange the posts on their profile grid in any order (MacRumors, ABC News). You long-press a post on the grid, choose Reorder grid from the menu, and drag. Changes are saved immediately and are visible to everyone who visits the profile. Until then the grid was reverse-chronological, and the only exception was pinning up to three posts at the top.
To the user, this is drag and drop. To the system, the order stops being something derived from creation time and becomes data that users write. Instagram has 3 billion monthly active users (announced in September 2025, CNBC). At that scale, how you store the order, assign its keys, and serve it determines write volume and cache behavior, and it can change what existing APIs return.
If I were adding this feature to a service of Instagram's size, this is the design I would choose:
- Store the order on the relationship between a profile and a post, not on the post.
- Keep chronological order as the default and write a sort key only for posts that the user moves. Keys are integers in the same range as the time-ordered 64-bit post IDs, and a moved post takes the midpoint of its neighbors' keys. Only when no midpoint is left are a few nearby keys relabeled.
- Serialize every write to a profile's grid (moves, new posts, deletions, and so on), and bump a grid version with each one.
- Return the list with a keyset cursor and key the caches by the grid version.
- Keep APIs and jobs that relied on creation time chronological, and add display order as a new, explicit concept.
Instagram's implementation is not public, so what follows is my design based on public information. I distinguish sourced figures, results of code that I ran, and assumptions made to get a sense of scale.
1. What the feature does and what the design assumes
1.1 What public sources say
Instagram head Adam Mosseri previewed the feature in January 2025 and, in a June 2025 post on Meta's newsroom, wrote that Instagram would "make it possible to re-order the posts on your grid" (Meta, TechCrunch). Reports of the June 2026 launch and how-to guides for users describe the behavior as follows.
| Behavior | Source |
|---|---|
| Any unpinned post, however old, can be moved to any position | News reports |
| Changes are saved immediately and shown to every visitor | News reports |
| Pinned posts stay fixed at the top | News reports |
| Pinned posts must be unpinned before they can be moved | How-to guides |
| The post's date does not change, and the post does not reappear in followers' feeds | How-to guides |
| A new post goes to the top of the grid, right after the pinned posts | How-to guides |
| A collaborative post can be moved on your own grid, but not on your collaborator's | How-to guides |
| The order of the Reels tab does not change | How-to guides |
The news reports are the MacRumors and ABC News articles relaying Meta's and Instagram's announcements. The how-to guides are by TechPP and Hopper HQ; they describe what their authors observed, so details may change. The sources also differ on saving: the news reports say changes are saved immediately, while the Hopper HQ guide says you tap Done to save. The design below supports both saving on every drag (Section 5.1) and saving a batch (Section 5.3).
1.2 Requirements I add
A large service needs a few more properties. From here on, these are my assumptions.
- The amount written per move must not grow with the number of posts on the grid.
- Most users will never reorder; their existing data must not be rewritten (no backfill).
- Serving the first page of a profile must not get slower. Profile views presumably far outnumber reorders.
- The person who reorders sees the change immediately. Other viewers may see it a few seconds later.
2. What chronological order gave for free
In 2011, Instagram described how it generates IDs for posts and other objects (Sharding & IDs at Instagram). The first requirement on the list was: "Generated IDs should be sortable by time (so a list of photo IDs, for example, could be sorted without fetching more information about the photos)". Each ID is 64 bits, laid out from the most significant bit as follows.
| 41 bits: milliseconds since a custom epoch | 13 bits: logical shard ID | 10 bits: sequence |If the shard is chosen from the user ID, as in that post's example, all of a user's posts land on one logical shard, and comparing two IDs compares their creation times. I could not find whether Instagram still uses this scheme; I assume IDs that increase with time like these. A chronological grid then has these properties:
- The order follows from the IDs alone. Reading each profile's list of posts through an index in descending ID order gives the first page, with no extra column.
- The cursor for the next page is just the ID of the last post seen.
- New posts always go to the head. A cached first page is updated by adding one post at the front and dropping one from the end.
- "The first N posts" means "the newest N posts".
The 2013 paper on TAO, the graph store of Facebook (now Meta), relies on the same properties (Bronson et al., 2013). TAO keeps each list of associations (edges) in descending order of a time field and caches only a contiguous prefix of each list. The paper calls the pattern in which most data is old but most queries ask for the newest items "creation-time locality" and builds on it. The time field is 32 bits; a footnote says it is actually a generic application-assigned integer, and the main text notes that many associations set it to their creation time. Writing display order into that field could represent any order; Section 7.1 explains why that idea fails.
Allowing reordering changes these properties:
| Aspect | Chronological grid | Reorderable grid |
|---|---|---|
| What determines the order | Post ID (creation time) | Data written by the user |
| What shifts positions | New posts and deletions | Also moves; every post between the old and new position shifts |
| Cursor for the next page | ID of the last post seen | Pair of sort key and post ID |
| Meaning of the first N posts | The newest N | The N chosen by the owner |
| Cached first page | Add one post at the front for each new post | Rebuild as a new version on every change to the grid, new posts included |
New posts still go to the top. What changes is that an old post can move anywhere.
3. Data model: where the order lives
3.1 The order belongs to the profile–post relationship
Making the order an attribute of the post (a column on the posts table) causes three problems.
The first is collaborative posts. One post appears on several profiles, and each owner orders it independently (the table in Section 1.1). The order describes which post sits where on which profile's grid, so it is an attribute of that relationship. If another list, such as the Reels tab, ever gets its own order, the kind of list becomes part of the primary key too.
The second is that a move would count as an update to the post. A move does not change the content. With the order on the post row, every move changes the post's update timestamp, and consumers that subscribe to post changes through change data capture (CDC), such as search indexing or feature pipelines for machine learning, receive an update event that means nothing to them.
The third is write cost. In PostgreSQL, updating a column that an index references rules out a heap-only tuple (HOT) update, so every index on the table gets a new entry (PostgreSQL documentation). Putting the order in a narrow table that represents only the profile–post relationship, instead of the heavily indexed posts table, limits each move to that table's indexes.
3.2 Store keys only for posts that moved
sort_key overrides the position given by the post ID. It is written only for posts that the user has moved and for the few neighbors a relabel rewrites (the exceptions are new posts, covered in Section 3.3, and keys reassigned on unarchive or unpin, described below). The effective key is the override if there is one and the post ID otherwise, computed as coalesce(sort_key, post_id), and the grid is sorted by it in descending order. Because post IDs increase with time, a grid that has never been reordered keeps its old order.
This design has several benefits:
- Existing rows of users who never reorder are never rewritten. Adding nullable columns to the existing list and building an index is enough to ship the feature (Section 3.3).
- A new post gets an ID larger than the existing keys, so it normally goes to the top without a key of its own. This requires that no overridden key exceed the IDs issued so far (Section 4.3).
- Resetting to chronological order only means clearing the overrides back to NULL. According to the how-to guides, Instagram does not offer this today, but in this design it would write only the overridden rows.
- Deleting a post never requires rewriting other posts' keys.
When a post is archived, its row is removed from the list, and it is inserted again when the post is unarchived. Where it reappears is a product decision: to restore its previous position, keep its key in the archive record; to return it to its chronological position, insert it with a NULL key. Either is normally a single-row write. While the post was archived, though, a move's midpoint or a relabel may have given another row the same effective key, so check before inserting it again; if the key is taken, assign a new one between its neighbors with the method in Section 4.3.
Pins stay separate from the main list, as before. The pinned posts (up to three) stay in their existing storage; they are placed first on the first page and excluded when the main list is read. An unpinned post returns to the position of its effective key. Pinned rows are left out of relabels, so on unpin, as on unarchive, check whether another row now has the same effective key and assign a new one if so.
3.3 Schema and queries
I assume that a list of the posts on each profile's grid (grid_entry) already exists. Showing collaborative posts on several profiles is likely to require such a list in some form. The design adds two nullable columns to it: the sort key, and a column used in Section 6.1 to look up a cursor's key again (not needed if you skip that step). In PostgreSQL, adding a column without a default only changes the table definition and does not rewrite existing rows (PostgreSQL documentation). The index can likewise be built with CREATE INDEX CONCURRENTLY without blocking writes (Section 7.3 covers running these in production). The grid_order row that holds the grid version is created the first time the profile's grid changes (a move, a new post, a deletion, and so on); a missing row means version 0.
-- Existing list: one row for each post on a grid
CREATE TABLE grid_entry (
profile_id bigint NOT NULL,
post_id bigint NOT NULL, -- 64-bit ID that increases with time
PRIMARY KEY (profile_id, post_id)
);
-- Two nullable columns; neither rewrites existing rows
ALTER TABLE grid_entry
ADD COLUMN sort_key bigint, -- NULL means the post ID's (chronological) position
ADD COLUMN moved_version bigint; -- version of the last position change (Section 6.1; optional)
-- Display order: effective key descending, then post ID descending
CREATE INDEX CONCURRENTLY grid_entry_display ON grid_entry
(profile_id, (coalesce(sort_key, post_id)) DESC, post_id DESC);
-- Grid version; the row is created on the first change and also serializes every write to the grid
CREATE TABLE grid_order (
profile_id bigint PRIMARY KEY,
version bigint NOT NULL DEFAULT 0 -- incremented on every change to the grid
);Every write that changes the grid (moves, new posts, deletions, archiving and unarchiving, and pin changes) starts its transaction by locking the version row and incrementing the version; in the move transaction shown below, that is the first INSERT statement. All writes take their locks in the same order to avoid deadlocks. A move locks the version row and then updates post rows. If a deletion were written to delete the post's row first and bump the version afterward, a move and a deletion of the same post could each wait for the other's lock; PostgreSQL detects that after deadlock_timeout (1 second by default) and aborts one of them (PostgreSQL documentation). Put the version bump in a single data-access function, and route through it the paths that change a grid without a user action, such as moderation takedowns or a collaborator leaving a collaborative post. Caches and cursors use the version (Section 6), and so do batched reorders (Section 5.3).
When adding a new post to the grid, compare its ID, after taking the lock, with the top effective key among unpinned rows. If a move to the top, or a relabel whose window reaches the top, commits between issuing the ID and taking the lock, a key it wrote can be larger than the new post's ID. If the ID is smaller, write a key for the new post the same way a move to the top does.
A system without a per-profile list can keep only the overrides in a separate table and merge them with the time-ordered list at read time (the same shape as in Section 3.4).
The first page and later pages differ by a single condition. A later page reads what comes after the effective key and post ID of the last row on the previous page.
-- First page (pinned posts are fetched separately and placed first)
-- $2 is the array of pinned post IDs; pass an empty array if there are none (NULL returns no rows)
SELECT post_id, coalesce(sort_key, post_id) AS k
FROM grid_entry
WHERE profile_id = $1
AND post_id <> ALL ($2::bigint[])
ORDER BY coalesce(sort_key, post_id) DESC, post_id DESC
LIMIT 24;
-- Later pages: $3, $4 are (k, post_id) of the last row on the previous page
SELECT post_id, coalesce(sort_key, post_id) AS k
FROM grid_entry
WHERE profile_id = $1
AND post_id <> ALL ($2::bigint[])
AND (coalesce(sort_key, post_id), post_id) < ($3, $4)
ORDER BY coalesce(sort_key, post_id) DESC, post_id DESC
LIMIT 24;On PostgreSQL 18.6 (PGlite), both queries are planned as an Index Scan on grid_entry_display. The row-value comparison for later pages is used as an index search condition (Index Cond), and excluding pinned posts is a filter applied after the index scan.
A move runs in one transaction. The destination is given as the post that should come immediately before the moved one (the anchor); Section 5.1 explains why.
BEGIN;
-- Lock the version row and increment it (creating it at version 1 if missing).
-- Every write that changes the grid starts with this statement
INSERT INTO grid_order (profile_id, version) VALUES ($1, 1)
ON CONFLICT (profile_id) DO UPDATE SET version = grid_order.version + 1
RETURNING version;
-- Read the anchor's effective key and that of the post right after it
-- ($pinned is the array of pinned post IDs; a move to the top goes between
-- the top post's effective key and a freshly issued ID)
SELECT coalesce(sort_key, post_id) AS k FROM grid_entry
WHERE profile_id = $1 AND post_id = $anchor;
SELECT coalesce(sort_key, post_id) AS k FROM grid_entry
WHERE profile_id = $1 AND post_id <> $moved AND post_id <> ALL ($pinned)
AND (coalesce(sort_key, post_id), post_id) < ($anchor_k, $anchor)
ORDER BY coalesce(sort_key, post_id) DESC, post_id DESC
LIMIT 1;
-- The application computes the midpoint of the two keys and writes it. When no
-- midpoint exists, it reads a few dozen rows on each side of the anchor, relabels
-- them, and writes those rows here as well
-- $version is the version returned by the first statement
UPDATE grid_entry SET sort_key = $new_k, moved_version = $version
WHERE profile_id = $1 AND post_id = $moved;
-- If this did not update exactly one row (the post is not on this grid), roll back and return 404
-- (rolling back also undoes the version increment)
COMMIT;Run this transaction at the default READ COMMITTED level, so that the SELECTs after a lock wait see the write that just committed. Under REPEATABLE READ, the transaction that waited for the lock fails its first statement with a serialization error.
3.4 Caveats for wide-column stores
In stores such as Cassandra, where clustering keys order rows within a partition, it is tempting to put the sort key into the clustering key. Primary key columns cannot be changed with UPDATE, though (DataStax CQL reference), so a move becomes "delete the old row and insert a new one". The delete leaves a tombstone, which reads have to skip over until it is compacted away after gc_grace_seconds, 864,000 seconds (10 days) by default (Apache Cassandra documentation). The more often a profile is reordered, the more tombstones accumulate in its partition. With the default settings, a single read that scans more than 1,000 tombstones logs a warning, and one that scans more than 100,000 fails (tombstone_warn_threshold and tombstone_failure_threshold in cassandra.yaml).
Concurrency causes a second problem. If two devices move the same post to different places at almost the same time, both delete the same old row and each inserts its own new row. The post then appears twice.
In a wide-column store, it is safer to leave the time-ordered table as it is and keep only the overrides in a small per-profile partition (post ID → key). With the version in a static column of that partition, a conditional batch on the partition (IF version = ?) writes the keys and bumps the version as a single compare-and-set (Apache Cassandra documentation). That compare-and-set protects moves only, though. New posts are written to the time-ordered table, so their version bump cannot be part of the same operation, and the two tables cannot be read from one snapshot. So a new post writes its row first and bumps the version afterward, and the code that fills the cache reads the version first and the rows afterward. In the opposite order, an old order can be cached under a new version. A read fetches all the overrides, streams the time-ordered rows while skipping posts that have an override, and merges the two sequences by key. For profiles that accumulate many overrides, a reordered copy of the list can be built asynchronously.
4. Choosing the sort key
4.1 Six candidates
These are the common ways to store a user-defined order.
| Approach | Rows written per move | Reading a page | Weakness |
|---|---|---|---|
| Dense ranks (1, 2, 3, …) | Every row between the old and new position | Range scan on an index | The farther a post moves, the more rows it rewrites |
| Linked list (pointers to neighbors) | The moved post and a few neighbors at both ends | Must walk from the head to build a later page | Concurrent updates can break the chain or create cycles |
| The whole order as one array | One row, but the whole array is rewritten | Read the array, then slice it | With tens of thousands of posts, each move rewrites a large value; all updates to a profile conflict |
| Floating-point midpoints | One row | Range scan on an index | Precision runs out after repeated inserts into the same gap |
| Midpoints of gapped integers | Usually one row; a few nearby rows when a gap runs out | Range scan on an index | Needs relabeling logic |
| Variable-length strings (fractional indexing) | One row | Range scan on an index | Keys grow with repeated inserts into one gap; the wrong collation breaks the order |
To see what dense ranks would cost, here is a rough estimate based on assumptions. Suppose 1% of the 3 billion monthly active users each move 20 posts a day. That is 600 million moves a day, about 7,000 per second on average, and about 20,000 per second at a peak of three times the average. A scheme that writes one row per move writes about 20,000 rows per second. With dense ranks and an average move distance of 100 posts, it is about 2 million rows per second, each of which also has to be replicated and reflected in caches. Because users had asked for this feature for years, the first days after launch would presumably see far more moves than this.
The demo below keeps the same 12-post grid under dense ranks, gapped integers, and fractional keys at once. Moving L (the oldest post) to the top rewrites 12 rows with dense ranks and 1 row with each of the other two. Pressing "Same gap ×5" a few times triggers a relabel of the gapped integers and makes the fractional keys longer.
How many rows does one move rewrite?
The same 12-post grid is kept under three key schemes at once. Pick a post, move it, and compare how many rows each scheme rewrites. A is the newest post and L the oldest.
| Scheme | Rows, last action | Total rows | Relabels | Longest key (digits) |
|---|---|---|---|---|
| Dense ranks | — | 0 | — | 2 |
| Gapped integers | — | 0 | 0 | 5 |
| Fractional keys | — | 0 | — | 2 |
Gapped integers start 1,000 apart here. The article's design uses 64-bit post IDs directly, so even two posts made one second apart leave room for 32 inserts. When a gap runs out, a few neighboring keys are spread out evenly again (a relabel). Moving a post to the top uses a freshly issued ID as the upper bound. Fractional keys are shown in base 10 for readability; real implementations use base 62 or similar, so keys grow more slowly.
4.2 Floating-point midpoints run out of precision
Taking midpoints in floating point is the simplest to implement. A double stores 52 bits of its significand (53 bits of precision with the implicit leading bit), though, so repeated inserts into the same gap eventually produce a midpoint equal to one of the ends. When I tried it in JavaScript, repeatedly taking the midpoint of 1 and the previously inserted value, starting from 2, produced new values only 52 times.
Larger values leave fewer steps. With millisecond timestamps like those from Date.now() (about 1.79 × 1012 in 2026) as initial keys, only 22 inserts fit between two posts made one second apart. Figma uses arbitrary-precision fractions rather than 64-bit doubles for its ordering so that precision does not run out after many edits (Wallace, 2017).
4.3 64-bit integer keys seeded by post IDs
In the design from Section 3.2, the key of a post that has not been moved is its post ID. With the ID layout from Section 2, one millisecond corresponds to 223 in ID units, because the lower 23 bits hold the shard ID and the sequence. Two posts made one second apart have IDs about 8.4 × 109 (about 233) apart, enough for more than 30 midpoints. When no integer midpoint is left, a few nearby keys are spread out evenly again. In Go:
// Entry is a post on a grid. Key is the effective key: the override if
// there is one, otherwise the time-ordered post ID. The grid shows larger
// keys first.
type Entry struct {
PostID int64
Key int64
}
// keyBetween returns an integer strictly between lo and hi.
// ok is false when none exists and the caller has to relabel.
func keyBetween(lo, hi int64) (key int64, ok bool) {
if hi-lo < 2 {
return 0, false
}
return lo + (hi-lo)/2, true // (lo+hi)/2 overflows for large IDs
}
// minGap is the spacing left between neighbors after a relabel.
const minGap = 1 << 16
var errKeySpace = errors.New("grid: key space exhausted")
// place puts post between es[g-1] and es[g] and returns the rows to write.
// es is sorted by ascending Key and does not contain post. ceiling is a
// freshly issued post ID: the upper bound when placing at the top
// (g == len(es)) or when a relabel window reaches the top. Callers pass it
// every time.
func place(es []Entry, g int, post, ceiling int64) ([]Entry, error) {
lo, hi := bounds(es, g, g, ceiling)
if k, ok := keyBetween(lo, hi); ok {
return []Entry{{post, k}}, nil
}
// The gap is used up: widen a window around g, doubling it each time,
// until its keys can be spread out at least minGap apart.
for size := 2; ; size *= 2 {
from, to := max(g-size/2, 0), min(g+size/2, len(es))
lo, hi := bounds(es, from, to, ceiling)
n := to - from + 1 // posts in the window plus the moved post
whole := from == 0 && to == len(es)
if (hi-lo)/int64(n+1) < minGap && !whole {
continue
}
step := (hi - lo) / int64(n+1)
if step < 1 {
return nil, errKeySpace
}
out := make([]Entry, 0, n)
next := lo
add := func(id int64) { next += step; out = append(out, Entry{id, next}) }
for _, e := range es[from:g] {
add(e.PostID)
}
add(post)
for _, e := range es[g:to] {
add(e.PostID)
}
return out, nil
}
}
func bounds(es []Entry, from, to int, ceiling int64) (lo, hi int64) {
lo, hi = 0, ceiling
if from > 0 {
lo = es[from-1].Key
}
if to < len(es) {
hi = es[to].Key
}
return lo, hi
}I tested this code with IDs built from the layout in Section 2. After every move, the test checks that the order matches a plain array reordered the same way, that keys are strictly increasing, and that every key stays below the ceiling.
- On a 300-post grid, with posts spaced randomly between one second and one day apart, 100,000 random moves wrote 1.005 rows per move on average and needed 30 relabels.
- As a worst case, I inserted 20,000 times in a row into one gap in the middle of the grid. Between posts one second apart, the first 32 inserts found a midpoint and the 33rd triggered the first relabel; between posts one day apart, 49 inserts found a midpoint. Over the 20,000 inserts, the average was 1.2 rows per insert (for the one-second spacing), and the largest relabel rewrote 65 rows.
Keeping integer labels in order under insertions, relabeling when a gap runs out, is the order-maintenance problem, also studied as list labeling. Bender et al. give simple algorithms showing that when the label space is polynomial in the number of elements, the amortized number of relabels per insertion is O(log n) (Bender et al., 2002). The code above is a simplified implementation that grows the window by element count and does not carry that guarantee, but a 263 range is far larger than the number of posts on any profile, and it held up in the experiments above.
Three details need care in an implementation:
- Midpoint overflow.
(lo + hi) / 2exceeds the signed 64-bit range once IDs pass 262. Under this scheme (41 bits of milliseconds from a 2011 epoch), IDs created around 2028–2029 cross that value. Writinglo + (hi - lo) / 2cannot overflow as long asloandhiare non-negative. It is the same bug made famous by binary search (Bloch, 2006). Later still, 240 milliseconds after the epoch (around 2045–2046), the top bit of the ID is set and the ID becomes negative as a signed integer, which breaks the assumption that IDs are non-negative and increase with time. - Moving to the top. Use a freshly issued ID as the upper bound, issued by the same generator (the same shard) as new posts. Overridden keys then never exceed any ID issued so far, so posts whose IDs that generator issues later land above them. A value made from another server's clock can, through clock skew, be larger than the ID of a post created afterwards. Posts numbered on another shard, such as collaborative posts, can be off by the same skew.
- Ties. So that the display order stays stable even if a bug ever produces equal effective keys, the index and the queries include the post ID as the second sort column (Section 3.3).
4.4 Variable-length string keys and collation
With variable-length strings, a gap never runs out. Figma represents fractions between 0 and 1 as strings, so a reorder rewrites a single value (Wallace, 2017). fractional-indexing, an implementation of David Greenspan's method, builds keys from base-62 digits (0-9A-Za-z).
The price is key growth. Measuring with fractional-indexing 4.0.0, I found that 1,000 inserts into the same gap produced a 169-character key. Each character adds about 6 bits of resolution (log2 62 ≈ 5.95), so the key grows by one character about every six inserts. Prepending or appending 10,000 times, by contrast, left keys at 4 characters.
Growing keys need a length limit and relabeling. In LexoRank, which Jira uses to rank issues, a rank string reaching 128 characters schedules a rebalance within the next 12 hours, and one reaching 160 characters starts it immediately. Ranking operations that would produce 254 characters or more are stopped. Ranking continues as usual while rebalancing runs (Atlassian documentation).
String keys assume byte-order comparison, so collation matters.
- In PostgreSQL, a linguistic collation such as en_US compares letters case-insensitively first, so
'a' < 'B'is true. In byte order,'B'(0x42) sorts before'a'(0x61), so key order breaks. Declare the column withCOLLATE "C"or usebytea. - MySQL 8.0's default collation for utf8mb4,
utf8mb4_0900_ai_ci, is case-insensitive, soa0andA0compare equal. Use a binary collation. - In application code, sorting with JavaScript's
localeComparegives the wrong order; the fractional-indexing README asks for plain string comparison.
4.5 The choice at this scale
At Instagram's scale, I would use 64-bit integer keys seeded by post IDs. Keys are a fixed 8 bytes, comparisons are integer comparisons, and collation never comes into play. The scheme ships without rewriting existing posts, and as the experiments in Section 4.3 show, a move writes about one row on average, relabels included.
Variable-length strings suit lists with no natural seed such as a timestamp, or cases like Figma's where clients generate keys locally before the server sees the edit.
5. Writes: the move API and concurrent updates
5.1 Name the neighbor, not the position
The move API identifies the destination by the post that should come immediately before the moved one (the previous post when reading the grid from the top left), not by a position number. I call this post the anchor. Position numbers shift with moves from other devices and with new posts; an anchor names a post, so it keeps its meaning when other posts move.
POST /v1/me/grid/moves HTTP/1.1
Content-Type: application/json
Idempotency-Key: "8e0c1f6a-2b7d-4c39-9f4e-5d1a7b3c6e20"
{"post_id": "4197176259380573189", "after": "4197170219582813190"}HTTP/1.1 200 OK
Content-Type: application/json
{"grid_version": 42}Setting after to null moves the post to the top, right after the pinned posts. A request whose after names a pinned post is treated the same way, as a move to the top. If the post being moved is pinned, the server returns 409, and the app tells the user to unpin it first. A request whose after equals its post_id is rejected with 400, and one whose post_id is not on this grid gets 404. If the after post cannot be found, it may have just been deleted or archived, so the server returns 409 with the current version (Section 5.2).
Applying the same "right after A" request twice gives the same order as applying it once, whereas a relative operation such as "up one" would move the post twice. A retry is still not entirely safe: if a timed-out request is retried and the retry arrives after a later, different move, it applies the old operation again. So the app attaches a unique value to each drag in an Idempotency-Key header, and the server keeps processed keys with their responses for a while and returns the stored response for a repeated key (IETF Internet-Draft). Duplicate requests then no longer bump the version or trigger cache rebuilds.
Store the idempotency key, together with a fingerprint of the request and the response, in the same transaction as the move. If they were written separately, a crash between committing the move and recording the key would let a retry apply the move again. Check for a stored key after taking the version-row lock; if it is there, roll back (which also undoes the version increment) and return the stored response. A retry that arrives while the original is still in progress then waits for the lock and, once the original commits, receives the stored response.
The Internet-Draft also asks servers to document how keys are handled, including how long they are kept. It says to return 422 Unprocessable Content when the same key arrives with a different request, and 409 Conflict when the same key arrives while the original request is still being processed. To return 409 instead of making the retry wait, try pg_try_advisory_xact_lock on a hash of the key at the start of the transaction, and return 409 if the lock is already held.
Undo can be sent as another move, back to the previous anchor. If the move rewrote only that row and the version has not changed since, the server can also write back the previous sort_key (including NULL) as it was.
IDs travel as strings. A 64-bit ID exceeds 253, the limit up to which a JavaScript number (a double) represents every integer exactly. For example, JSON.parse("4197176259380573189") returns a number whose exact value is 4197176259380573184 (the console prints 4197176259380573000), and the next ID parses to the same number. The Instagram Graph API also returns IDs as strings.
Only the server computes sort keys, and it never returns them to clients. Keeping sort keys out of the API contract leaves room to relabel or change their representation (from integers to strings, for example) without affecting clients.
5.2 Serialize per profile
The same profile's order is written concurrently by one person's several devices, or by the several people who run an account. As in Section 3.3, every grid write first locks the profile's version row in grid_order. Writes to one profile normally arrive only as fast as a person acts, so serializing them causes almost no waiting, and different profiles proceed in parallel. Keep grid_entry and grid_order on the profile's shard so that a move is a single-shard transaction. A collaborative post has a row on each profile's shard, so deleting it or removing a collaborator updates each grid in its own transaction. A store without transactions can serialize writes the same way with a compare-and-set on the version.
With writes serialized, concurrent operations resolve like this:
- Two devices move the same post at once. The move that commits last wins. The key is one column of one row, so the post can never appear in two places. Kleppmann shows that list CRDTs representing a move as "delete and insert" duplicate an element when it is moved concurrently, and proposes representing each element's position as a last-writer-wins register (Kleppmann, 2020). In this design,
sort_keyplays the role of that per-element register. The duplication in Section 3.4 is the same problem. - Two devices insert different posts into the same gap. The later move reads the key the earlier move wrote before computing its midpoint, so no two posts get the same key. Figma likewise has the server assign a different position to the second of two simultaneous inserts at the same place.
- The anchor is deleted or archived concurrently. Return 409 Conflict with the current version and have the app reload the order.
The move response returns the incremented version (Section 6.3 shows how the app uses it). To keep automated scripts from triggering endless relabels and cache rebuilds, rate-limit moves per profile.
5.3 Batched reorders: rewrite only what the LIS leaves out
Some clients send the new order of a whole range at once, such as an editing screen that saves after several moves, or an API that imports an order planned in another tool. Rewriting every key in the range makes writes and replication proportional to its length.
Only posts whose relative order changed need new keys. List the old position of each post in the new order; posts on a longest increasing subsequence (LIS) of that list are still in the same relative order, so they keep their keys. The remaining posts get new keys between the keys of the nearest kept posts. As long as the gaps are large enough, that rewrites "range length minus LIS length" posts, which is the minimum. When several posts go into one gap, divide the gap evenly instead of taking repeated midpoints. Putting 30 posts into a gap of about 233 one at a time, each at the midpoint between the previous one and the lower end of the gap, shrinks the last gap to about 23; dividing evenly leaves about 228 between each pair.
// keep marks the posts in a client's new order that can keep their keys.
// old[i] is the previous position of the post that the new order puts at
// position i. Posts on a longest increasing subsequence of old are already
// in the right relative order, so only the others need new keys.
func keep(old []int) []bool {
res := make([]bool, len(old))
if len(old) == 0 {
return res
}
tails := []int{} // tails[k]: index ending an increasing run of length k+1
prev := make([]int, len(old)) // predecessor links for reconstruction
for i, v := range old {
lo, hi := 0, len(tails)
for lo < hi { // binary search for the first k with old[tails[k]] >= v
mid := lo + (hi-lo)/2
if old[tails[mid]] < v {
lo = mid + 1
} else {
hi = mid
}
}
prev[i] = -1
if lo > 0 {
prev[i] = tails[lo-1]
}
if lo == len(tails) {
tails = append(tails, i)
} else {
tails[lo] = i
}
}
for i := tails[len(tails)-1]; i >= 0; i = prev[i] {
res[i] = true
}
return res
}For example, reordering A B C D E F into E A B C D F gives old positions [4 0 1 2 3 5], whose LIS is A B C D F. Only E needs a new key. Vue 3 does the same when it updates keyed child elements, using getSequence in renderer.ts to move as few DOM nodes as possible. The dynamic programming article covers how to compute an LIS.
For batched orders, provide a resource that returns only the order (the list of post IDs), such as GET /v1/me/grid/order, and have its response carry the version as an entity tag, such as ETag: "42". A client sending a batched order to the same resource (for example, PATCH /v1/me/grid/order) puts the version it built the order from in If-Match. The server rejects the request with 412 Precondition Failed if the version no longer matches, and with 428 Precondition Required (RFC 6585) if If-Match is missing. A single move can still be applied as long as its anchor exists, but the order of a whole range cannot be reinterpreted on top of changes made from another device.
Using the version as the ETag works only because this resource returns nothing but the order. On a response that also includes post bodies, such as like counts, the content would change while the version stayed the same, so a cache revalidating with If-None-Match would get 304 Not Modified and keep the stale numbers (RFC 9110, Section 8.8.1). Such responses return the version in a grid_version field of the body instead.
Because the version advances on every change to the grid, a new post causes a 412 just as a move from another device does. Have the editing screen record the user's actions as a sequence of anchor-based moves; on a 412, it reloads the order, replays the moves, and sends the result again with the new version. If only the moves whose anchors have disappeared go back to the user for confirmation, a long editing session does not have to start over.
6. Reads: pagination and caching
6.1 Paginating a list whose order changes
If the next page is fetched with OFFSET, a change in order makes posts that nobody moved appear twice or disappear. With keyset pagination, which reads what comes after the key of the last row on the previous page, the only posts affected are the moved ones and, occasionally, a few nearby posts whose keys a relabel rewrote. The demo below shows what reaches a viewer under each method when the owner reorders twice between page loads.
Paging through a grid while it is being reordered
The owner moves two posts while a viewer loads the grid 3 posts at a time. Compare what reaches the viewer with OFFSET paging and with keyset paging.
Dots mark the posts returned to each device in this step. The number is the current position.
Nothing loaded yet
Nothing loaded yet
duplicate of a moved postduplicate of a post nobody moved (!)
Twelve posts, newest first (A is the newest). The viewer loads three posts per page, one row of the grid at a time. The device marked orange pages with OFFSET; the one marked green uses the key of the last post it received as the cursor.
On the OFFSET device, J moving to the top pushed every later post back by one, so C arrived twice. When B then moved further back, the posts closed up, and F never arrived at all. Nobody moved F, and the device has no way to tell that it is missing. The keyset device saw only two effects, both of which the OFFSET device also had: the moved post B arrived twice, and J, which moved to the top, never arrived. The duplicate B can be dropped by post ID, and J appears the next time the viewer reloads.
TAO's API has both forms as well: assoc_range selects a range by position in the list, and assoc_time_range selects it by values of the time field. The latter is keyset pagination over a time-ordered list.
The cursor carries the effective key and post ID of the last row on the previous page, plus the version at which that page was read, returned as a signed, opaque token (Section 4.5 of the RPC and REST article covers how to build one). When reading the next page, the server can see whether the version has changed, so it can flag the response as "grid changed". Whether the app then suggests a reload or just keeps reading is a product decision.
To remove the effect of relabels as well, look up the current effective key of the cursor's post by its ID when reading the next page. A relabel keeps the relative order inside its window, so as long as the cursor's post itself has not changed position, what comes after its current key is the correct continuation. To tell whether it has, compare the cursor's version with moved_version from Section 3.3. That column records the version whenever a row changes position for any reason other than a relabel: a move, a return from the archive, or a reset to chronological order. If the post changed position or its row is gone (deleted or archived), the server falls back to the key stored in the cursor. For this check to work, read the version that goes into the cursor in the same snapshot as the page's rows, or before them. Read afterward, a post that moved in between would look unmoved, and the next page would continue from its new position.
6.2 What to cache and how to invalidate it
The first page is read every time someone opens the profile, so caching it pays off the most. Keep the order (pairs of post ID and effective key) and the post bodies in separate entries. Then a change in like counts or captions does not discard the cached order, and a move does not discard cached post bodies. The effective keys are cached too because the cursor for page 2 needs the key of the last row.
grid:{profile_id}:v{version} IDs and effective keys of the pins (up to 3) and the next two pages (48 posts), in display order
post:{post_id} post body (image URLs, caption, like count, ...)Include the grid version in the key of the cached order. The version advances on every write that changes the grid (Section 3.3), so any change to the first page produces a different key. Readers receive the current version along with the profile's basic data and then read the order for that version. Once the version advances, the old entry is no longer read and expires by TTL. The cached basic data must be deleted whenever the version changes, but since it shows the post count, it is presumably already deleted on every new post and deletion.
The code that fills the cache reads the version, the pins, and the order from one snapshot and writes them to the cache entry for the version it read. Under READ COMMITTED, PostgreSQL's default isolation level, two SELECTs in the same transaction can see different data (PostgreSQL documentation), so read them in a single SQL statement or in a REPEATABLE READ, read-only transaction. REPEATABLE READ also works on hot standby replicas (PostgreSQL documentation). If a lagging replica can only return an older version than the one requested, the filler writes nothing to the cache and reads from the primary instead. Each version's cache entry then always holds the same order, so delayed or reordered invalidation messages can never cause an old order to be served as a new version.
For each version's cache entry to always hold the same order, version numbers must also never be reused. When a database with asynchronous replication fails over, the last few commits can be lost, and the same version number can then be issued again for a different order. The cache still holds the order of the lost version, so add a generation number that changes on every failover to the cache key, or discard that shard's cached orders. Include the generation wherever else versions are compared, too: the ETag in Section 5.3, cursors in Section 6.1, and the version the app remembers in Section 6.3. Compare generations first; a newer generation counts as newer whatever the version numbers say. Never delete grid_order rows either, including when resetting to chronological order; a deleted and recreated row would start counting from 1 again.
For accounts with many followers, every move deletes the cached basic data, so many requests hit the database at once. The Facebook memcache paper describes leases: on a cache miss, only one client gets the right to refill the value, and the others wait briefly and retry. By default a lease token is issued at most once every 10 seconds per key. For a set of keys particularly prone to this problem, leases cut the peak database query rate from 17,000 to 1,300 per second (Nishtala et al., 2013). Leases also prevent stale sets, where a reordered invalidation and refill leave an outdated value in the cache.
When an association list changes, TAO does not discard the cached list; it has the cache fetch the current list again (a refill). Because only a contiguous prefix of each list is cached, invalidating would truncate it and lose many edges. Likewise, after the commit, the writer stores the new version's order in the cache (reading it the same way as a cache fill) and then deletes the cached basic data (the diagram in Section 5.2). In the opposite order, readers that learn the new version would go to the database for an order that is not cached yet.
The writer deletes the cached basic data instead of writing the new version into it because, when different servers handle two writes to one profile, their cache writes can arrive in either order. If v5 is written after v6, the old version's order is served until the TTL expires. Deletes give the same result in any order, and the next reader takes a lease and refills the entry. The memcache paper likewise deletes cached data instead of updating it, because deletes are idempotent.
If that refill reads from a replica that has not applied the write yet, though, the old version goes straight back into the cache. So also send deletes from the commit log of the database that the cache refills from. Such a delete arrives after that database has applied the write, so the old version can survive only as long as the replication lag. The same delete also covers an API server that crashes after COMMIT before sending its own. In the memcache paper, an mcsqueal daemon on each database, including replicas in other regions, extracts deletes from the statements that database commits and broadcasts them to the caches, which avoids invalidations arriving before the data has been replicated. TAO uses the same pipeline and delivers its invalidation and refill messages right after the write reaches that region's replica; for a write forwarded from another region, followers receive two of them, one when the write succeeds and one when it is replicated.
6.3 Immediate for the owner, a few seconds late for everyone else
The person who reorders expects to see the change at once. The app applies the move on screen first and remembers the version returned in the response. Later reads ask for that version or newer; if a replica or cache holds an older version, the read goes to the primary.
Other viewers may see the change a few seconds later, after replication and cache updates (Section 6.2).
7. Backward compatibility: code that assumed "first means newest"
7.1 Treat creation time and display order as separate concepts
On a chronological grid, creation order and display order were the same. With reordering they become separate concepts. Creation time is when the post was published; it drives labels like "3 days ago", feed ranking, and analytics. Display order is where the post sits on the profile, and only the profile grid uses it. Writing display order into a field like TAO's time field (Section 2) would break every reader that treats it as a time, so keep the two in separate values.
Every piece of code that used to read "the first N posts of the grid" needs checking to see which of the two it meant. Examples include:
- Showing the latest post in notifications or search previews
- Machine learning pipelines that use "days since the last post" as a feature
- Widgets that embed the latest posts on other sites
- Review or analytics jobs that process new posts first
Hyrum's Law observes that with a sufficient number of users of an API, every observable behavior ends up being depended on by somebody, whatever the contract promises (Hyrum's Law). A list that has meant "first is newest" for more than 15 years should be checked on the assumption that it carries such implicit dependencies.
7.2 Keep creation-time semantics in the public API
Public APIs should keep their creation-time meaning. As of November 2026, the documentation for the Instagram Graph API's IG User Media edge says that it "returns a maximum of 10K of the most recently created media" and supports time-based pagination with since and until (Meta documentation). If that list switched to display order, a range bounded by until would no longer be a contiguous slice of the returned order, and integrations that page by creation time would get wrong results. Integrations that need display order should get a new parameter or field that asks for it explicitly. I could not find public information on how Meta handled the Graph API when reordering launched.
7.3 Old app versions and a staged rollout
Old versions of mobile apps stay in use for a long time. They should still display whatever order the server returns, but an implementation that re-sorts its local cache by creation time or ID would undo the owner's arrangement. Test the most widely used old versions against the server's new responses, and show the reordering editor only in new app versions.
Roll out grid-version bumps first, then reads, then reordering writes. Start reading version-keyed caches only after every write path that changes the grid bumps the version. If old servers that do not bump it are still running, an order missing a new post keeps being served from the same version's cache entry. If reordering writes were enabled before reads, viewers whose requests still went through the old read path would see chronological order, contradicting what the owner sees.
Step 0 needs no downtime, but in PostgreSQL two things need care (ALTER TABLE and CREATE INDEX documentation):
ADD COLUMNrewrites no rows, but it takes an ACCESS EXCLUSIVE lock. While it waits for that lock behind a long transaction, every later read and write on the table waits too, so set a shortlock_timeoutand retry if the lock is not acquired. Adding both columns in oneALTER TABLE, as in Section 3.3, takes the lock only once.CREATE INDEX CONCURRENTLYreads every existing row, so it takes time. It also cannot run inside a transaction block. If it fails partway, it leaves an INVALID index that queries ignore but every write still maintains, so drop it and build it again.
For profiles without overrides, the new query must return exactly what the old one did. Running both on a sample of production reads before launch catches mistakes in the new read path before users see them. If something goes wrong, turn off the reading of overrides and hide the editor as well. The cache still holds orders that include the overrides, so discard the cached orders too, or change their keys. Every grid returns to chronological order, and nobody keeps editing an arrangement that no one can see. The overrides stay in the database, so turning reading back on restores everyone's arrangements.
8. What to monitor
After launch, watch these signals:
- Move latency and error rate (lock-wait timeouts, deadlocks, the share of 409 responses)
- Relabel frequency and the distribution of rows rewritten per relabel. Relabels are normally rare, so a sudden increase points to automated abuse or a bug
- The cache hit ratio of first pages and the number of requests that waited on a lease
- For profiles without overrides, the share of mismatches between old and new queries (it should be zero)
- For sampled cache entries whose version matches the database's current version, the share whose order differs from the database (it should be zero)
- The number of duplicate posts that devices dropped, and how often responses carried the "grid changed" flag
- Moves per profile and how often the rate limit is hit
Also run a periodic check, excluding pinned rows, for rows with duplicate effective keys and rows that point to nonexistent posts. Pinned rows are left out of relabel windows, so they can end up sharing a key with another row. Because the post ID breaks ties, the display order is always well defined, but any other duplicate key indicates a bug in key assignment.
9. Conclusion
| Decision | Reason | Trade-off |
|---|---|---|
| Put the order on the profile–post relationship | Collaborative posts and different lists can have their own order; a move is not an update to the post | Needs a per-profile list; without one, keep only the overrides in a separate table and merge at read time (Section 3.3) |
| Write keys only for moved posts | No backfill; new posts go to the top automatically; resetting to chronological is easy | Building the effective-key index reads every existing row |
| 64-bit integer keys seeded by post IDs | Fixed size, fast comparisons, no collation problems; about one row per move in the experiments | Occasional relabels to implement, and care with overflow |
| Anchor-based move API, serialized per profile | Intent survives concurrent operations, and a post can never be duplicated | Lock waits within one profile |
| Keyset cursor carrying the version | Confines anomalies to moved posts (and a few relabeled ones) and detects changes to the grid | No jumping to an arbitrary page number |
| Version-keyed cache | Never serves an old order as a newer version, even if invalidation messages arrive out of order | Readers have to learn the current version, and every write that changes the grid has to bump it |
| Public API keeps creation-time semantics | Existing integrations keep working | Integrations that need display order need a new option |
Adding a user-defined order to a list that has always been sorted by time or ID is a common task: playlists, kanban boards, issue backlogs, favorites lists. Keep the default order and store only the differences; attach the order to the relationship between list and item rather than to the item itself; treat display order and creation time as separate concepts. For any list that has a default order, these three choices carry over directly.
References
- Hartley Charlton, Instagram Now Lets Users Reorder Posts on Their Profile Grid, MacRumors, June 9, 2026
- Angeline Jane Bernabe, Meta debuts new Instagram feature that allows users to rearrange posts on profile grid, ABC News, June 8, 2026
- Adam Mosseri, Inspiring Creativity That Brings People Together, Meta Newsroom, June 12, 2025
- Aisha Malik, Instagram will finally let you rearrange your grid, TechCrunch, June 12, 2025
- Ramesh Reddy, How to Rearrange Your Instagram Profile Grid in Any Order, TechPP, June 9, 2026
- Paula, How to Rearrange Your Instagram Grid (and Plan It Before You Touch Anything), Hopper HQ, October 7, 2026
- Jonathan Vanian, Instagram now has 3 billion monthly active users, CNBC, September 24, 2025
- Mike Krieger, Sharding & IDs at Instagram, Instagram Engineering, 2011 (republished on Medium in 2012)
- Nathan Bronson et al., TAO: Facebook's Distributed Data Store for the Social Graph, USENIX ATC 2013
- Rajesh Nishtala et al., Scaling Memcache at Facebook, NSDI 2013
- Evan Wallace, Realtime editing of ordered sequences, Figma, 2017
- David Greenspan, Implementing Fractional Indexing
- rocicorp/fractional-indexing
- Atlassian, Managing LexoRank
- Michael A. Bender et al., Two Simplified Algorithms for Maintaining Order in a List, ESA 2002
- Martin Kleppmann, Moving Elements in List CRDTs, PaPoC 2020
- Joshua Bloch, Extra, Extra - Read All About It: Nearly All Binary Searches and Mergesorts are Broken, Google Research, 2006
- PostgreSQL, Heap-Only Tuples (HOT)
- PostgreSQL, Modifying Tables
- PostgreSQL, ALTER TABLE
- PostgreSQL, CREATE INDEX
- PostgreSQL, Transaction Isolation
- PostgreSQL, Hot Standby
- PostgreSQL, Lock Management
- DataStax, CQL UPDATE
- Apache Cassandra, Tombstones
- Apache Cassandra, cassandra.yaml file configuration
- Apache Cassandra, Good use of BATCH statement
- IETF, The Idempotency-Key HTTP Header Field (Internet-Draft)
- IETF, RFC 6585: Additional HTTP Status Codes
- IETF, RFC 9110: HTTP Semantics
- Meta, IG User Media
- Hyrum Wright, Hyrum's Law
- vuejs/core: packages/runtime-core/src/renderer.ts