Skip to content

Slow event loading (~10s) that degrades over time #15

Description

@kraenhansen

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)

  1. Add composite indexes on events_occurrences for the three query patterns (non-recurring, finite recurring, infinite recurring)
  2. 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
  3. Batch selectEventEntitiesByUids — the DAO already has an unused selectByUidIn() method that could replace the N individual LIKE queries
  4. Replace O(n²) filter with a Set<Triple<userId, calendarId, eventId>> lookup
  5. Cap decryption cache with LRU eviction (e.g. 5,000 entries)
  6. 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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions