Jotai Cold Start SSR + unified startup timing schema — cold start optimization via MMKV snapshot hydration and the cross-platform `[StartupTiming]` log taxonomy for OneKey native app. Use when debugging startup performance regressions, analyzing cold start timeline, comparing iOS vs Android startup phases, or modifying the snapshot hydration pipeline. Triggers on: cold start, startup optimization, 启动时间, SSR hydration, Balance displayed regression, MMKV snapshot, contextAtomBase, flushColdStartCache, __ONEKEY_CTX_ATOM_SNAPSHOT__, StartupTiming, main_host.did_start, bg_runner.start, ios.main_entry.evaluated, android.app.on_create, android.activity.on_create.
disable-model-invocation
true
Jotai Cold Start SSR
Cold start optimization pattern for OneKey native app. Analogous to web SSR hydration — previous session's atom values are persisted to MMKV, pre-read at startup, and used as initial atom values so the first React render displays cached data immediately without waiting for network.
Currently supported: Native (iOS/Android) only. Desktop/Web/Extension support planned.
Architecture Overview
Session N (runtime) Session N+1 (cold start)
───────────────────── ──────────────────────────
Phase 3: SAVE Phase 1: PRE-READ
atom value changes index.ts (entry point)
→ coldStartValuesMap → MMKV.getString(snapshot)
→ debounce 2s → globalThis.__ONEKEY_CTX_ATOM_SNAPSHOT__
→ flushColdStartCache()
→ MMKV.set(snapshot JSON) Phase 2: HYDRATION
contextAtomBase (module load)
Also flushes on AppState → read __ONEKEY_CTX_ATOM_SNAPSHOT__
'background' event → use as atom initialValue
→ first render shows cached data
Phase 4: REVALIDATION
BG thread fetches fresh data
→ atoms update in-place
→ UI re-renders with live data
The Three Phases (Code Locations)
Phase 1: Snapshot Pre-read
File:apps/mobile/index.ts (top of entry point, before any module imports)
// Reads cold start cache from dedicated MMKV instance into globalThis// MUST execute before any contextAtomBase module evaluatesconst _ctxRaw = coldStartCacheStorage.getString(
EAppSyncStorageKeys.onekey_jotai_context_atoms_snapshot,
);
if (_ctxRaw) {
(globalThis asany).__ONEKEY_CTX_ATOM_SNAPSHOT__ = JSON.parse(_ctxRaw);
}
Key constraints:
Must be synchronous (MMKV is sync)
Must run before any require() that triggers contextAtomBase
Stored in dedicated coldStartCacheStorage MMKV instance (separate from app settings)
// Read-modify-write: patch only dirty keys into existing snapshot// Preserves cached values for scopes not rendered this sessionconst snapshot = raw ? JSON.parse(raw) : {};
for (const name of coldStartDirtyKeys) {
snapshot[name] = coldStartValuesMap.get(name);
}
coldStartCacheStorage.set(key, JSON.stringify(snapshot));
Trigger points:
scheduleColdStartSave() — debounced 2s timer after any atom value change
AppState 'background' event — flush immediately when app goes to background
Key constraints:
Uses read-modify-write (not full overwrite) to preserve unrendered scopes
All callers are on main thread — no cross-thread race
coldStartValuesMap tracks all rendered atom values via wrappedUse()
Snapshot Cleanup
__ONEKEY_CTX_ATOM_SNAPSHOT__ is cleaned up on HomePageReady event (first screen rendered), not on setTimeout(0). This ensures split-bundle lazy-loaded modules can still hydrate from the snapshot.
Split Bundle: main vs background Bundle Sizes
The app uses a dual-runtime split bundle architecture. Bundle sizes directly impact cold start:
common.jsbundle ~8.8MB Shared polyfills, loaded by native at app launch
main.jsbundle ~10.1MB UI thread entry, async-evaluated after ~100ms defer
background.bundle ~20.7MB BG thread, loaded in parallel Hermes runtime
+ segment files variable Lazy-loaded on demand (vault impls, icons, etc.)
main.jsbundle (10.1MB): async eval takes ~1300ms — the single biggest bottleneck (87% of total startup)
background.bundle (20.7MB): runs in parallel, apiProxy import ~700ms. Currently non-blocking but close to critical path (BG ready at +1261ms vs main eval at +1300ms)
Segment loads: icon segments ~25ms each, vault settings ~20ms each, loaded on-demand after first render
Rules of thumb:
Any code added to main.jsbundle directly increases the 1300ms eval time
Move non-critical code to segments (lazy import()) to keep main bundle lean
background.bundle size is less critical since it runs in parallel, but if it gets slower than main eval it becomes a blocker
Use apps/mobile/scripts/unionBuild.js to analyze bundle composition
contextAtom Cold Start Cache Keys (SSR Keys)
Each context atom that participates in Cold Start SSR must declare a coldStartCacheKey. These keys are registered in a central const:
Context atoms are scoped by provider (e.g., different accounts). The snapshot stores scoped keys:
{scopeKey}::{coldStartCacheKey}
Example: hd-1--0::ctx:renderedTokenListCacheAtom
scopeKey comes from store.__ONEKEY_JOTAI_COLD_START_SCOPE_KEY__ (set when creating the Jotai store for a provider)
coldStartCacheKey is the ctx:xxx string from the const above
Adding a new SSR-cached atom:
Add key to CONTEXT_ATOM_COLD_START_CACHE_KEYS in jotaiConsts.ts
Pass { coldStartCache: true, coldStartCacheKey: CONTEXT_ATOM_COLD_START_CACHE_KEYS.yourKey } to contextAtom()
The atom will automatically be tracked by wrappedUse() and saved by flushColdStartCache()
On next cold start, the cached value will be used as initialValue via Phase 2 hydration
Caution: Only cache atoms whose data is safe to show stale (e.g., token list, balance). Don't cache atoms with security-sensitive or time-critical data.
SWR Cache (usePromiseResult)
Separate from Jotai Cold Start SSR but shares the same coldStartCacheStorage MMKV instance.
File:packages/shared/src/utils/swrCacheUtils.ts
Purpose: Cache results of usePromiseResult hooks so repeated renders / screen revisits don't re-fetch from network.
Actually main thread's require('./App') chain total
main entry evaluated
apps/mobile/index.ts
Main JS bundle top-level done
Balance displayed
Home page first paint
Target TTI metric
[BackgroundEntry] polyfills loaded
apps/mobile/background.ts
BG thread polyfills done
[BackgroundEntry] backgroundApiProxy ready
apps/mobile/background.ts
BG thread main module ready
[BackgroundEntry] entry JS executed
apps/mobile/background.ts
BG thread bundle done
Step 3: Expected Timelines
All numbers below are measured on codex/feat-split-background-thread
(commits 18c67990d7 + ee1877d289) on real devices, not estimates. Update
when the build pipeline or App require-tree changes materially.
Android baseline — total ~2.4–3.2s tap-to-Balance (5-run sample)
Native phase anchors at android.app.on_create.start (first line of
MainApplication.onCreate). The tap → process-fork → zygote/ART/dex2oat
window happens before the anchor and is reported by
android.zygote_to_app_on_create for context, not added to "+from launch".
iOS baseline — total ~?s tap-to-Balance (TBD, awaiting fixed build)
⚠ The first iOS instrumented build (commit 18c67990d7) had a Swift
lazy-init bug: appLaunchCFTime was a module-level let that only
initialized on first read (now in didFinishLaunching), collapsing every
"+from launch" to ~0ms. Fixed in ee1877d289 by moving the anchor to
AppDelegate.appLaunchCFTime (static let) and force-evaluating it inside
AppDelegate.init(). Re-baseline iOS once the new build is on a device.
Approximate iOS timeline shape (deltas between phases are reliable from the
buggy build; absolute "+from launch" needs the fixed build):
ios.app.did_finish_launching.start +Xms (was 0 due to lazy bug)
main_host.did_start (common bundle loaded) +X+14ms
bg_runner.start +X+14ms
ios.app.jpush_register 2ms
ios.app.super_did_finish_launching 0ms ← RN init happens in factory.startReactNative, not super
ios.app.did_finish_launching.done +X+22ms
ios.main_entry.deferred +X+34ms (defer delay ~21ms)
ios.main_entry.evaluated +X+41ms (just dispatch — async load)
── JS phase ──
[BackgroundEntry] polyfills loaded +51ms (from JS entry)
[StartupTiming] BG transport setup +844ms ← ~1ms/2 of Android
[StartupTiming] main entry evaluated +844ms
[BackgroundEntry] backgroundApiProxy ready +767ms
Balance displayed +1077-1127ms (warm) ← target TTI
iOS vs Android (warm restart medians, JS side):
Metric
iOS
Android
Ratio
Balance displayed (from JS entry)
~1100ms
~2200ms
2.0×
BG transport setup (require('./App') chain)
~790ms
~1700ms
2.2×
backgroundApiProxy ready (BG thread)
~720ms
~1500ms
2.1×
[BackgroundEntry] polyfills loaded
~51ms
~115ms
2.3×
Conclusion: Hermes-iOS executes the same JS bundle ~2× faster than
Hermes-Android on this device. JS parse time is the dominant cost on both
platforms (75-85% of total cold start), much larger than any native phase.
main.jsbundle grew, or dispatch scheduling pressure
Re-check bundle composition, unionBuild.js output
Balance displayed OK but layout shift
Cached data shape mismatch — partial hydration
Check resolvedInitialValue merge logic
Memory growth over sessions
Snapshot blob growing unbounded
Check snapshot key count, consider LRU eviction
Step 5: Verify SSR Pipeline
# 1. Check Phase 1 executed
grep "MMKV contextAtom snapshot pre-read""$LOG"# Expected: "N keys (+XXXms)"# 2. Check Phase 2 hydration (no explicit log; if Balance displayed is# within baseline, hydration is working)# 3. Check Phase 3 save (cold start cache flush after balance)
grep "ColdStartCache""$LOG"# 4. Check cleanup timing
grep "HomePageReady""$LOG"# 5. Cross-platform comparison: line up the shared milestones
grep -E "StartupTiming.*(main_host\.did_start|bg_runner\.start)""$LOG"# 6. Pull a single-session timing table (sorted)
grep 'StartupTiming'"$LOG" | awk -F'\\] ''{print $NF}' | head -40
Step 6: Parse for Tracking / Regression Dashboard
Since all native + JS timing lines share the [StartupTiming] tag with a consistent
<label>: <detail> (+<cumulative>ms from launch) shape, a minimal parser is:
# Extract label → cumulative_ms pairs
grep 'StartupTiming'"$LOG" \
| sed -E 's/.*\[StartupTiming\] ([a-z0-9_.]+).*\+([0-9]+)ms from launch.*/\1\t\2/' \
| grep -v 'StartupTiming'# drop lines without cumulative
Feed into a time-series store (Sentry, internal dashboard, etc.) keyed by label
to spot per-phase regressions over builds.
Critical Rules
Never remove Phase 2 module-load-time hydration — this is the core of the SSR pattern. Without it, atoms start empty and the app waits for network (~2s regression).
Never use setTimeout(0) for snapshot cleanup — split-bundle modules load asynchronously and need the snapshot. Use HomePageReady event.
Always use read-modify-write in flushColdStartCache — full overwrite drops cached values for unrendered scopes (e.g., different accounts).
Phase 1 must execute before any contextAtomBase — the snapshot must be on globalThis before modules evaluate. Place it at the very top of index.ts.
coldStartCacheStorage is a separate MMKV instance — isolated from app settings to prevent contention with unrelated writes.
Currently native-only. To extend to other platforms:
Desktop (Electron):electron-store is synchronous — same pattern applies. Replace MMKV reads with electron-store reads in Phase 1.
Web: Use localStorage.getItem() (synchronous) in Phase 1. Phase 3 writes via localStorage.setItem().
Extension: Extension background persists via chrome.storage. UI popup can read from localStorage for sync Phase 1. Cross-context sync via __ONEKEY_JOTAI_INIT_STATES__ (existing mechanism).
Key requirement for all platforms: Phase 1 must be synchronous and execute before module evaluation.