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)
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.
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/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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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/main6118b6d.gfx smooth (50th pct 6 ms). So the cost is entirely the first on-demand IMAP body fetch.
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.
FetchPolicy.WIFI_ONLYon metered LTE ->SyncResourcePolicy.shouldPrefetchContent= false. So neitherMailSyncernorMailBackfillerpre-cached any body; every tap paid a cold fetch.
Root cause (confirmed in code)
shouldPrefetchContentis true:FetchPolicy.ALWAYS, orWIFI_ONLYon an unmetered network.The default is
WIFI_ONLY, so on cellular nothing is pre-cached and the first open is alwayscold.
MailSyncerfetches the newest 50 headers (fetched=50in the logs) but not theirbodies.
ImapClientopens a fresh CONNECT+TLS+LOGIN and LOGOUT percall; the reuse spike (#125) is OFF (
reuseConnections = false). Even absent backfillcontention, each interactive fetch pays a full handshake + login before any body bytes flow.
openMessage()->fetchBodyMarkingSeen()is the only place the body is fetched, lazily, onthe reader's critical path.
Proposed approach (either/both)
newest messages open instantly. Prefer a small, bounded set (e.g. first screenful) over the
unbounded backfill prefetch.
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.
ImapConnectionCachereuse path so interactive fetches reuse a kept-alive authenticatedconnection 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 evidencethat 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)
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.
fetch (GreenMail can assert no second body FETCH).
AppLogfor 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.
Resolved — the warm-connection lever (#368) landed and the on-device perf proof confirmed it. Closing per maintainer; will revisit if related issues surface.