perf(reader): make first message-open fast independent of backfill (prefetch visible bodies + warm IMAP connection) #357

Closed
opened 2026-07-05 20:54:17 +00:00 by JMR-dev · 1 comment
JMR-dev commented 2026-07-05 20:54:17 +00:00 (Migrated from github.com)

Summary

Make the first open of a message fast regardless of what backfill is doing, by (1) having
the body already cached for the rows the user is likely to tap, and (2) not paying a cold
CONNECT+TLS+LOGIN per interactive fetch. Today an uncached open pays a full network round-trip
on a fresh connection, which — under concurrent backfill — takes 35–74 s (see #355 for the
measurement and correlation).

Evidence (physical-device perf run, 2026-07-05)

Pixel 10 Pro XL, real Gmail (PASSWORD_IMAP) over LTE, origin/main 6118b6d.

  • Open uncached message: avg 48.4 s (35–74 s), n=6. Cached re-open: effectively instant.
    gfx smooth (50th pct 6 ms). So the cost is entirely the first on-demand IMAP body fetch.
  • The tapped messages were recent inbox mail (top of the list: a PR-run email 12 min old, a
    DEV Community digest, a github-actions reply, Meetup, Walgreens, Legacybox). These are the rows
    a user actually opens — and exactly the ones that were not pre-cached.
  • Prefetch was inactive: default FetchPolicy.WIFI_ONLY on metered LTE ->
    SyncResourcePolicy.shouldPrefetchContent = false. So neither MailSyncer nor MailBackfiller
    pre-cached any body; every tap paid a cold fetch.

