Skip to content

[$175] Avoid repeating full search and filtering work while typing in SearchRouter #103148

Open

Description

@MelvinBot

Background

The Search Router is the suggestion window you open with Cmd+K / Ctrl+K or the search icon in the header. As you type, the UI filters and renders local suggestions right away. Backend search results arrive later and fill the remaining slots, up to the 20-result limit.

Problem

To build local suggestions, the app processes the full report collection and builds contact options on every debounced keystroke. On accounts with thousands of reports and large contact lists, this brute-force filtering blocks the main thread. Under 4x CPU slowdown:

  • Median latency before the first suggestions commit: 307–329 ms
  • React render time per query: over 310 ms
  • UI frame freezes: up to 576 ms

Current Sentry telemetry covers only opening the router and the first list render. Keystroke work is not measured.

Solution

  1. Telemetry baseline: Add sampled Sentry measurements for debounced queries. Track query execution and list render durations across platforms and account sizes. Do not record query text.
  2. Search option index: Add an in-memory search index so query matching and ranking no longer depend on building every UI option.
    • Store the index as a Map<string, OptionIndexRow> keyed by reportID or accountID. Each row holds:
      • Search text: a pre-normalized, lowercased string with titles, subtitles, logins/emails, and participant names.
      • Sort key: the report timestamp / last read time (or contact display name), used for ranking.
    • Build rows in the background during idle time, and update them incrementally as Onyx data changes.
    • On each keystroke, iterate Map.values() with a String.includes() check. Stream matches through the existing bounded MaxHeap (optionsOrderBy) to get the top candidate IDs.
    • Format and validate only those candidates with getSearchOptions. It stays the single source of truth for formatting, permissions, and query-dependent filtering (excludeLogins, includeCurrentUser).
  3. Rollout: Use the Sentry telemetry to confirm responsiveness gains and monitor memory use.

Code clarity is a requirement. Keep the index cleanly isolated, and document its data structures and lookup flow so contributors can understand it and avoid misusing it.

Expected impact (from PoC benchmarks)

  • Time to first result commit drops about 75% (~320 ms → ~70 ms under 4x CPU slowdown).
  • React render duration drops about 75% (~320 ms → ~80 ms). Peak frame freezes drop about 27% on Web and 51% on iOS.
  • Incremental Onyx updates cost about 3 ms per change.

Trade-offs

  • Memory: Retained JS heap grows by ~89 MB (+21.6% warm) to ~171 MB (+41.7% cold) on a large test account, because of cached search rows.
  • Startup work: Pre-warming uses ~144 ms of total CPU time in idle windows, spread over ~3.9 s after startup.

Benchmark setup (Web)

  • Automated Playwright benchmark, Chromium headed at 1440x900, development web build.
  • Large test account: 2,406 reports, 11,840 personal details, 194 policies. Network API traffic blocked to isolate client-side work.
  • 4x CPU throttling plus Onyx churn (background writes every 200 ms across 20 reports).
  • 10 measured runs per version, plus 1 discarded warm-up run.

PoC: software-mansion-labs#420

Reported in Slack.

Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @eVoloshchak

