Hi folks - I'm experiencing some performance issues with the app displaying events and I had Claude do some digging - results are below. Let me know what you think might be the best cause of action and I'd be happy to (have Claude) spin up a PR which fix some of this.
Problem
Event loading takes ~10 seconds before events appear on screen, and this degrades over time as more events accumulate in the local database. Clearing app data temporarily resolves the issue, suggesting the problem is related to local data growth rather than network latency.
The architecture is designed to show cached events instantly (DB queries and network fetch run in parallel), but the processing between DB query result and UI render is the bottleneck — decrypt + expand happens synchronously on every render.
Root Causes
1. Decrypt + expand under a global mutex
In GetUiEventsUseCase.kt:133, a single mutex.withLock serializes:
- Decrypting every event (PGP session key decrypt + signature verification — per event)
- Expanding recurring events (RRULE expansion generates all occurrences up to the window end)
- N+1 UID queries —
selectEventEntitiesByUids runs one LIKE '%UID:..%' query per UID instead of batching
This all happens on every emission from the combine() of 4 DB flows, before the UI receives any Success result.
2. Unbounded infinite recurring query
EventOccurrencesDao.selectInfiniteRecurring() fetches ALL infinite recurring events where firstOccurrenceStartTime <= toDate — there is no lower bound. As users accumulate weekly meetings, birthdays, etc., this result set grows monotonically and never shrinks.
3. Missing database indexes
The only index on events_occurrences is on eventId. The three event queries effectively do table scans filtering on (userId, calendarId, rRule, startTime, endTime, lastOccurrenceEndTime).
4. O(n²) filter on infinite recurring events
The notOccurring filter (GetUiEventsUseCase.kt:112-118) uses nested filterNot { recurring -> notOccurring.any { ... } } instead of a Set-based lookup.
5. Unbounded decryption cache
EventDecryptorImpl uses a ConcurrentHashMap with no size limit, keyed on (eventId, calendarId, modifyTime). This grows indefinitely without eviction.
Suggested Improvements (rough priority order)
- Add composite indexes on
events_occurrences for the three query patterns (non-recurring, finite recurring, infinite recurring)
- Bound the infinite recurring query — add a lower bound or use a heuristic to exclude very old recurring events that can't possibly have occurrences in the requested window
- Batch
selectEventEntitiesByUids — the DAO already has an unused selectByUidIn() method that could replace the N individual LIKE queries
- Replace O(n²) filter with a
Set<Triple<userId, calendarId, eventId>> lookup
- Cap decryption cache with LRU eviction (e.g. 5,000 entries)
- Pre-decrypt on sync — decrypt and cache events when they arrive from the network, rather than deferring all crypto to UI render time
Notes
- The iOS calendar app (ProtonMail/ios-calendar) reportedly does not have this issue, suggesting a different caching/decryption strategy
- The
GetMinimalCalendarEventsUseCase ("minimal" = single calendar, current month) still performs full decryption — there is no lightweight render path
Hi folks - I'm experiencing some performance issues with the app displaying events and I had Claude do some digging - results are below. Let me know what you think might be the best cause of action and I'd be happy to (have Claude) spin up a PR which fix some of this.
Problem
Event loading takes ~10 seconds before events appear on screen, and this degrades over time as more events accumulate in the local database. Clearing app data temporarily resolves the issue, suggesting the problem is related to local data growth rather than network latency.
The architecture is designed to show cached events instantly (DB queries and network fetch run in parallel), but the processing between DB query result and UI render is the bottleneck — decrypt + expand happens synchronously on every render.
Root Causes
1. Decrypt + expand under a global mutex
In
GetUiEventsUseCase.kt:133, a singlemutex.withLockserializes:selectEventEntitiesByUidsruns oneLIKE '%UID:..%'query per UID instead of batchingThis all happens on every emission from the
combine()of 4 DB flows, before the UI receives anySuccessresult.2. Unbounded infinite recurring query
EventOccurrencesDao.selectInfiniteRecurring()fetches ALL infinite recurring events wherefirstOccurrenceStartTime <= toDate— there is no lower bound. As users accumulate weekly meetings, birthdays, etc., this result set grows monotonically and never shrinks.3. Missing database indexes
The only index on
events_occurrencesis oneventId. The three event queries effectively do table scans filtering on(userId, calendarId, rRule, startTime, endTime, lastOccurrenceEndTime).4. O(n²) filter on infinite recurring events
The
notOccurringfilter (GetUiEventsUseCase.kt:112-118) uses nestedfilterNot { recurring -> notOccurring.any { ... } }instead of aSet-based lookup.5. Unbounded decryption cache
EventDecryptorImpluses aConcurrentHashMapwith no size limit, keyed on(eventId, calendarId, modifyTime). This grows indefinitely without eviction.Suggested Improvements (rough priority order)
events_occurrencesfor the three query patterns (non-recurring, finite recurring, infinite recurring)selectEventEntitiesByUids— the DAO already has an unusedselectByUidIn()method that could replace the N individual LIKE queriesSet<Triple<userId, calendarId, eventId>>lookupNotes
GetMinimalCalendarEventsUseCase("minimal" = single calendar, current month) still performs full decryption — there is no lightweight render path