Root cause (confirmed in code)

  • No prefetch of visible rows on the interactive network. Body prefetch only runs when
    shouldPrefetchContent is true: FetchPolicy.ALWAYS, or WIFI_ONLY on an unmetered network.
    The default is WIFI_ONLY, so on cellular nothing is pre-cached and the first open is always
    cold. MailSyncer fetches the newest 50 headers (fetched=50 in the logs) but not their
    bodies.
  • Connect-per-operation cost. ImapClient opens a fresh CONNECT+TLS+LOGIN and LOGOUT per
    call; the reuse spike (#125) is OFF (reuseConnections = false). Even absent backfill
    contention, each interactive fetch pays a full handshake + login before any body bytes flow.
  • openMessage() -> fetchBodyMarkingSeen() is the only place the body is fetched, lazily, on
    the reader's critical path.

Proposed approach (either/both)

  1. Prefetch the bodies the user is about to open.
    • Prefetch the top-of-inbox / on-screen rows' bodies right after a foreground sync, so the
      newest messages open instantly. Prefer a small, bounded set (e.g. first screenful) over the
      unbounded backfill prefetch.
    • Reconsider the metered gate for this small foreground prefetch: the current WIFI_ONLY
      default disables it exactly when the user is most likely on cellular and actively reading.
      A tightly-bounded "prefetch the first N visible bodies" is cheap even on metered data and is
      distinct from the unbounded full-history body prefetch that WIFI_ONLY is meant to gate
      (#88/#89). Keep the low-battery pause.
  2. Warm/interactive IMAP connection (revisit #125). Wire the already-built
    ImapConnectionCache reuse path so interactive fetches reuse a kept-alive authenticated
    connection instead of a cold login each time. The spike code and validation notes already
    exist (docs/perf/issue-125-connection-reuse-spike.md); this run is the real-device evidence
    that motivated it. Pairs naturally with a dedicated interactive lane (see #355).

Affected files

  • app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt (prefetch newest/visible bodies after sync)
  • app/src/main/kotlin/org/libremail/data/sync/SyncResourcePolicy.kt (bounded foreground-prefetch gate)
  • app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt (prefetchMessage, openMessage)
  • app/src/main/kotlin/org/libremail/mail/ImapClient.kt + ImapConnectionCache.kt (#125 reuse for the interactive path)
  • app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt (if a new bounded-prefetch setting is added)

Definition of done (per repo DoD)

  • Unit tests: newest/visible bodies are prefetched after sync within the bounded set; the
    bounded foreground prefetch runs on metered data while the unbounded backfill body prefetch
    still respects WIFI_ONLY; interactive path reuses a warm connection when reuse is enabled.
  • E2E/instrumented test: a freshly-synced top-of-inbox message opens without a network body
    fetch (GreenMail can assert no second body FETCH).
  • PII-free AppLog for prefetch hits/misses and connection reuse (see #358).

Relates to

Prior perf work #86 and #125 (both closed; #125 built the reuse cache but left it OFF). Root
cause #12; fetch-policy/battery gating #88/#89. Sibling fixes: #355, #356, #358. Filed from the
2026-07-05 Pixel 10 Pro XL perf run.

## Summary Make the **first** open of a message fast regardless of what backfill is doing, by (1) having the body already cached for the rows the user is likely to tap, and (2) not paying a cold CONNECT+TLS+LOGIN per interactive fetch. Today an uncached open pays a full network round-trip on a fresh connection, which — under concurrent backfill — takes 35–74 s (see #355 for the measurement and correlation). ## Evidence (physical-device perf run, 2026-07-05) Pixel 10 Pro XL, real Gmail (`PASSWORD_IMAP`) over LTE, origin/main `6118b6d`. - Open uncached message: **avg 48.4 s (35–74 s)**, n=6. Cached re-open: effectively instant. gfx smooth (50th pct 6 ms). So the cost is entirely the first on-demand IMAP body fetch. - The tapped messages were **recent inbox mail** (top of the list: a PR-run email 12 min old, a DEV Community digest, a github-actions reply, Meetup, Walgreens, Legacybox). These are the rows a user actually opens — and exactly the ones that were **not** pre-cached. - Prefetch was inactive: default `FetchPolicy.WIFI_ONLY` on metered LTE -> `SyncResourcePolicy.shouldPrefetchContent` = false. So neither `MailSyncer` nor `MailBackfiller` pre-cached any body; every tap paid a cold fetch. ## Root cause (confirmed in code) - **No prefetch of visible rows on the interactive network.** Body prefetch only runs when `shouldPrefetchContent` is true: `FetchPolicy.ALWAYS`, or `WIFI_ONLY` on an unmetered network. The default is `WIFI_ONLY`, so on cellular nothing is pre-cached and the first open is always cold. `MailSyncer` fetches the newest 50 **headers** (`fetched=50` in the logs) but not their bodies. - **Connect-per-operation cost.** `ImapClient` opens a fresh CONNECT+TLS+LOGIN and LOGOUT per call; the reuse spike (#125) is OFF (`reuseConnections = false`). Even absent backfill contention, each interactive fetch pays a full handshake + login before any body bytes flow. - `openMessage()` -> `fetchBodyMarkingSeen()` is the only place the body is fetched, lazily, on the reader's critical path. ## Proposed approach (either/both) 1. **Prefetch the bodies the user is about to open.** - Prefetch the top-of-inbox / on-screen rows' bodies right after a foreground sync, so the newest messages open instantly. Prefer a small, bounded set (e.g. first screenful) over the unbounded backfill prefetch. - Reconsider the metered gate for this *small foreground* prefetch: the current WIFI_ONLY default disables it exactly when the user is most likely on cellular and actively reading. A tightly-bounded "prefetch the first N visible bodies" is cheap even on metered data and is distinct from the unbounded full-history body prefetch that WIFI_ONLY is meant to gate (#88/#89). Keep the low-battery pause. 2. **Warm/interactive IMAP connection (revisit #125).** Wire the already-built `ImapConnectionCache` reuse path so interactive fetches reuse a kept-alive authenticated connection instead of a cold login each time. The spike code and validation notes already exist (`docs/perf/issue-125-connection-reuse-spike.md`); this run is the real-device evidence that motivated it. Pairs naturally with a dedicated interactive lane (see #355). ## Affected files - `app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt` (prefetch newest/visible bodies after sync) - `app/src/main/kotlin/org/libremail/data/sync/SyncResourcePolicy.kt` (bounded foreground-prefetch gate) - `app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt` (`prefetchMessage`, `openMessage`) - `app/src/main/kotlin/org/libremail/mail/ImapClient.kt` + `ImapConnectionCache.kt` (#125 reuse for the interactive path) - `app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt` (if a new bounded-prefetch setting is added) ## Definition of done (per repo DoD) - Unit tests: newest/visible bodies are prefetched after sync within the bounded set; the bounded foreground prefetch runs on metered data while the unbounded backfill body prefetch still respects WIFI_ONLY; interactive path reuses a warm connection when reuse is enabled. - E2E/instrumented test: a freshly-synced top-of-inbox message opens without a network body fetch (GreenMail can assert no second body FETCH). - PII-free `AppLog` for prefetch hits/misses and connection reuse (see #358). ## Relates to Prior perf work #86 and #125 (both closed; #125 built the reuse cache but left it OFF). Root cause #12; fetch-policy/battery gating #88/#89. Sibling fixes: #355, #356, #358. Filed from the 2026-07-05 Pixel 10 Pro XL perf run.
JMR-dev commented 2026-07-07 17:15:23 +00:00 (Migrated from github.com)

Resolved — the warm-connection lever (#368) landed and the on-device perf proof confirmed it. Closing per maintainer; will revisit if related issues surface.

Resolved — the warm-connection lever (#368) landed and the on-device perf proof confirmed it. Closing per maintainer; will revisit if related issues surface.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#357