Skip to main content

Build Locale-Aware Sorting for Multilingual Apps

2026-09-17

Build Locale-Aware Sorting for Multilingual Apps

Locale aware sorting bugs rarely look like localization bugs. An app translates every label, then files Åsa beside A in Swedish, puts item 10 before item 2, or rearranges a paginated contact list on another device. The interface is translated, but the alphabet behaves incorrectly.

Another translation key will not fix the order. Define an ordering contract, build a collator from the effective app locale, choose comparison options for the list's purpose, and add a stable tie-breaker. If a server owns pagination, it must follow the same contract.

Why translated text still sorts incorrectly

A basic string comparator often compares Unicode code points or implementation-specific binary values. That operation is deterministic, but it does not model how every language orders letters. Lowercasing both strings does not solve accents, composed characters, punctuation, scripts, or language-specific alphabet rules.

MDN's Intl.Collator example shows the consequence with four characters. German collation places ä near a, while Swedish places ä after z. Neither result can be derived from a universal accent-stripping rule because the correct order depends on the selected locale.

The Unicode ICU collation guide describes collation as locale-sensitive comparison with configurable strength, normalization behavior, tailoring, and reusable sort keys. That means ordering is application behavior, not a property stored inside a string.

The bug is easy to miss when several systems participate:

  • The database orders records using its default collation.
  • An API returns one page and a continuation cursor.
  • The client sorts that page again with the device locale.
  • Search folds accents differently from sort comparison.
  • Two labels compare as equal, so their order changes between refreshes.

A Swedish contacts-app developer reported this exact class of problem in an Android localized collation question. Names containing Swedish letters did not appear in the expected alphabetic position. Treat the question as a practitioner report, not a platform specification, but keep its fixture shape in your tests.

Write the ordering contract before choosing an API

Start with the list's behavior, not a framework method. Record the contract beside the feature because two lists in the same app may need different rules.

For each sortable field, answer these questions:

  1. Which locale controls ordering: the app locale, the content locale, the user's profile locale, or a fixed business locale?
  2. Should case differences affect the primary order?
  3. Should accents distinguish entries or only break ties?
  4. Should digit sequences sort numerically, so item 2 precedes item 10?
  5. Should punctuation and spaces matter?
  6. Which immutable field breaks ties when labels compare as equal?
  7. Does the client own the entire result set, or does a server own ordering and pagination?

A settings menu translated with the interface will usually follow the effective app locale. A directory of Swedish legal entities may need a fixed Swedish content locale even when the interface is English. A mixed-language social feed may use chronology instead of alphabetical collation. One global comparator cannot satisfy all of these lists.

Store the locale as a valid language tag and resolve it through the same locale policy used by the rest of the app. Do not pass a translated language name such as Swedish into the comparator. Do not silently switch to the device locale when the app has its own language preference.

Choose comparison options deliberately

Collators expose options because equality and ordering depend on the job. A contact list, a file picker, and a SKU table should not inherit one shared comparator without review.

Comparison strength and sensitivity

At a primary strength, base letters can compare together even when accents or case differ. Higher strengths consider more distinctions. Android's java.text.Collator documentation defines strength levels and decomposition behavior for locale-sensitive comparisons. JavaScript exposes similar choices through Intl.Collator sensitivity options.

For many app lists, a forgiving main comparison works well when the original label and immutable ID break ties. Visually related names stay together, but their order does not jump between runs.

Do not remove accents and call the result locale-aware. Accent folding can be a search aid, but it destroys distinctions that some alphabets use for ordering. It also cannot reproduce locale tailoring such as the Swedish placement shown in the MDN example.

Numeric comparison

Human-facing labels often contain digits. Binary comparison puts Version 10 before Version 2. The same locale data drives display, covered in localizing app dates, times, and numbers without hardcoded formats. Enable numeric comparison when users perceive embedded digits as numbers. Keep it disabled for identifiers where leading zeros or exact character order are meaningful.

Punctuation and display labels

Ignoring punctuation may be useful for titles, but dangerous for identifiers. Decide whether quotation marks, leading symbols, and spaces affect the result. If a display label is empty, define its placement explicitly instead of depending on a collator's behavior for an empty string.

Implement one comparator with stable tie-breakers

Create the collator once for a locale and reuse it. Rebuilding it inside every comparison adds avoidable work and makes it easier for options to drift.

In JavaScript or TypeScript:

type ListItem = {
  id: string;
  label: string;
};

export function makeItemComparator(locale: string) {
  const collator = new Intl.Collator(locale, {
    usage: "sort",
    sensitivity: "base",
    numeric: true,
    ignorePunctuation: false,
  });

  return (left: ListItem, right: ListItem): number => {
    const byLabel = collator.compare(left.label, right.label);
    if (byLabel !== 0) return byLabel;

    const byExactLabel = left.label.localeCompare(right.label, locale);
    if (byExactLabel !== 0) return byExactLabel;

    return left.id.localeCompare(right.id, "en");
  };
}

