Repository navigation
Description
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
- 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.
- 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 byreportIDoraccountID. 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 aString.includes()check. Stream matches through the existing boundedMaxHeap(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).
- Store the index as a
- 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
- Upwork Job URL: https://www.upwork.com/jobs/~022107386264393515456
- Upwork Job ID: 2107386264393515456
- Last Price Increase: 2026-10-06
Issue Owner
Current Issue Owner: @eVoloshchakMetadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsCRITICAL
Activity
melvin-bot commentedon Oct 6, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @eVoloshchak (
External)[-]Avoid repeating full search and filtering work while typing in SearchRouter[/-][+][$175] Avoid repeating full search and filtering work while typing in SearchRouter[/+]melvin-bot commentedon Oct 6, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022107386264393515456
mohamad-shafeez commentedon Oct 6, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
SearchRouteris 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
isSearchingis 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_SUGGESTIONSresults, 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:
SearchAutocompleteListconsumesuseFilteredOptionsusingSEARCH_ROUTER_OPTIONS_CONFIG. OnceisSearchingbecomes 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
getSearchOptionsas the final source of truth.1. Build a lightweight search index outside the typing path
Maintain a
Mapkeyed 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:
MaxHeapapproach to retain only the best candidates needed for the result window.getSearchOptionspath.MAX_AMOUNT_OF_SUGGESTIONSlimit and server-result merge behavior unchanged.The index should therefore answer:
while
getSearchOptionscontinues to answer:That separation is important because
getSearchOptionsmust remain responsible for the existing formatting, permission/visibility rules, and query-dependent behavior such asexcludeLoginsandincludeCurrentUser.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:
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
MaxHeapmechanisms 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:
Index construction
Result parity
Candidate under-fill
Incremental updates
Warmup/readiness
Session lifecycle
Existing SearchRouter semantics
getSearchOptionscontinues to apply the existing permission/visibility and query-dependent rulesexcludeLogins/includeCurrentUserbehavior remains unchangedPerformance regression coverage
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.
mohamad-shafeez commentedon Oct 7, 2026
Contributor details
Your Expensify account email: shafeezchappi18@gmail.com
Upwork Profile Link: https://www.upwork.com/freelancers/~01ede4d9cba5c66ae0
melvin-bot commentedon Oct 7, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
camesenin commentedon Oct 7, 2026
melvin-bot commentedon Oct 7, 2026
✅ Contributor details stored successfully. Thank you for contributing to Expensify!
45 remaining items
melvin-bot commentedon Nov 3, 2026
@eVoloshchak Eep! 4 days overdue now. Issues have feelings too...
melvin-bot commentedon Nov 4, 2026
@eVoloshchak Huh... This is 4 days overdue. Who can take care of this?
melvin-bot commentedon Nov 4, 2026
@eVoloshchak Eep! 4 days overdue now. Issues have feelings too...
eVoloshchak commentedon Nov 4, 2026
Not overdue
melvin-bot commentedon Nov 9, 2026
@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:
grgia commentedon Nov 11, 2026
@jmgraa is #105898 the last PR here, or are the later roadmap phases still coming?