Activity

  1. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Oct 6, 2026
  2. melvin-bot commented on Oct 6, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @eVoloshchak (External)

  3. changed the title [-]Avoid repeating full search and filtering work while typing in SearchRouter[/-] [+][$175] Avoid repeating full search and filtering work while typing in SearchRouter[/+] on Oct 6, 2026
  4. melvin-bot commented on Oct 6, 2026

    @melvin-bot
  5. mohamad-shafeez commented on Oct 6, 2026

    @mohamad-shafeez

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    SearchRouter is doing too much work on the main thread for every debounced search query.

    When the user types in the Search Router, local suggestions are expected to appear immediately while backend results arrive later and fill the remaining slots. However, the current local-search path expands to the full report collection while isSearching is true and repeatedly performs expensive option construction, filtering, and ranking work for each debounced query.

    This means that although SearchRouter ultimately needs at most CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_SUGGESTIONS results, the client is doing work proportional to the size of the account's report/contact data before it can determine those results.

    That is what causes the main-thread blocking described in the issue: under the PoC's 4x CPU slowdown, first-suggestion latency reaches roughly 307–329 ms, React rendering exceeds 310 ms, and frame freezes can reach 576 ms.

    The current telemetry also does not measure the expensive per-query work itself, so regressions or improvements in this part of the interaction are difficult to observe.

    What is the root cause of that problem?

    The current SearchRouter path combines two separate responsibilities that do not both need to happen on every keystroke:

    1. Discover which records could match the query.
    2. Build and validate the final UI options.

    SearchAutocompleteList consumes useFilteredOptions using SEARCH_ROUTER_OPTIONS_CONFIG. Once isSearching becomes true, the local option path works against the full report collection rather than the smaller recent-report window.

    The debounced query then reaches getSearchOptions, which performs the existing option construction, filtering, and ranking work over that large candidate set.

    Much of the information required to discover candidates does not change with every query. Searchable text, stable identifiers, and ordering information can be prepared ahead of time.

    The important distinction is that we should optimize candidate discovery without replacing the existing SearchRouter option semantics.

    What changes do you think we should make in order to solve the problem?

    I would introduce an isolated in-memory search index for the SearchRouter local-suggestion path and keep getSearchOptions as the final source of truth.

    1. Build a lightweight search index outside the typing path

    Maintain a Map keyed by stable report/account identifiers.

    Each index row should contain the pre-normalized searchable representation needed for candidate matching, together with the ordering information required by the existing ranking behavior.

    The searchable representation should cover the same relevant fields currently used to find local suggestions, rather than introducing a second, different definition of what is searchable.

    The index should be populated incrementally during idle/background work and updated as the underlying Onyx data changes.

    This removes repeated preparation of the same searchable data from every debounced query.

    2. Change the query path from full option generation to candidate discovery

    For each debounced query:

    1. Iterate the lightweight index and perform the query match against the pre-normalized search representation.
    2. Use the existing ordering/ranking mechanism and bounded MaxHeap approach to retain only the best candidates needed for the result window.
    3. Pass those candidates through the existing getSearchOptions path.
    4. Keep the existing MAX_AMOUNT_OF_SUGGESTIONS limit and server-result merge behavior unchanged.

    The index should therefore answer:

    Which records are candidates for this query?

    while getSearchOptions continues to answer:

    Which of those candidates are valid SearchRouter options right now, and how should they be represented?

    That separation is important because getSearchOptions must remain responsible for the existing formatting, permission/visibility rules, and query-dependent behavior such as excludeLogins and includeCurrentUser.

    I would not duplicate those rules inside the index unless they are proven to be safe to pre-filter without changing the existing result semantics.

    3. Preserve result and ranking parity

    The optimization should not introduce a new ranking system.

    For the same underlying data and query, the indexed path should produce the same valid local results, in the same order, as the existing implementation.

    The existing ordering logic and bounded heap should be reused rather than creating an independent ranking implementation.

    This also avoids a subtle failure mode where the index returns only a small number of candidates and a later validation step removes some of them, leaving fewer valid results even though additional valid candidates existed below the cutoff.

    The implementation should therefore ensure that candidate selection and final validation work together without changing the existing SearchRouter result semantics.

    4. Make index lifecycle correctness explicit

    The index must not become a stale second copy of SearchRouter data.

    It should be updated for:

    • new reports/contacts
    • changed searchable fields
    • changed ordering information
    • removals/deletions
    • Onyx hydration and subsequent updates
    • session/logout changes
    • account changes

    In particular, entries that disappear from the underlying Onyx data must not remain searchable indefinitely.

    Warmup also needs an explicit readiness strategy. A partially populated index cannot simply be treated as a complete replacement for the current search collection, otherwise the optimization could make valid results disappear while the index is still being built.

    If the index is not ready for the complete dataset, the implementation should have a safe fallback/readiness mechanism rather than silently returning an incomplete result set.

    5. Keep the index work off the critical typing path

    The issue's PoC already demonstrates the intended trade-off: some memory and startup CPU are exchanged for substantially less repeated work while typing.

    Index construction should therefore be performed incrementally during idle/background opportunities rather than as one large synchronous operation.

    Updates should also be incremental where possible instead of rebuilding the entire index after every Onyx change.

    The implementation should respect the existing platform constraints rather than assuming a browser-only execution model.

    6. Add the required Sentry measurements

    The issue specifically calls for sampled measurements around debounced query execution and list rendering, without recording query text.

    There is already a draft PR, #103318, for SearchRouter keystroke telemetry. I would build on or coordinate with that work rather than creating a competing telemetry implementation.

    The measurements should let us compare query execution and rendering across platforms and account sizes and verify the rollout against the original problem.

    Memory usage should also be monitored because the issue's PoC shows that the index has a measurable JS heap cost.

    What alternative solutions did you explore? (Optional)

    I would keep the first implementation focused on the in-memory index described by the issue rather than introducing a new search dependency or moving the entire SearchRouter pipeline to another execution environment.

    A Web Worker/off-thread implementation would introduce additional state synchronization and platform complexity without addressing the underlying problem as directly as precomputing the data that is repeatedly rebuilt today.

    Increasing the debounce delay or deferring rendering would only reduce how frequently the expensive work is performed or how noticeable it is. It would not change the fact that each query still scales with the size of the account.

    Similarly, introducing a third-party search library would add another dependency and indexing model when the existing matching/ranking and bounded MaxHeap mechanisms can be reused.

    The in-memory index keeps the change focused: move reusable candidate data out of the query path while preserving the existing final option-building logic.

    What specific scenarios should we cover in automated tests?

    The regression coverage should primarily prove behavioral parity, not just that the new index can find strings.

    I would cover:

    1. Index construction

      • reports and contacts are indexed from the same source data used by the current SearchRouter path
      • searchable text is normalized consistently
    2. Result parity

      • for a fixed dataset and query, the indexed path produces the same valid local result set as the current path
      • result ordering remains consistent with the existing ranking behavior
    3. Candidate under-fill

      • candidates removed by final validation do not cause valid results below the initial cutoff to disappear
      • the final result count still respects the existing maximum
    4. Incremental updates

      • adding a record makes it searchable
      • changing searchable fields updates its index row
      • changing ordering information updates its ranking data
      • removing/evicting a record removes it from search
    5. Warmup/readiness

      • a partially built index cannot silently return an incomplete replacement result set
      • the safe fallback/readiness behavior is exercised
    6. Session lifecycle

      • logout clears the appropriate index state
      • switching sessions/accounts cannot expose stale results from the previous account
    7. Existing SearchRouter semantics

      • getSearchOptions continues to apply the existing permission/visibility and query-dependent rules
      • excludeLogins / includeCurrentUser behavior remains unchanged
      • the existing 20-result limit remains unchanged
      • local/server result merging remains unchanged
    8. Performance regression coverage

      • reuse the issue's existing benchmark methodology where practical
      • compare query execution and rendering rather than claiming a new performance number without measurement

    The PoC's reported ~75% reduction in first-result latency and render time should be treated as the benchmark target described by the issue, not as a guaranteed result until the implementation is measured in the actual repository.

  6. mohamad-shafeez commented on Oct 7, 2026

    @mohamad-shafeez

    Contributor details
    Your Expensify account email: shafeezchappi18@gmail.com
    Upwork Profile Link: https://www.upwork.com/freelancers/~01ede4d9cba5c66ae0

  7. melvin-bot commented on Oct 7, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  8. camesenin commented on Oct 7, 2026

    @camesenin
  9. melvin-bot commented on Oct 7, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  10. 45 remaining items

  11. melvin-bot commented on Nov 3, 2026

    @melvin-bot

    @eVoloshchak Eep! 4 days overdue now. Issues have feelings too...

  12. melvin-bot commented on Nov 4, 2026

    @melvin-bot

    @eVoloshchak Huh... This is 4 days overdue. Who can take care of this?

  13. melvin-bot commented on Nov 4, 2026

    @melvin-bot

    @eVoloshchak Eep! 4 days overdue now. Issues have feelings too...

  14. eVoloshchak commented on Nov 4, 2026

    @eVoloshchak
    Contributor

    Not overdue

  15. melvin-bot commented on Nov 9, 2026

    @melvin-bot

    @eVoloshchak

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.5.34-1 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

  16. grgia commented on Nov 11, 2026

    @grgia
    Contributor

    @jmgraa is #105898 the last PR here, or are the later roadmap phases still coming?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorOverdueSkip PaymentSkips all the payment process steps for an issue. Use it for fixed-price projects.Task

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions