Opening a not-yet-cached message stalls ~35–74 s (avg 48 s) behind the reader's
loading spinner on a real device, because the on-demand body fetch has no priority
over the continuous full-history backfill and loses the race for the account's IMAP
throughput. This ticket: make an interactive open pre-empt / pause background
backfill so the body the user is waiting on is fetched first.
Evidence (physical-device perf run, 2026-07-05)
Pixel 10 Pro XL, Android 17 / SDK 37, real Gmail (PASSWORD_IMAP) over cellular LTE,
debug build of origin/main 6118b6d.
Mailbox -> open an uncached message: avg 48,366 ms, range 34,992–73,658 ms (n=6;
independent validation sample ~55–58 s). The reader navigates instantly; the body
renders behind a spinner until MailRepositoryImpl.openMessage() returns.
Re-opening an already-cached message clears the spinner effectively instantly, and
reader rendering is smooth (gfxinfo 50th pct 6 ms, 7% janky). So the stall is the
first on-demand IMAP body fetch — not UI, navigation, or rendering.
Cold open is healthy (avg 324 ms) and out of scope.
Correlation with backfill (logcat_full_nav.txt):
BackfillWorker.doWork() chains slices back-to-back: while (mailBackfiller.runBackfill() && !isStopped) { }. MailBackfiller.runBackfill() returned moreWork=true for the entire ~50-min session — backfill slice: maxBatches=20 / slice done: moreWork=true
roughly every 85 s from 14:55 to 15:40+, never clearing (full-history backfill, #12).
All six measured opens (device-local 15:22:44–15:27:38) overlapped an in-flight
backfill slice. Worst case: open #4 (73.7 s, 15:25:20 -> 15:26:34) ran entirely inside
the slice 15:25:10 -> 15:26:45.
Default FetchPolicy.WIFI_ONLY + metered LTE means SyncResourcePolicy.shouldPrefetchContent
was false, so backfill was paging headers only (no body prefetch). Even pure header
paging sufficed to starve the interactive fetch.
Root cause (confirmed in code)
ImapClient is connect-per-operation: every call does a fresh CONNECT+TLS+LOGIN then
LOGOUT. The connection-reuse spike (#125) is OFF by default
(ImapClient.@Inject constructor() : this(reuseConnections = false); no DI override).
openMessage() -> ImapClient.fetchBodyMarkingSeen() opens its own connection,
independent of backfill's, and shares no in-process lock with it: MailMaintenanceGate.mutex
only serializes backfill vs the pruner; MailSyncer has a separate mutex. Nothing gives the
interactive fetch priority.
Net: sustained back-to-back backfill logins + FETCHes keep the Gmail account near its
per-account IMAP throttle/bandwidth ceiling, and the un-prioritized reader fetch queues
behind that background storm for tens of seconds.
NB this refines the perf write-up's "monopolize the account's single IMAP connection"
phrasing: it is not one shared socket — it is connect-per-operation with zero
interactive-vs-background prioritization, plus server-side throttling.
Proposed approach
Introduce a small process-wide coordinator (mirroring MailMaintenanceGate) that lets
interactive IMAP work signal "a user fetch is in flight" and makes backfill yield to it:
New InteractiveImapGate (@Singleton) exposing e.g. an active-interactive counter / Mutex or a withInteractive { } scope.
MailRepositoryImpl wraps the user-facing IMAP paths in withInteractive { }: openMessage, inlineImages, downloadAttachment, buildReplyDraft (and optionally
star/delete/move).
MailBackfiller.backfillFolder() already has a natural yield point — it delay(BACKFILL_BATCH_DELAY_MS) and calls ensureActive() between pages. Before starting
each page (and before starting a slice in runBackfill()), check the gate and park
(suspend) while an interactive fetch is active, resuming when it clears. This is cooperative
and needs no thread priorities.
Keep it best-effort: backfill parks between pages, so a page already in flight finishes; the
interactive fetch still wins the next server round-trip. Combine with #356 (bounded backfill)
for the case where the open lands mid-page.
Affected files
app/src/main/kotlin/org/libremail/data/sync/MailMaintenanceGate.kt (pattern to mirror)
app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt (yield to the gate between pages/slices)
Unit test: with the gate held, MailBackfiller parks before its next page and resumes on
release (Turbine/coroutines-test; GreenMail for the IMAP side).
E2E/instrumented test proving an interactive open completes while backfill is enqueued.
PII-free AppLog breadcrumb when backfill yields to / resumes after an interactive fetch
(see #358).
Relates to
Root cause #12 (full-history backfill, closed/implemented). Sibling fixes: #356 (bound backfill), #357 (fast first-open), #358 (observability). Prior perf work: #86, #125 (closed). Filed from the
2026-07-05 Pixel 10 Pro XL perf run.
## Summary
Opening a not-yet-cached message stalls **~35–74 s (avg 48 s)** behind the reader's
loading spinner on a real device, because the on-demand body fetch has **no priority
over the continuous full-history backfill** and loses the race for the account's IMAP
throughput. This ticket: make an interactive open **pre-empt / pause** background
backfill so the body the user is waiting on is fetched first.
## Evidence (physical-device perf run, 2026-07-05)
Pixel 10 Pro XL, Android 17 / SDK 37, real Gmail (`PASSWORD_IMAP`) over cellular LTE,
debug build of origin/main `6118b6d`.
- Mailbox -> open an uncached message: **avg 48,366 ms, range 34,992–73,658 ms** (n=6;
independent validation sample ~55–58 s). The reader navigates instantly; the body
renders behind a spinner until `MailRepositoryImpl.openMessage()` returns.
- Re-opening an **already-cached** message clears the spinner effectively instantly, and
reader rendering is smooth (gfxinfo 50th pct **6 ms**, 7% janky). So the stall is the
first on-demand IMAP body fetch — not UI, navigation, or rendering.
- Cold open is healthy (avg 324 ms) and out of scope.
Correlation with backfill (`logcat_full_nav.txt`):
- `BackfillWorker.doWork()` chains slices back-to-back: `while (mailBackfiller.runBackfill()
&& !isStopped) { }`. `MailBackfiller.runBackfill()` returned `moreWork=true` for the
**entire ~50-min session** — `backfill slice: maxBatches=20` / `slice done: moreWork=true`
roughly every 85 s from 14:55 to 15:40+, never clearing (full-history backfill, #12).
- All six measured opens (device-local **15:22:44–15:27:38**) overlapped an in-flight
backfill slice. Worst case: open #4 (73.7 s, 15:25:20 -> 15:26:34) ran entirely inside
the slice 15:25:10 -> 15:26:45.
- Default `FetchPolicy.WIFI_ONLY` + metered LTE means `SyncResourcePolicy.shouldPrefetchContent`
was false, so backfill was paging **headers only** (no body prefetch). Even pure header
paging sufficed to starve the interactive fetch.
## Root cause (confirmed in code)
- `ImapClient` is **connect-per-operation**: every call does a fresh CONNECT+TLS+LOGIN then
LOGOUT. The connection-reuse spike (#125) is **OFF** by default
(`ImapClient.@Inject constructor() : this(reuseConnections = false)`; no DI override).
- `openMessage()` -> `ImapClient.fetchBodyMarkingSeen()` opens its **own** connection,
independent of backfill's, and shares **no in-process lock** with it: `MailMaintenanceGate.mutex`
only serializes backfill vs the pruner; `MailSyncer` has a separate mutex. **Nothing gives the
interactive fetch priority.**
- Net: sustained back-to-back backfill logins + FETCHes keep the Gmail account near its
per-account IMAP throttle/bandwidth ceiling, and the un-prioritized reader fetch queues
behind that background storm for tens of seconds.
- NB this refines the perf write-up's "monopolize the account's single IMAP connection"
phrasing: it is **not** one shared socket — it is connect-per-operation with **zero
interactive-vs-background prioritization**, plus server-side throttling.
## Proposed approach
Introduce a small process-wide coordinator (mirroring `MailMaintenanceGate`) that lets
interactive IMAP work signal "a user fetch is in flight" and makes backfill yield to it:
- New `InteractiveImapGate` (`@Singleton`) exposing e.g. an active-interactive counter /
`Mutex` or a `withInteractive { }` scope.
- `MailRepositoryImpl` wraps the user-facing IMAP paths in `withInteractive { }`:
`openMessage`, `inlineImages`, `downloadAttachment`, `buildReplyDraft` (and optionally
star/delete/move).
- `MailBackfiller.backfillFolder()` already has a natural yield point — it
`delay(BACKFILL_BATCH_DELAY_MS)` and calls `ensureActive()` between pages. Before starting
each page (and before starting a slice in `runBackfill()`), check the gate and **park**
(suspend) while an interactive fetch is active, resuming when it clears. This is cooperative
and needs no thread priorities.
- Keep it best-effort: backfill parks between pages, so a page already in flight finishes; the
interactive fetch still wins the next server round-trip. Combine with #356 (bounded backfill)
for the case where the open lands mid-page.
## Affected files
- `app/src/main/kotlin/org/libremail/data/sync/MailMaintenanceGate.kt` (pattern to mirror)
- `app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt` (yield to the gate between pages/slices)
- `app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt` (`openMessage`, `inlineImages`, `downloadAttachment`, `buildReplyDraft`)
- `app/src/main/kotlin/org/libremail/mail/ImapClient.kt` (interactive fetch entry points)
- new: `InteractiveImapGate` (+ Hilt binding)
## Definition of done (per repo DoD)
- Unit test: with the gate held, `MailBackfiller` parks before its next page and resumes on
release (Turbine/coroutines-test; GreenMail for the IMAP side).
- E2E/instrumented test proving an interactive open completes while backfill is enqueued.
- PII-free `AppLog` breadcrumb when backfill yields to / resumes after an interactive fetch
(see #358).
## Relates to
Root cause #12 (full-history backfill, closed/implemented). Sibling fixes: #356 (bound backfill),
#357 (fast first-open), #358 (observability). Prior perf work: #86, #125 (closed). Filed from the
2026-07-05 Pixel 10 Pro XL perf run.
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
Opening a not-yet-cached message stalls ~35–74 s (avg 48 s) behind the reader's
loading spinner on a real device, because the on-demand body fetch has no priority
over the continuous full-history backfill and loses the race for the account's IMAP
throughput. This ticket: make an interactive open pre-empt / pause background
backfill so the body the user is waiting on is fetched first.
Evidence (physical-device perf run, 2026-07-05)
Pixel 10 Pro XL, Android 17 / SDK 37, real Gmail (
PASSWORD_IMAP) over cellular LTE,debug build of origin/main
6118b6d.independent validation sample ~55–58 s). The reader navigates instantly; the body
renders behind a spinner until
MailRepositoryImpl.openMessage()returns.reader rendering is smooth (gfxinfo 50th pct 6 ms, 7% janky). So the stall is the
first on-demand IMAP body fetch — not UI, navigation, or rendering.
Correlation with backfill (
logcat_full_nav.txt):BackfillWorker.doWork()chains slices back-to-back:while (mailBackfiller.runBackfill() && !isStopped) { }.MailBackfiller.runBackfill()returnedmoreWork=truefor theentire ~50-min session —
backfill slice: maxBatches=20/slice done: moreWork=trueroughly every 85 s from 14:55 to 15:40+, never clearing (full-history backfill, #12).
backfill slice. Worst case: open #4 (73.7 s, 15:25:20 -> 15:26:34) ran entirely inside
the slice 15:25:10 -> 15:26:45.
FetchPolicy.WIFI_ONLY+ metered LTE meansSyncResourcePolicy.shouldPrefetchContentwas false, so backfill was paging headers only (no body prefetch). Even pure header
paging sufficed to starve the interactive fetch.
Root cause (confirmed in code)
ImapClientis connect-per-operation: every call does a fresh CONNECT+TLS+LOGIN thenLOGOUT. The connection-reuse spike (#125) is OFF by default
(
ImapClient.@Inject constructor() : this(reuseConnections = false); no DI override).openMessage()->ImapClient.fetchBodyMarkingSeen()opens its own connection,independent of backfill's, and shares no in-process lock with it:
MailMaintenanceGate.mutexonly serializes backfill vs the pruner;
MailSyncerhas a separate mutex. Nothing gives theinteractive fetch priority.
per-account IMAP throttle/bandwidth ceiling, and the un-prioritized reader fetch queues
behind that background storm for tens of seconds.
phrasing: it is not one shared socket — it is connect-per-operation with zero
interactive-vs-background prioritization, plus server-side throttling.
Proposed approach
Introduce a small process-wide coordinator (mirroring
MailMaintenanceGate) that letsinteractive IMAP work signal "a user fetch is in flight" and makes backfill yield to it:
InteractiveImapGate(@Singleton) exposing e.g. an active-interactive counter /Mutexor awithInteractive { }scope.MailRepositoryImplwraps the user-facing IMAP paths inwithInteractive { }:openMessage,inlineImages,downloadAttachment,buildReplyDraft(and optionallystar/delete/move).
MailBackfiller.backfillFolder()already has a natural yield point — itdelay(BACKFILL_BATCH_DELAY_MS)and callsensureActive()between pages. Before startingeach page (and before starting a slice in
runBackfill()), check the gate and park(suspend) while an interactive fetch is active, resuming when it clears. This is cooperative
and needs no thread priorities.
interactive fetch still wins the next server round-trip. Combine with #356 (bounded backfill)
for the case where the open lands mid-page.
Affected files
app/src/main/kotlin/org/libremail/data/sync/MailMaintenanceGate.kt(pattern to mirror)app/src/main/kotlin/org/libremail/data/sync/MailBackfiller.kt(yield to the gate between pages/slices)app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt(openMessage,inlineImages,downloadAttachment,buildReplyDraft)app/src/main/kotlin/org/libremail/mail/ImapClient.kt(interactive fetch entry points)InteractiveImapGate(+ Hilt binding)Definition of done (per repo DoD)
MailBackfillerparks before its next page and resumes onrelease (Turbine/coroutines-test; GreenMail for the IMAP side).
AppLogbreadcrumb when backfill yields to / resumes after an interactive fetch(see #358).
Relates to
Root cause #12 (full-history backfill, closed/implemented). Sibling fixes: #356 (bound backfill),
#357 (fast first-open), #358 (observability). Prior perf work: #86, #125 (closed). Filed from the
2026-07-05 Pixel 10 Pro XL perf run.