The question
Play Console said about three in four of our whiteboard’s cold starts were slow, over Android vitals’ 5-second line. On my desk, the same release opened in 2.4 seconds on a Teachmint X panel and 2.9 s on a TeachmintX 75 Pro. That was five runs each, nowhere near five seconds.
One of those numbers had to be wrong. Neither was. Getting there took a week of measuring the measurements, and the fixes only started after that.
My initial mental model
Cold start is a single number with a single cause. Find the slowest thing on the main thread before the first frame, move it, and the number goes down. If the lab disagrees with the field, the lab isn’t reproducing field conditions. So I tried to make the lab look like the field:
- I reset compiled code, so the app ran like a fresh install: 2.69 s against 2.42 s.
- I put the CPU under load: about 2.6 s.
Neither came anywhere near 5 seconds.
What actually happens
The two dashboards were looking at different fleets
We also collect our own telemetry from the panels. A week of it covered thousands of panels. Cold start came out at a median of about 4.5 s. That is slower than my desk, but fewer than half of cold starts were over 5 s, not three in four.
The explanation was in a column I hadn’t looked at. Play’s report had one Android version row: Android 14. In our telemetry, almost every panel runs Android 16. The two dashboards were mostly describing different firmware populations. On the few Android 14 panels that do report to our telemetry, most cold starts were over 5 s. That’s close to Play’s number. It’s a small sample, but it points the same way.
So the first lesson came before any code: know which devices a vitals dashboard is counting. I now track Play’s slow-start rate for the Android 14 fleet and our own p50/p90 for the Android 16 fleet.
Half the field’s cold starts happen while the panel is still booting
Splitting our telemetry by uptime explained the rest of the gap with my desk. More than half of cold starts happen within two minutes of the panel booting, and those are the slow ones. On panels that had been up for more than four hours, the median was about 2.5 s, which is my desk number.
A classroom panel is switched on, and the whiteboard launches straight away while the system is still starting everything else. My desk panel had been up for days. The lab wasn’t wrong. It measured the best case.
Debug builds lie about where the time goes
The first traces I took were from debug builds, and they were about 4.5× slower than release-like ones. ART doesn’t precompile debuggable apps, so a debug trace spends its time in places a production launch doesn’t. Fresh debug installs were worse still. On the first two launches, opening and extracting dex files cost 2.4 s and 1.25 s before any of our code ran.
From then on, absolute timings come from release-like builds: R8 on, not debuggable, installed next to the production app. Debug builds were only used for relative A/B comparisons.
Where the time actually went
On a TeachmintX 75 Pro, three cold starts of a release-like build with our own WB: trace sections
gave 2,913 ms from launch to first frame:
| Phase | ms | Share |
|---|---|---|
| Layout inflation (the whiteboard screen’s layout) | 1,495 | 51% |
| First frame (measure, layout, draw) | 612 | 21% |
bindApplication |
310 | 11% |
| Process start | 142 | 5% |
Inflation was the single biggest cost, and most of it wasn’t inflation:
| Inside the inflate | ms |
|---|---|
The Edu AI WebView declared in XML, starting Chromium |
414 |
| LaTeX renderer init | 238 |
| 40 selection-badge icons decoded on the main thread | 222 |
| ML Kit shape recognizer | 196 |
That’s about 1,070 ms of the 1,495 that had no reason to run before the board was on screen.
A production Layout Inspector snapshot added the other half of the picture. At launch the board had 184 views and 78 ComposeViews. 37 of the ComposeViews were composed, 24 of them hidden, and nine full-screen compose layers were visible on top of one another.
The fixes
Make the WebView a host, not a view
A <WebView> in a layout file starts Chromium the moment it’s inflated, even under an invisible
parent. The layout now holds an empty FrameLayout, and the manager creates the WebView into it on
first access:
// Simplified. The layout only declares `host`, an empty FrameLayout.
val webView: WebView
get() = host.getChildAt(0) as? WebView
?: WebView(host.context).also { host.addView(it, MATCH_PARENT) }
Startup asks for it only after the first frame is on screen and the looper is idle. ML Kit and the LaTeX init moved there too:
// Simplified.
afterFirstFrame { // a frame callback, then an idle handler
smartTools.loadHome() // creates the WebView now
startShapeRecognizer()
initLatex()
}
I caught one regression from this in testing. After the change, the Edu AI page didn’t switch accounts on logout and login, because two callbacks that fire before the first frame now found no WebView and skipped. The fix was to have them create it on demand like everything else, rather than assume startup already had.
A hidden ComposeView still composes
This one surprised me. Once setContent has run, a ComposeView composes as soon as it’s
attached to a window, whatever its visibility. Dialog hosts that start GONE were building
full compositions at launch for UI nobody had opened.
// Simplified. Holds the content while hidden, and composes on the
// first layout pass that finds the view visible.
fun ComposeView.setContentWhenShown(content: @Composable () -> Unit) {
if (isVisible) { setContent(content); return }
pending = content
doOnLayout { if (isVisible) setContent(pending!!) }
}
I used it for four hosts that have nothing to do while hidden. Hidden compositions at launch went 18 → 9, first frame 612 → 567 ms, and cold start 2,874 → 2,819 ms. That’s 55 ms. It was a real fix, but small, and a reminder to measure each change rather than add up estimates.
INVISIBLE is not GONE on a 4K screen
Several full-screen hosts were VISIBLE or INVISIBLE at launch. INVISIBLE still takes part in
measure and layout, and on a 3840×2160 panel each full-screen view is 8.3 megapixels of work per
frame. Six on-demand hosts now start GONE, and the dismiss paths use gone() instead of hide().
Three loaders and a trial banner, 28 views in total, moved behind ViewStubs. The launch hierarchy
went from 232 → 182 views and from 8 → 3 visible full-screen compose layers. In a Perfetto A/B
of debug builds, the fragment’s inflate went from 334 → 233 ms.
Decode icons once, after the first frame
The 40 bitmaps drawn around a selected object were decoded on the main thread in onCreateView, at
200–260 ms in every trace, cold or warm. PNG decoding is native code, so no amount of ART compilation
makes it cheaper. They were also decoded a second time in the ML Kit callback, just to pass one
boolean, which caused another ~200 ms freeze about 3.5 s after launch. Now the decode runs once on
Dispatchers.Default after the first frame, and the callback only flips a flag. A selection made
before the icons arrive shows its badges a moment later.
The surprising part: Baseline Profiles on a system app
Once startup had less work in it, I turned to the code that remained. A Baseline Profile tells ART which classes and methods to compile ahead of time. It’s the standard fix for an app whose startup code runs interpreted or JIT-compiled.
Our setup bends the usual recipe in three places:
- The production app can’t be replaced by a local build. On the panels, the release package is a
release-signed system priv-app. The Macrobenchmark build types install under the
.debugapplication id instead, with Crashlytics uploads off. - Not every panel installs from Play. Many get the app through our own OTA and MDM channels,
where Play’s cloud profiles never arrive. Pinning
profileinstallermakes those installs compile the profile too. - The emulator is the wrong device. My first profile came from a tablet emulator. Generated on a real Teachmint X panel (Android 16, user build, no root needed), it had 48,470 rules, 2,667 of them only on the panel. Those covered the IFP-only SDK paths the emulator never reaches. Emulator timings were also too noisy to A/B. On the panel, runs spread by about ±90 ms.
Results on a TeachmintX 75 Pro, Macrobenchmark, 10 cold starts each, time to initial display:
| Compilation | Median | Range |
|---|---|---|
| None | 2,758 ms | 2,655–2,824 |
| Library profiles only | 2,253 ms | 2,163–2,335 |
| With our app’s Baseline Profile | 1,807 ms | 1,742–1,859 |
| Production build on the same panel, for reference | 2,430 ms |
The app profile is worth ~450 ms on top of the library profiles, and about 950 ms (34%) against no compilation. Compared with the production build on the same panel, the branch with the profile is about 620 ms faster to first frame.
The profile also pointed at the next problem. About 4,570 of its rules are kotlin.reflect, so
something on the launch path uses full Kotlin reflection. That’s the next thing to remove.
Practical consequences
All of this has shipped. The fleet numbers aren’t in yet, and they’re what will settle it. For the Android 16 fleet I’ll judge it by our telemetry’s p50/p90, and for the Android 14 fleet by Play’s slow-start rate, because each dashboard can only see its own fleet.
Boot contention is the part no in-app fix removes. When half of all cold starts happen while the system is still starting, the whiteboard competes with everything else that launches at boot. The remaining lever is doing less before the first frame, which is what most of this work was.
What I would remember
- Check which devices a vitals dashboard is counting before trusting it, or doubting it.
- Split field data by uptime. “Cold start” right after boot and “cold start” on a settled device are different problems.
- Never profile startup on a debug build. It was 4.5× slower and spent its time in different places.
- A
ComposeViewcomposes when attached, even if hidden. DefersetContentuntil it’s shown. - Generate Baseline Profiles on the real device, and remember
profileinstallerif you don’t ship through Play.
Further reading
- App startup time and the 5-second slow cold start threshold.
- Baseline Profiles and Macrobenchmark.
- ProfileInstaller for apps installed outside Play.
- Perfetto trace processor.