Skip to content

perf(auth): build the token store lazily and warm it off the main thread - #362

Merged
garfiec merged 1 commit into
developfrom
perf/token-store-cold-start
Aug 13, 2026
Merged

perf(auth): build the token store lazily and warm it off the main thread#362
garfiec merged 1 commit into
developfrom
perf/token-store-cold-start

Conversation

@garfiec

@garfiec garfiec commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Summary

Cold start built the encrypted token store and decrypted the active session on the
main thread, inside startKoin. The store is now created on first use, and an eager
TokenCacheWarmer performs the decrypt on the IO dispatcher instead.

The synchronous read behind isAuthenticated / getAccessToken stays, now reading
through to storage rather than seeding the cache. A reader arriving before the warm
lands therefore gets the same value it would have before — paying a decrypt per call
until the warm publishes, rather than risking a stale bearer.

Changes

Token store

  • TokenDataStore (Android) creates its EncryptedSharedPreferences on first use,
    published through a single @Volatile reference. One reference rather than a store
    plus a created flag: two fields let a reader observe "built" before the store is
    published, read that as "the keystore gave up", and route a login into the in-memory
    fallback, where it is lost at process death.
  • CommonTokenDataStore.warmTokenCache() — idempotent, takes stateMutex, so a warm
    cannot interleave with a mutation; tokenInitialized becomes @Volatile.
  • ensureTokenLoaded() reads through to storage and publishes nothing, leaving the
    warm as the only writer of the cache fields outside a mutation.
  • emitSessionExpired resolves the active account through storage when the cache is
    not yet warm, instead of comparing against an unpopulated field. That comparison is
    a secure-storage read on the caller's thread during the pre-warm window.
  • TokenCacheWarmer, an eager Koin single that performs the load rather than awaiting
    one triggered elsewhere.
  • Both platform stores drop their constructor-time seed, so iOS moves to the same
    async warm (keychain rather than keystore) with the same unwarmed read-through
    window. ServerUrlKeychainFallback.readServerUrl stays synchronous and unwarmed by
    design.

Session cache cleaner

  • CommonSessionCacheCleaner takes cacheRoot: () -> String, evaluated on the
    teardown paths that clear caches (logout and last-account removal). It is a
    constructor dependency of AuthRepositoryImpl, so an eagerly resolved root put a
    disk read on the main thread when NavHostViewModel was first built.
  • Consequence on iOS: an unresolvable NSCachesDirectory now throws inside
    clearFileCaches, where it is caught and logged, rather than at DI resolution.
    Caches go uncleared with a warning instead of failing the first frame.

TokenManager is unchanged; isAuthenticated and getAccessToken are kept.

Verification

Unit tests, including a new CommonTokenDataStoreWarmTest covering construction
touching storage zero times, warm idempotency under concurrency, a warm ordered
against a login, and the read-through fallback.

Android emulator (API 37), against the parent commit through an identical rig, five
cold starts per state:

before after
keystore build thread main, 15/15 runs IO worker, 15/15 runs
main-thread violations at createEncryptedPrefs 10/run 0/run
main-thread cacheDir read 1/run 0/run
cold-start time (am start -W, median) 1687 ms 1652 ms

Logged-in, logged-out, expired-session cold starts, an account switch and a sign-out
all behave as before. With the warm suppressed for the length of a sign-in, the first
write still persists to the encrypted store and a logged-in launch is carried by the
synchronous fallback alone.

Not verified: physical hardware, and iOS beyond compilation.

Notes

With the keystore work off the main thread, an unrelated pre-existing main-thread disk
read becomes visible at cold start: slf4j's ServiceLoader scanning the apk during Ktor
client creation. Not introduced here; worth its own issue.

Creating the encrypted token store and decrypting the active session both ran on
the thread startKoin runs on, because TokenDataStore did the work in its field
initializer and its init block. Build the store on first use instead, and let an
eager TokenCacheWarmer perform the cache load on the IO dispatcher.

The synchronous load stays as the fallback, which is what makes this safe: if the
warm loses the race to a reader, the load happens on the caller's thread exactly
as before. A lost race costs the previous behaviour, not a wrong bearer.

TokenDataStore publishes the built store through a single @volatile reference
rather than a store plus a "created" flag, so no reader can observe "built" before
the store is published, read that as "the keystore gave up", and route a login into
the in-memory fallback where it dies at process death.

warmTokenCache takes stateMutex so it cannot interleave with a mutation, and
ensureTokenLoaded reads through without publishing, leaving the warm the only
writer of the cache fields outside a mutation. emitSessionExpired resolves the
active account through storage while the cache is cold, since comparing against an
unpopulated field would suppress a real expiry.

Also defers CommonSessionCacheCleaner's cache root behind a function: it is a
constructor dependency of AuthRepositoryImpl, so resolving the root eagerly put a
disk read on the main thread whenever NavHostViewModel was built.

TokenManager is unchanged.
@github-actions

Copy link
Copy Markdown
Contributor

Android debug APK

Artifact: switchboard-android-debug-362
Download: switchboard-android-debug-362.zip
Retention: 90 days
Commit: 40dd3623de1d398885b58738f1f5521a78998b8e

Download requires a GitHub login. Installs over previous debug builds without uninstalling (stable signing key).

@garfiec
garfiec merged commit 11e4aef into develop Aug 13, 2026
6 checks passed
@garfiec
garfiec deleted the perf/token-store-cold-start branch August 13, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant