HarmonyOS Cold Start Optimization: 4-Step Pipeline from Icon Tap to Interactive
This article details a four-step HarmonyOS cold start optimization methodology: baseline measurement, deferred non-critical parsing, taskpool concurrency for heavy parsing, and skeleton screens with in-process caching to decouple first-frame and interactive metrics, validated via dual-track measurement on a sample 'Morning Brief' page.
Startup speed is the user's first impression of an app — unfair because there is no second chance. Slow cold starts directly hurt conversion and retention, yet they leave no crash reports. Worse, cold-start slowness is nearly invisible during development: test devices are high-end, mock data is small, and splash screens mask the frozen first frame.
Two Metrics, Not One
Optimization targets two distinct metrics:
First-frame time : when the user sees the first content frame (skeleton counts).
Interactive time : when first-screen data is ready and the page is tappable/scrollable.
Decoupling these metrics defines the optimization ceiling. A 1.5 s white-screen-then-all-at-once feels totally different from a 0.2 s skeleton + 0.8 s data-ready, even if total time is similar.
Startup Attribution: Three Segments
From icon tap to interactive, time splits into:
System segment : process launch, window scheduling, before onCreate (uncontrollable but must be measured).
Framework segment : loadContent to first frame — component tree build and render (depends on first-screen component weight and nesting).
Business segment : aboutToAppear data pipeline (where most optimization happens).
Proportions dictate strategy: high system share → client changes barely help; high framework share → restructure first-screen components; high business share → apply the four-step governance below.
Instrumentation: Dual-Track Measurement
Anchor t0 at the first line of EntryAbility.onCreate (closest observable to process start). Use Date.now() for ms precision and alignment with hilog timestamps.
// EntryAbility.ets
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
markAbilityCreate(Date.now()); // t0
// ...
}
onWindowStageCreate(windowStage: window.WindowStage): void {
markWindowStage(Date.now()); // system/framework boundary
windowStage.loadContent('pages/erqi/coldstart/ColdStartPage', (err) => {
// ...
});
}First-frame mark: attach onAttach on the first content/skeleton component (not aboutToAppear or onAppear). Interactive mark: timestamp at first-screen data assignment.
A RunRecorder class stores absolute timestamps; relative values are computed at display time. This decouples instrumentation from presentation, enabling the same recorder for both simulation and real tracks.
Experiment Lab: "Morning Brief" Page, Four Versions, Three Data Volumes
The test page shows a stats bar + news card feed. Its startup pipeline has three stages (extracted from real business):
Config parsing : 2000-item channel config JSON → parse + weight correction.
Feed parsing & processing : 1k/3k/6k brief items JSON → JSON.parse + per-item cleaning, trimming, fingerprint hash (heaviest stage).
Stats aggregation : full-data column dedup, average heat, top-3 hot searches (compute-intensive).
Four implementations on the same UI and data:
V1 Serial Baseline : Config, parsing, stats all synchronous on main thread → Baseline
V2 Deferred Split : First page first; config & stats deferred → First-frame time
V3 Taskpool Concurrency : Parsing & compute in @Concurrent taskpool; main thread awaits only essential data → Interactive time
V4 Full Orchestration : Skeleton-first + in-process cache → Both metrics fully decoupled
Three data volumes (1k/3k/6k) act as a "problem amplifier": gaps invisible on light data or strong devices widen dramatically on heavy data or weak devices. Data is deterministically generated from fixed pseudo-random seeds ( buildFeedJson / buildConfigJson) so every run is comparable.
Code is split into three layers: ColdStartData.ets (pure logic), ColdStartTasks.ets ( @Concurrent tasks), ColdStartPage.ets (UI, dashboard, skeleton).
Two measurement tracks share the same RunRecorder:
Simulation track : tap "Restart" to replay the pipeline; t0 = tap time. Controllable, repeatable.
Real track : t0 = EntryAbility.onCreate mark; aboutToAppear runs current mode. Reproduce via "Exit App" (warm start) or swipe away from recent tasks (cold start).
Dashboard uses lightweight ArkUI bar charts (Row width percentages) — the instrument must be lighter than the measured code.
Step 1: Baseline — V1 Serial Pipeline & Frozen Loader
V1 runs all three stages synchronously on the main thread. The JS timer-driven spinner freezes completely because the event loop's call stack is occupied; message queue callbacks (including spinner steps) and the render pipeline stall. The waterfall shows three contiguous bars pushing first-frame to the far right. First-frame ≈ sum of three stages + render.
Simulation track waits 30 ms before starting to let the shell render one frame, mimicking the system splash screen.
Step 2: V2 First-Frame Slimming — Essential vs. Non-Essential
Classify every startup task: "Is it visible on first screen?" Three reusable criteria:
Visibility : first-screen pixels depend on it? (Stats bar not on critical path.)
Time window : must it appear this frame? (Config parse can wait 200 ms.)
Dependency direction : does first-screen data depend on it, or vice versa? Only the former stays on critical path.
Result: only first-page feed (12 items) is essential. V2 parses only the first page, renders first frame, then defers config parse, full feed parse, and stats via await sleep(60) (placeholder for idle callback). First-frame drops sharply; interactive unchanged. Cost: full feed parsed twice (once for first page, once for full). Deferred work is a transition, not the endpoint.
Step 3: V3 Taskpool Concurrency — Move Heavy Work Off Main Thread
ArkTS taskpool with @Concurrent functions. Two hard constraints:
No closure over external variables — logic must be fully inlined.
Parameters and return values must be cross-thread serializable (primitives, plain objects, arrays). Hence data rows use only primitive fields.
// ColdStartTasks.ets
@Concurrent
export function parseFeedConcurrent(json: string): Array<BriefRow> {
const payload: FeedPayload = JSON.parse(json) as FeedPayload;
const list: BriefRow[] = payload.list;
const rows: Array<BriefRow> = [];
for (let i = 0; i < list.length; i++) {
const row: BriefRow = list[i];
let fp: number = 5381;
for (let k = 0; k < row.digest.length; k++) {
fp = (fp * 33 + row.digest.charCodeAt(k)) % 1000000007;
}
// ... explicit field copy, trim anomalies
rows.push(next);
}
return rows;
}Calling side:
const rows = await taskpool.execute(parseFeedConcurrent, json) as BriefRow[]; taskpool.executeserializes function + args, runs on worker thread, returns result via Promise. await yields the main thread, so the spinner stays fluid.
Critical orchestration: await only what blocks first screen first .
const rows = await taskpool.execute(parseFeedConcurrent, json) as BriefRow[];
this.applyFirstPage(firstPageOf(rows, FIRST_PAGE)); // ① main thread waits only essential
const st = await taskpool.execute(computeStatsConcurrent, rows) as BriefStats;
this.stats = st; // ② stats don't block
const conf = await taskpool.execute(parseConfigConcurrent, confJson) as ChannelConf[];
this.channelCount = conf.length; // ③ config least urgentGranular tasks (three separate functions) enable fine-grained await ordering. Merging into one big task would serialize inside the worker, saving no main-thread time.
Cross-thread serialization has a cost: passing the full parsed array to computeStatsConcurrent adds serialization overhead visible as a slightly longer bar. Prefer passing raw JSON string to worker and parsing there.
Step 4: V4 Skeleton Screen & Cache — Full Metric Decoupling
V3 interactive time equals essential feed parse time, but first-frame still waits for that data. Skeleton screen breaks the dependency: fixed structure (stats bar + card list) can render gray placeholders instantly.
Stack() {
if (this.phase === 2) {
Column() { this.contentReady() }
.onAttach(() => this.onContentAttach()); // data first-frame mark (V1-V3)
} else if (this.mode === 3) {
Column({ space: 10 }) {
this.skeletonCard();
this.skeletonCard();
this.skeletonCard();
}
.onAttach(() => this.onSkeletonAttach()); // skeleton first-frame mark (V4)
} else { /* V1-V3 spinner */ }
} onSkeletonAttachcalls rec.markFirstFrame(Date.now()) — first-frame locks the moment skeleton mounts; subsequent data swap doesn't affect it.
Two design nuances:
Structure alignment : skeleton layout must match real content exactly (heights, widths, gaps) to avoid layout shift on swap.
Restrained gray : background color 1-2 shades darker; no breathing animation or shimmer — skeleton should announce position, not distract.
In-process cache (module-level variables) stores parsed rows, stats, and data volume key. Cache hit reduces pipeline to "skeleton frame + cache read + assign" — waterfall bar nearly invisible. Cache key includes data volume so switching volumes forces miss (real apps use API version or time window).
Cache cannot fix cold start : module variables die with process. Swipe away from recent tasks → new process → cache miss. Cache only helps "warm" re-entries (tab switch, quick reopen). Disk cache (preferences/file) could extend to cold start but adds async I/O trade-offs; only worthwhile if parse cost is tens/hundreds of ms.
Real Cold Start Verification: The Second Track
Real track reads t0 from EntryAbility.onCreate mark. Two reproduction paths:
In-app exit : tap "Exit App" ( terminateSelf), relaunch from home. Process may be kept alive → warm start (cache + mode persist).
Full cold start : swipe away from recent tasks, relaunch. Fresh process, mode resets to V1, cache empty.
Distinguish by checking mode label and cache indicator on the page. System process-keeping policy determines warm vs. cold; don't rely on manual steps alone.
Absolute numbers vary by device (mid-range vs. flagship can differ 2x), but relative version relationships are stable: V1 interactive ≈ sum of three stages; V3 ≈ single feed stage; V4 first-frame ≈ one render frame.
Pitfall Checklist (Beyond the Four Steps)
Sync I/O in onCreate / onWindowStageCreate : large preferences/file reads, sync JSON.parse — blocks before first frame. Move later or off-thread.
Fake async : wrapping sync work in new Promise without await or fire-and-forget async — still blocks main thread. Waterfall reveals truth.
First-screen large images undecimated : decoding 4000×3000 original for a few-hundred-pixel view steals render frames. Decode at 2-3× display size, off-thread, size in request params.
Deferred task storm : chained zero-delay setTimeout after first frame floods main thread during scroll. Stagger, limit, or move to worker.
Instrumentation left in production : high-frequency marks become startup cost. Aggregate or sample in prod.
Cache without invalidation : at minimum version or time-window key; otherwise stale data silently persists.
Passing large objects to taskpool : serialization tax can eat half the concurrency gain. Pass raw JSON string or ID lists.
Summary: Methodology & Pre-Launch Checklist
Four transferable principles:
Measure first, optimize later : no baseline = blind; no re-measure = self-comfort. RunRecorder (30 lines) makes every step auditable.
Single variable : change one thing, re-measure immediately. Stacking four techniques hides which one worked.
Deterministic data : reproducible experiments need fixed seeds; random data destroys comparability.
Two metrics separate : first-frame = "app is alive"; interactive = "user can use". Defer/skeleton → first-frame; concurrency → interactive; cache → warm re-entry.
Pre-launch checklist:
Any sync I/O in onCreate / onWindowStageCreate? → defer or off-thread.
Sync compute >10 ms in aboutToAppear? → off-thread.
First-screen tasks each asked "essential?" → non-essential out of critical path.
Fixed first-screen structure but no skeleton? → add to decouple first-frame.
Taskpool args: large objects or raw strings? → pass raw response.
Cache invalidation key (version/time-window)? → add if missing.
Production instrumentation sampled/aggregated? → converge.
Optimization is done not when a millisecond target is hit, but when the waterfall shape changes: first-frame bar hugs the origin; three heavy bars scatter into the relaxed zone right of first-frame. Shape right → numbers right.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