The first comparison applies the reader-facing policy. The exact-label comparison makes accent or case variants stable inside a primary-equivalent group. The immutable ID handles duplicate labels. Choose an ID comparison whose semantics do not change with the UI locale.

On Android, construct a Collator for the effective locale and set its strength once:

data class ListItem(val id: String, val label: String)

fun itemComparator(locale: Locale): Comparator<ListItem> {
    val collator = Collator.getInstance(locale).apply {
        strength = Collator.PRIMARY
    }

    return Comparator { left, right ->
        val localized = collator.compare(left.label, right.label)
        if (localized != 0) return@Comparator localized

        val exact = left.label.compareTo(right.label)
        if (exact != 0) return@Comparator exact

        left.id.compareTo(right.id)
    }
}

Run the localized comparison first, then apply deterministic tie-breakers.

Keep comparator creation outside rendering loops. Recreate or invalidate it when the effective locale changes, then resort the full in-memory collection. Do not reuse a collator built for the previous language.

Keep search matching separate from sort order

Search and sort answer different questions. Search decides whether a record matches a query. Sort decides where matching records appear. Sharing one normalized key between them can make both behaviors wrong.

A search policy may case-fold text, normalize Unicode, index transliterations, or accept accent-insensitive matches. Sorting should still use the locale's collation rules on the display value. Keep the original text for rendering and comparison. Store any search-normalized form as a derived index, never as the canonical label.

When search returns equal relevance scores, apply the same locale-aware comparator and stable ID tie-breaker used by the normal list. Without that final tie-breaker, two identical display names can exchange positions between keystrokes.

Align server ordering with pagination

Client-side sorting works only when the client holds the complete result set. Sorting each page after the server paginates creates a broken global order. A record that belongs near the start may arrive on page four. A locale change can also invalidate a cursor built under the previous order.

For server-owned lists, include these values in the ordering contract:

  • Resolved locale tag
  • Collation implementation and version
  • Comparison options
  • Normalized or generated sort key, when used
  • Immutable tie-breaker
  • Cursor fields in exact comparison order

A cursor should contain every ordered field needed to resume after the last record. If the effective locale changes, discard the old cursor and start at the first page. Never continue a Swedish collation cursor under German rules.

Database collations vary in locale support and versioning. Verify the selected database collation against the same fixtures used on the client. If exact cross-platform parity is required, sort in one owning layer or persist versioned sort keys generated by one collation implementation. Rebuild those keys when the collation data or policy changes.

Test the behavior, not only the comparator call

Build fixture lists that expose policy decisions. A useful minimum set includes:

  • Swedish names containing A, Å, Ä, and Ö
  • German labels containing a and ä
  • Turkish labels containing dotted and dotless I
  • Composed and decomposed forms of the same accented character
  • Labels such as item 2 and item 10
  • Equal display labels with different immutable IDs
  • Empty labels, punctuation, and mixed scripts
  • A locale change while the list is visible
  • A paginated result resumed with a valid and an obsolete cursor

Assert the complete ordered ID list, not merely that the output changed. IDs reveal whether equal labels remain stable. Run the same fixtures against client and server implementations when both sort user-visible data.

Also test search followed by sorting. A query that matches accented and unaccented labels should return a predictable order. Repeat the test after a locale switch and process restart so stale collators cannot pass unnoticed.

Handle failures without hiding them

Locale parsing can fail, a backend may not support the requested collation, or a collation upgrade may change sort keys. Define these paths before release.

Reject malformed locale tags at the boundary. For a valid but unsupported locale, use the product's documented fallback locale and emit an observable diagnostic. Do not fall back independently on the server and client, because two quiet fallbacks can produce different orders.

Version persisted sort keys. During a migration, either regenerate all keys before serving the new order or keep reads on the previous version until rebuilding completes. Mixing key versions inside one result set breaks cursor comparisons.

If the server cannot honor a requested locale, return that resolved locale in the response. The client can then display or log the actual ordering context instead of assuming its request succeeded.

Verify before shipping

Run the shared fixtures for every supported ordering locale as a release check. Assert the comparator options and full ordered ID list. Cover equal-label stability, numeric behavior, locale switching, and pagination restart. When a check fails, print the resolved locale and collation version.

Then inspect one signed mobile build or production-like web bundle. Change the app language, open a list that contains the test characters, search within it, background and restore the app, and fetch another page. The order should remain internally consistent and should change only where the locale contract says it should.

Start with the list users notice most, such as contacts, countries, products, or settings. Write its seven contract answers, add stable fixtures, and replace raw string comparison with one locale-aware comparator. Do not generalize the helper to every list until that first contract passes on the client and its pagination owner.

References