Skip to main content

October 6, 2026

Shared sort keys collapse unlisted PLP groups

On a colour-sorted fashion PLP, unlisted colours shared one Algolia sort attribute and one hue reappeared mid-grid until each group got a stable id-only key.

Sander Korf4 min read

Shoppers open a fashion product listing page (PLP): a grid of cards sorted by colour. Merchandisers maintain a curated list of colour ids in the CMS. Listed colours should appear as dense blocks in that CMS order. Products whose colour is not on that list are unlisted; they should still sit together in their own blocks after the curated section, not sprinkled through the grid. Nightly Playwright checks assert that every card sharing a colour id stays contiguous. One night those checks failed on Coral and Mist while Navy, Sand, and Olive looked fine. On the live PLP the same hue showed up near the top and again dozens of cards later.

The PLP reads from a search replica (a search index view) that sorts on a numeric sort key we write at index time. Incremental reindex updates one product document when stock or merchandising changes, without rebuilding the whole catalogue. The trap is easy to miss because listed colours use index + 1 in the curated array and look perfect for months. Unlisted colours all received the same default from getIndexOrDefault(..., 9999). The replica treats equal sort keys as one bucket and uses tie-breakers, so different unlisted colour ids interleave. The bug stays quiet until an unlisted colour has enough SKUs for the scatter to show, and until someone scrolls past the first block.

What I tried first

I assumed the sentinel was too small and bumped unlisted colours into a high band with hash(colourId) % 9000. Two real ids still landed on the same remainder. I pinned hue-10 and hue-562 in a fixture: same modulus, different colours, one interleaved block in the toy grid below.

Full-width FNV over the id string without modulo still collided on production-scale ids. I stopped treating "hash hard enough" as uniqueness.

Next I ranked every colour by position in "listed ids plus sorted(all known ids)". Keys looked unique until merchandising added a new listed colour. Incremental reindex touched one Coral SKU after the CMS change. Half the Coral cards kept the old rank and half picked up the new one, so one colour split across the grid. That strategy failed stability under partial updates, not just collision.

I also tried encoding the first ten characters of the colour id. Lowercasing erased case distinctions and prefixes tied (colour-10001 vs colour-10002). Using Number.MAX_SAFE_INTEGER as "after everything" sat above Algolia's numeric attribute ceiling (~4.61e15), so the vendor rejected or truncated values.

Sort keys for grouping, not just ordering

A sort key used to keep groups together must be unique per group identity and stable when only one document reindexes. Shared sentinels, colliding hashes, ranks that depend on who else is in the catalogue, and prefix encodes all break one of those rules.

Listed colours stay on dense ranks 1..n from the CMS array. Unlisted colours move to a reserved band above that space: dual-seed FNV-1a over the full colour id string, case preserved, no prefix slice and no % N. Missing colour gets a single sentinel above the unlisted band and below the vendor numeric limit, for example 2**52 + LISTED_KEY_SPACE.

Promoting or reordering listed colours still requires reindexing every affected document. Unlisted keys must never depend on which other colours exist in the catalogue.

In the demo, start with shared-sentinel and scan the grid: Coral and Mist cards mix. Switch to hash-mod and watch the collision badge; rank-in-set looks fine until you click Add Coral to curated list + reindex one SKU, then Coral splits. id-hash (fixed) keeps each colour in one block.

Colour-sorted PLP (toy replica sort)

Merchandisers pin Navy, Sand, Olive in the CMS. Coral and Mist are unlisted. Pick a sort-key strategy and watch whether each colour stays one block.
Split colour blocksShared sentinel (9999)
06Navy
05Navy
04Navy
03Navy
02Navy
01Navy
09Sand
08Sand
07Sand
10Sand
11Sand
14Oliv
15Oliv
12Oliv
13Oliv
18Cora
19Cora
16Cora
17Cora
collision-bhue-
collision-ahue-
30Mist
25Mist
24Mist
27Mist
26Mist
21Cora
20Cora
23Cora
22Cora
29Mist
28Mist

Same colour appears in more than one block (Coral, Mist). Tie-breakers on the replica sort scattered cards with the same sort key.

Curated order: Navy → Sand → Olive. Unlisted in catalogue: Coral, Mist, plus collision toy ids for hash-mod.

const LISTED_KEY_SPACE = 10_000
const NO_COLOR_SORT_KEY = 2 ** 52 + LISTED_KEY_SPACE
 
function encodeId(id: string): number {
  const high = fnv1a(id, 0x811c9dc5)
  const low = fnv1a(id, 0x9747b28c) % 2 ** 20
  return high * 2 ** 20 + low
}
 
function sortKey(listed: string[] | undefined, id: string | undefined) {
  if (!id) return NO_COLOR_SORT_KEY
  const i = listed?.indexOf(id) ?? -1
  if (i !== -1) return i + 1
  return LISTED_KEY_SPACE + encodeId(id)
}

When this pattern travels

It applies wherever a faceted or replica sort uses a numeric attribute and documents update incrementally: Algolia, Elasticsearch, Typesense, or any "unknown goes to default" mapping that must still group by identity. It does not apply when you always rebuild the whole index, when sort order does not need same-id grouping, or when the enum is tiny and every value is always listed.

A PLP can survive a long colour menu. It cannot survive a sort key that tells the search engine five different hues are the same number.


Happy coding! Sander