From tap to first frame: how an Android app starts
The complete path from a touch on the launcher icon to the first frame of a Compose screen, and where the time goes.
On this page
A cold start finishes in well under a second on a decent phone, yet it touches five processes, one fork(), a few dozen Binder calls and a full trip through the GPU. Knowing the order of events is what makes startup problems easy to reason about: at every moment you know who is working, and whether that work is the system's or yours.
The map
Read this first when short on time. Every branch is a section below.
The whole trip in one picture
Five actors take part. The Launcher is just another app. system_server is the process that hosts almost every system service. Zygote is a pre-warmed process that gets cloned to make yours. Your app process runs your code. SurfaceFlinger turns everybody's pixels into one image on the display.
startActivity over Binder · 3 fork request over a local socket · 4 fork · 5 attachApplication over Binder · 6 the launch and resume transaction, plus window add and relayout with WindowManager · 7 rendered buffers · 8 scanout on the next VSYNC.Everything below is these five actors passing messages in a fixed order. The rest of the article follows one cold start from top to bottom.
Cold, warm, hot: three kinds of start
The kind of start is decided by what already exists in memory when the tap lands. Two questions settle it: is the process alive, and is the Activity object alive?
flowchart TD
A([Tap the icon]) --> B{Process alive?}
B -- No --> C[Cold start]
B -- Yes --> D{Activity object alive?}
D -- No --> E[Warm start]
D -- Yes --> F[Hot start]
C --> C1["fork from Zygote, Application.onCreate, Activity.onCreate, first frame"]
E --> E1["Activity.onCreate, first frame"]
F --> F1["onRestart, onStart, onResume, redraw"]
| Start | What still exists | What has to run | Typical cause | Reported as slow above |
|---|---|---|---|---|
| Cold | Nothing | Process creation, Application.onCreate, Activity.onCreate, first frame | First launch after install or reboot; the system killed the process to reclaim memory | 5 s |
| Warm | The process and its Application | Activity.onCreate onwards, first frame | User backed out of the app; the activity was destroyed under memory pressure but the process survived | 2 s |
| Hot | Process and the Activity object | onRestart, onStart, onResume, a redraw | User switched away and came straight back | 1.5 s |
The thresholds in the last column are what the Play Console counts as excessive startup time. A warm start is a strict subset of a cold one, and a hot start is a subset of both, so understanding the cold path covers all three.
The tap: how a touch becomes a click
A tap is a kernel event that system_server turns into a MotionEvent and hands to whichever window owns the touched pixels. The app never talks to the touchscreen directly.
sequenceDiagram
autonumber
participant K as Kernel (evdev)
box transparent system_server
participant R as InputReader
participant D as InputDispatcher
end
participant L as Launcher (ViewRootImpl)
K->>R: raw events from /dev/input/eventN
R->>D: MotionEvent ACTION_DOWN, later ACTION_UP
D->>D: pick the window under the finger
D->>L: MotionEvent over the window's InputChannel
L->>L: dispatch down the View tree
L->>L: icon View: performClick()
L-->>D: finished (dispatch timeout cleared)
system_server.- The touch controller raises an interrupt; the kernel input driver publishes raw events on an
evdevdevice node. - The InputReader thread reads those events and cooks them into a
MotionEventwith screen coordinates and pointer ids. - The InputDispatcher thread asks WindowManager's window list which touchable window is under the finger and pushes the event down that window's InputChannel, a socket pair created when the window was added.
- In the Launcher process,
ViewRootImplreceives the event on the main thread and runs the normaldispatchTouchEventchain. The icon's view getsACTION_DOWN, thenACTION_UP, and callsperformClick(). - The Launcher's click handler now calls
startActivity. It also signals the dispatcher that the event was consumed; if an app does not do that within five seconds the system shows an ANR.
- InputChannel
- A pair of Unix domain sockets. One end lives in InputDispatcher, the other in the app's
ViewRootImpl. Every window gets its own when it is added to WindowManager. - MotionEvent
- The framework object for a pointer event: action, coordinates, pressure, timestamps, one entry per pointer.
Launcher asks the system to start your activity
startActivity is a request, not an action. The Launcher hands an Intent to system_server, and system_server decides everything else: which class, which task, whether a new process is needed, and what shows on screen meanwhile.
sequenceDiagram autonumber participant L as Launcher participant ATMS as ActivityTaskManager participant WMS as WindowManager participant AMS as ActivityManager L->>ATMS: startActivity(intent, options) over Binder ATMS->>ATMS: resolve the intent with PackageManager ATMS->>ATMS: checks, ActivityRecord, Task ATMS->>WMS: add a starting window (splash screen) for the record ATMS-->>L: START_SUCCESS ATMS->>L: PauseActivityItem ATMS->>AMS: startProcess(processName, uid) when no process is attached yet L-->>ATMS: activityPaused() Note over ATMS: waits for both: the Launcher paused and the app process attached
system_server does with a startActivity call. The splash screen is already on screen before the app process exists.Inside the Launcher process
The Launcher builds an explicit Intent with ACTION_MAIN, CATEGORY_LAUNCHER and your activity's ComponentName, usually with ActivityOptions that describe the icon's position for the launch animation. Activity.startActivity goes through Instrumentation.execStartActivity, which makes a Binder call into ActivityTaskManagerService. The Launcher's main thread blocks for the few milliseconds that call takes and gets back a result code.
Inside system_server
ActivityStarter owns the launch logic:
- Resolve. PackageManager turns the intent into an
ActivityInfo: class name, theme,launchMode,taskAffinity, and the process name from the manifest. - Check. Is the activity exported, does the caller hold the required permission, is the user allowed to start it?
- Record. A new
ActivityRecordis created and placed on top of an existingTaskor a fresh one, according to the launch mode and intent flags. - Starting window. WindowManager adds a starting window for the record right away. Since Android 12 this is the splash screen: your icon on your theme's
windowSplashScreenBackground, drawn by the system, not by your process. This is why the screen reacts instantly even though nothing of yours has run. - Pause the previous activity. The Launcher is the resumed activity, so it receives a
PauseActivityItemand runsonPause. The system waits foractivityPaused(or a 500 ms timeout) before it will resume the new one. A slowonPausein any app delays whatever launches next. - Process check. If a process with the target process name and uid is already attached, the launch proceeds straight to the activity (the warm path). If not,
ActivityManagerServiceis told to start one, and the record waits for it to attach.
- ActivityRecord
system_server's bookkeeping object for one activity instance: its state, its task, its window token, its process. YourActivityobject is the client-side counterpart.- Task
- The back stack. An ordered list of ActivityRecords that the user sees as one thing in Recents.
- WindowProcessController
- How
ActivityTaskManagerServicerefers to an app process: its pid, its activities, and the Binder handle for calling into it. - Starting window
- A placeholder window the system shows for an activity that has not drawn yet. On Android 12 and later it is the splash screen; earlier it was a plain window painted with the theme's
windowBackground.
A new process is born from Zygote
Zygote is a process that started at boot, loaded the ART runtime plus thousands of framework classes and common resources, and then went to sleep listening on a socket. Every app process is a fork() of it. Starting an app is a copy, not a boot, and the copied pages are shared with every other app until someone writes to them.
sequenceDiagram autonumber participant AMS as ActivityManager participant Z as Zygote participant P as New app process AMS->>AMS: decide uid, gids, ABI, SELinux label, runtime flags AMS->>Z: local socket: fork request, entry point android.app.ActivityThread Z->>Z: forkAndSpecialize() Z-->>P: child process, copy-on-write Z-->>AMS: pid P->>P: set uid, seccomp, Binder thread pool P->>P: ActivityThread.main, main Looper P->>AMS: attachApplication(IApplicationThread) over Binder P->>P: Looper.loop() forever
ActivityThread.main runs.What system_server sends
ProcessList.startProcessLocked computes what the new process needs to be: the uid assigned at install, supplementary gids for storage and network, the ABI to run (32 or 64 bit, which decides between the zygote and zygote64 sockets), the SELinux label, the nice name, and runtime flags such as debuggable. The request is written to Zygote's socket by ZygoteProcess, and the reply is the child's pid. A ProcessRecord in AMS and a WindowProcessController in ATMS now represent the process, and a ten second timer starts: if the process has not attached by then, the launch is abandoned.
What Zygote does
ZygoteConnection parses the arguments and calls forkAndSpecialize. In the child, native code sets the uid and gids, drops capabilities, applies the seccomp filter and the SELinux context, and closes the Zygote socket. The child then runs ZygoteInit.zygoteInit, which starts the Binder thread pool for this process and finds the requested entry point by reflection. That entry point is always android.app.ActivityThread.main, invoked with a clean stack.
Some devices keep a small pool of already-forked, not yet specialised processes (the USAP pool) so that the fork itself is off the critical path. The rest of the sequence is identical.
ActivityThread.main: where the main thread comes from
The thread that Zygote forked becomes the main thread by calling Looper.prepareMainLooper() and, at the very end, Looper.loop(). Between those two lines it creates one ActivityThread and calls attach, which makes the Binder call attachApplication(mAppThread, startSeq) into ActivityManagerService. mAppThread is an ApplicationThread, a Binder object that lives in your process; from now on system_server uses it to call back into you. Every callback it receives is posted as a message to the main Looper through the handler named H. That is the whole mechanism behind "everything happens on the main thread".
- ActivityThread
- One per process. Owns the main Looper, the
Application, everyActivity,ServiceandContentProviderinstance, and theHhandler that turns system callbacks into main-thread messages. - ApplicationThread
- The Binder stub inside
ActivityThread.system_serverholds its proxy and calls methods likebindApplicationandscheduleTransactionon it.
Your Application object comes to life
attachApplication is where system_server tells the new process who it is. The reply is a one-way Binder call, bindApplication, that carries the ApplicationInfo, the list of content providers to install, the instrumentation to use and the current configuration. The process then builds your Application and calls onCreate.
flowchart TD A["bindApplication arrives on the main thread"] --> B["LoadedApk: class loader, resources, native lib dir"] B --> C["Application instantiated by reflection"] C --> D["attachBaseContext()"] D --> E["every ContentProvider: constructor, onCreate()"] E --> F["Application.onCreate()"] F --> G["main thread free: next message is the activity launch"]
handleBindApplication. Providers run before your onCreate.In system_server, attachApplicationLocked matches the pid to its ProcessRecord, calls bindApplication on the process, and then asks ActivityTaskManagerService whether any activity is waiting for this process. Yours is, so the launch transaction is sent immediately after. Both arrive as messages on the main Looper, in that order.
Back in your process, handleBindApplication does the following, all on the main thread:
- Sets the process name, time zone, locale and configuration; enables StrictMode for debuggable builds.
- Creates the
LoadedApk: the in-memory representation of your APK. This is where thePathClassLoaderover your base and split APKs is created, together with theResourcesobject. - Creates the
Instrumentationand the app-levelContextImpl. - Instantiates your
Applicationsubclass (the class named byandroid:namein the manifest, or plainandroid.app.Application) throughAppComponentFactory, then callsattach, which is what runsattachBaseContext. - Instantiates every
ContentProviderdeclared in the merged manifest, callsonCreateon each, and publishes them toActivityManagerService. - Calls
Application.onCreate.
Content providers initialise before Application.onCreate, on the main thread, one after another. Libraries use this as an automatic entry point: any dependency that "just works" without an init call almost certainly declared a provider. Each one is a class load plus an onCreate that you pay for on every cold start. Check the merged manifest for <provider> entries you did not write. App Startup (androidx.startup) collapses them into one provider with an explicit dependency order.
class ClioApp : Application() {
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
// Earliest hook in the process, before any ContentProvider and before onCreate().
// Only things that must exist that early belong here.
}
override fun onCreate() {
super.onCreate()
// Runs once per process, on the main thread, before any Activity.
// Keep it cheap: build the dependency graph lazily, defer anything that is not
// needed for the first screen, and never touch disk or network synchronously.
appScope.launch { warmUpCaches() }
}
private suspend fun warmUpCaches() { /* ... */ }
}
- LoadedApk
- The runtime view of an installed package inside a process: its class loader, resources, application info and the
Applicationinstance once created. One per package loaded in the process. - Context
- The handle to the app environment: resources, assets, package name, system services, file directories. The
Applicationand eachActivitywrap their ownContextImpl.
The activity is launched
The system sends one transaction that says: create this activity, then bring it to the resumed state. The app's main thread walks the lifecycle to get there, and at the end of that walk it attaches the activity's window to WindowManager.
sequenceDiagram autonumber participant ATMS as ActivityTaskManager participant L as Launcher participant A as App main thread ATMS->>L: PauseActivityItem L->>L: onPause() L-->>ATMS: activityPaused() ATMS->>A: ClientTransaction: LaunchActivityItem + ResumeActivityItem A->>A: new MainActivity(), attach(): PhoneWindow A->>A: onCreate() with setContent A->>A: onStart(), onPostCreate() A->>A: onResume() A->>A: addView(decorView): ViewRootImpl A->>ATMS: addWindow() over Binder A->>A: first traversal, first frame A-->>ATMS: activityIdle, window drawn ATMS->>ATMS: remove starting window ATMS->>L: StopActivityItem L->>L: onStop()
onStop comes only after your first frame is on screen.What the transaction contains
realStartActivityLocked in system_server builds a ClientTransaction addressed to your ApplicationThread. It carries a LaunchActivityItem (intent, ActivityInfo, configuration, the activity token, saved state if any) and a target lifecycle state, normally ResumeActivityItem. In your process TransactionExecutor runs the launch item, then walks from the current lifecycle state to the requested one, calling each intermediate callback. That walk is where onStart comes from: nobody sends a "start" message, it is simply the state between created and resumed.
Creating the activity
performLaunchActivity creates an activity-scoped ContextImpl with its own Resources and display, instantiates your class by reflection through AppComponentFactory (which is why an Activity needs a public no-argument constructor), and calls Activity.attach. attach is where the Window comes from: it creates a PhoneWindow, sets the activity as its callback, and gives the activity a WindowManager bound to that window's token. Then onCreate runs.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) // ComponentActivity restores saved state, wires the lifecycle
setContent { // installs a ComposeView as the window's content
ClioTheme {
HomeScreen()
}
}
}
}
After onCreate, TransactionExecutor moves the activity through onStart (and onRestoreInstanceState when there is saved state) to onResume. The DecorView is still not attached to anything at this point; the activity is resumed but invisible.
Attaching the window
At the end of handleResumeActivity, and only then, the framework takes the window's DecorView and calls WindowManager.addView(decor, params). This does two things: it creates a ViewRootImpl, the object that connects a View tree to a window, and it makes the Binder call that registers the window with WindowManager, which creates a WindowState and the input channel for it. ViewRootImpl.setView also calls requestLayout, which schedules the first traversal.
When the main Looper next goes idle, ActivityThread reports activityIdle to system_server. Once your window has drawn and the launch transition has finished, system_server stops the activities that are no longer visible, so the Launcher gets its onStop after your first frame, not before.
- Window / PhoneWindow
- The framework's abstraction of a top-level surface with a View tree in it.
PhoneWindowis the only implementation. It owns theDecorView. - DecorView
- The root
FrameLayoutof every window. It holds the system chrome (insets, action bar if any) and a content frame with idandroid.R.id.contentwhere your view goes. - ViewRootImpl
- Not a View. It is the bridge between a View tree and WindowManager: it receives input, drives measure, layout and draw on VSYNC, and talks to the surface. One per window.
- ClientTransaction
- A batch of activity commands sent from
system_serverto an app process in one Binder call. Introduced so that lifecycle changes arrive in order and can be executed together.
What setContent actually does
setContent swaps the classic View content for a single ComposeView and arranges for a Recomposer tied to the window. The composition itself does not run inside onCreate; it runs the first time that view is attached to the window, which happens during the first traversal.
flowchart TD S["setContent in onCreate"] --> CV["ComposeView becomes the window content"] CV --> AT["first traversal: view attached to the window"] AT --> RC["Recomposer created for this window"] RC --> C1["Composition: run composables, build the LayoutNode tree"] C1 --> C2["Layout: measure and place every LayoutNode"] C2 --> C3["Draw: LayoutNodes draw into the Canvas"] C3 --> F["First frame"] F -. later state change .-> R["Recomposition on a following frame"] R --> C2
ComponentActivity.setContentcreates aComposeView, hands it your lambda, and callssetContentView. It also tags the DecorView with the activity asLifecycleOwner,ViewModelStoreOwnerandSavedStateRegistryOwner, which is howviewModel()andrememberSaveablefind their owners later.setContentViewgoes toPhoneWindow, which installs theDecorViewon first use and places theComposeViewinside the content frame.- Nothing else happens until the DecorView is attached. On the first traversal,
ViewRootImpldispatchesonAttachedToWindowdown the tree, andComposeViewresponds by creating its composition. - Creating the composition means finding or creating a
Recomposerfor the window. It runs on the main thread, on a dispatcher that is driven by theChoreographer, and it is paused while the lifecycle is belowSTARTED. - An
AndroidComposeViewis created as the only child of theComposeView. It is a realViewthat owns the rootLayoutNodeand translates View callbacks (measure, layout, draw, input) into Compose ones. - The initial composition runs synchronously: your composable lambdas execute, the slot table is filled, and a
LayoutNodeis emitted for every layout in the tree. All of this is still inside the first traversal, before measure.
The three phases
Compose builds a frame in three phases, and they map onto the View traversal that follows:
- Composition answers "what UI?". It runs your composables and produces the
LayoutNodetree. On launch it runs at attach time; later it reruns only for scopes whose state changed. - Layout answers "where and how big?". Each node measures its children and places them. This runs inside
AndroidComposeView.onMeasureandonLayout, that is, duringperformMeasureandperformLayoutof the traversal. - Drawing answers "what pixels?". Each node draws into the
Canvas. This runs insideAndroidComposeView.dispatchDrawduringperformDraw.
@Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel()) {
// Composition: this body runs on the main thread, inside the first traversal,
// before anything is drawn. Anything slow here delays the first frame.
val state by viewModel.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) { // Layout: measured and placed with the View tree
Text(state.title) // Draw: painted into the window's canvas
}
}
Because the first composition happens on the main thread inside the traversal, an expensive composable body is as bad for startup as an expensive onCreate. Keep the first screen's composables cheap and load data behind a state that starts as "loading".
- Recomposer
- The scheduler that watches snapshot state, decides which composition scopes are invalid, and reruns them on the next frame. One per window.
- Composition
- The result of running composables: the slot table (remembered values, groups) plus the node tree it emitted.
setContentcreates one for theComposeView. - LayoutNode
- Compose's equivalent of a View, but much lighter. Holds modifiers, a measure policy, children and draw commands. Managed by
AndroidComposeView, never by the View system.
The first frame: Choreographer, RenderThread, SurfaceFlinger
Nothing reaches the screen until a VSYNC pulse. The frame is built on the main thread, painted on the RenderThread, and combined with every other visible window by SurfaceFlinger. Each of those is a separate step with its own deadline.
sequenceDiagram autonumber participant M as Main thread participant W as WindowManager participant R as RenderThread participant SF as SurfaceFlinger M->>M: scheduleTraversals(): post to the Choreographer SF-->>M: VSYNC M->>M: doFrame(): run the traversal M->>W: relayout() over Binder W-->>M: a Surface for the window M->>M: attach to window: composition runs M->>M: measure, layout M->>M: draw: record display list M->>R: syncAndDrawFrame(display list) R->>R: render on the GPU R->>SF: queueBuffer SF->>SF: composite all layers SF->>SF: scanout M-->>W: first frame drawn W->>W: remove starting window, log Displayed
- Scheduling.
requestLayoutcallsscheduleTraversals, which puts a sync barrier on the mainLooper(so only asynchronous messages run until the frame is done) and asks theChoreographerto run the traversal on the next VSYNC. The Choreographer gets VSYNC from SurfaceFlinger through aDisplayEventReceiver. - VSYNC.
Choreographer.doFrameruns its callback queues in order: input, animation, insets animation, traversal, commit. The traversal callback callsViewRootImpl.performTraversals. - Relayout. On the first traversal
ViewRootImplasks WindowManager for its surface. WindowManager creates aSurfaceControl, which is a layer in SurfaceFlinger, and hands back aSurface. TheThreadedRendereris initialised against it on the RenderThread. - Attach.
dispatchAttachedToWindowwalks the tree. This is the moment theComposeViewcreates its composition, so your composables run here. - Measure and layout.
performMeasureandperformLayoutrun through the DecorView intoAndroidComposeView, which runs Compose's layout phase. - Draw.
performDrawdoes not paint pixels. It records drawing commands intoRenderNodedisplay lists; Compose's draw phase happens here. The display list is then handed to the RenderThread withsyncAndDrawFrame, and the main thread is free again. - Render. The RenderThread dequeues a buffer from the window's
BufferQueue, replays the display list through Skia on the GPU (OpenGL ES or Vulkan), and queues the finished buffer back. That buffer now belongs to SurfaceFlinger. - Composite. On its own next VSYNC, SurfaceFlinger takes the newest buffer of every visible layer (status bar, navigation bar, the starting window, your window) and combines them, using Hardware Composer overlays where it can and the GPU where it cannot. The result is scanned out to the panel on the VSYNC after that.
- Reporting. After the first draw,
ViewRootImpltells WindowManager the window has drawn. WindowManager removes the starting window with the splash exit animation, andActivityTaskManagerlogs the Displayed line with the time sincestartActivityenteredsystem_server.
The "Displayed" time ends at your first frame, which for most apps is a loading state. Activity.reportFullyDrawn(), or ReportDrawnWhen in Compose, logs a second line, "Fully drawn", when you say the screen is actually useful. Startup measurement tools read both.
- VSYNC
- The display's refresh pulse, 60 to 120 times a second. Every stage of the pipeline aligns its work to it so that frames are produced at a steady rate.
- Choreographer
- The main thread's frame scheduler. Apps post callbacks (animations, traversals) and it runs them when VSYNC arrives. One per
Looper. - RenderThread
- A second thread in every app process that talks to the GPU. The main thread records what to draw; the RenderThread draws it. That is why animations that run on the RenderThread, such as ripples, keep going even while the main thread is busy.
- SurfaceFlinger
- The system compositor. Every window is a layer with its own buffer queue; SurfaceFlinger merges them into the final image once per VSYNC.
- BufferQueue
- A small ring of graphics buffers shared between a producer (the app's RenderThread) and a consumer (SurfaceFlinger). Two or three buffers deep, so the app can draw the next frame while the last one is displayed.
Where the time goes and what you control
The first stretch of a cold start belongs to the system and costs about the same for every app. Everything after bindApplication is your code. That split is what decides which optimisations are worth doing.
The levers, in order of payoff
- Make
Application.onCreateboring. No synchronous disk or network, no reflection-heavy dependency graph built eagerly, no SDK init that the first screen does not need. Build singletons lazily and move the rest to a background coroutine or to first use. - Audit content providers. Open the merged manifest and count the
<provider>entries. Remove the ones you do not need withtools:node="remove"and initialise those libraries yourself, later. Use App Startup for the ones that must run early so they share one provider. - Keep the first composition light. The first screen should render a cheap loading state immediately and fill in behind a state flag. No
runBlocking, no large lists built synchronously, no image decoding in a composable body. - Ship a Baseline Profile. ART interprets and JIT-compiles fresh code on the first runs after install. A Baseline Profile tells the installer which methods to compile ahead of time, so the startup path runs as native code from the first launch. Generated with a Macrobenchmark
BaselineProfileRule, installed throughandroidx.profileinstaller, and often worth 20 to 30 percent of cold start time. - Use the splash screen correctly. Call
installSplashScreen()beforesuper.onCreate()and keep it on screen only while you genuinely have nothing to show. Never add a second custom splashActivity: that is a whole extra activity launch on the critical path. - Reduce what gets loaded. Fewer and lighter dependencies, R8 in release builds, and no work in static initialisers of classes touched during startup.
class MainActivity : ComponentActivity() {
private val viewModel: HomeViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
val splash = installSplashScreen() // must be called before super.onCreate()
super.onCreate(savedInstanceState)
splash.setKeepOnScreenCondition { viewModel.state.value.isLoading }
setContent {
val state by viewModel.state.collectAsStateWithLifecycle()
ReportDrawnWhen { !state.isLoading } // logs "Fully drawn" once real content is up
ClioTheme { HomeScreen(state) }
}
}
}
<!-- AndroidManifest.xml: drop a library's auto-init provider you do not want -->
<provider
android:name="com.example.somelib.InitProvider"
android:authorities="${applicationId}.somelib-init"
tools:node="remove" />
Measuring it
Never optimise from a guess. The cheapest measurement is one shell command; the most precise one is a trace.
# Force a cold start and print the timing. Kill the process first.
adb shell am force-stop com.example.clio
adb shell am start -W -n com.example.clio/.MainActivity
# Status: ok
# LaunchState: COLD
# TotalTime: 486 time until the first frame of the launched activity
# WaitTime: 493 TotalTime plus the time spent inside the am command itself
# The same numbers, as the system logs them
adb logcat -s ActivityTaskManager | grep -E "Displayed|Fully drawn"
am start -WgivesTotalTime, which matches the Displayed line. Run it several times and take the median; the first run after install includes one-off dex work.- Macrobenchmark with
StartupTimingMetricandStartupMode.COLDmeasurestimeToInitialDisplayMsandtimeToFullDisplayMsover many iterations on a real device, with and without a Baseline Profile. - Perfetto shows where the time went: the
bindApplication,activityStartandChoreographer#doFrameslices on the main thread, and every content provider and class load in between.
Recap
- Touch goes kernel → InputReader → InputDispatcher → Launcher's
ViewRootImpl→performClick→startActivity. startActivityis a Binder call intoActivityTaskManagerService. PackageManager resolves the intent;ActivityStartercreates anActivityRecordin aTask.- The starting window (splash screen) is shown by the system before your process exists.
- The Launcher is paused first; its
onStopcomes only after your first frame. - If no process is attached,
ActivityManagerServiceasks Zygote to fork one. Zygote already has ART and the framework loaded, so the fork is cheap and shared. - The child runs
ActivityThread.main: main Looper,attachApplicationBinder call, thenLooper.loop()forever. bindApplicationbuilds theLoadedApk, theApplication, callsattachBaseContext, initialises every ContentProvider, and only thenApplication.onCreate.- One
ClientTransactionlaunches the activity: instantiate →attach(creates thePhoneWindow) →onCreate→onStart→onResume→WindowManager.addView→ViewRootImpl. setContentinstalls aComposeView; the composition runs when the view attaches, inside the first traversal, before measure.- Compose phases: composition (what), layout (where), draw (pixels). Layout and draw ride on the View traversal.
- First frame: VSYNC →
Choreographer→performTraversals→ display list → RenderThread → GPU → SurfaceFlinger → panel. - After the first draw, the starting window is removed and "Displayed" is logged.
reportFullyDrawnmarks the moment real content is visible. - System cost is fixed; your cost is providers,
Application.onCreate,Activity.onCreateand the first composition. Measure witham start -W, Macrobenchmark and Perfetto.
Questions
Try answering each one out loud before opening it. A good answer leads with the one-line version, then the supporting steps, then one detail that shows you have been underneath it.
Walk through what happens between tapping an app icon and seeing its first frame.
The Launcher asks system_server to start the activity, system_server forks a process from Zygote if needed, that process builds the Application and the Activity, and the first frame goes through the View traversal, the RenderThread and SurfaceFlinger.
- The touch is routed by InputDispatcher in
system_serverto the Launcher window, which handles the click and callsstartActivity. ActivityTaskManagerServiceresolves the intent with PackageManager, creates anActivityRecordin a Task, shows the starting window, and pauses the Launcher.- If no process exists,
ActivityManagerServiceasks Zygote to fork one. The child runsActivityThread.mainand callsattachApplication. bindApplicationcreates theLoadedApk, theApplication, the content providers, and runsApplication.onCreate.- A
ClientTransactionlaunches the activity: instantiate,attach,onCreatewithsetContent,onStart,onResume, thenWindowManager.addViewcreates theViewRootImpl. - On the next VSYNC the traversal runs: the composition is created at attach, then measure, layout and draw. The display list goes to the RenderThread, the buffer to SurfaceFlinger, and the pixels to the panel.
- WindowManager removes the starting window and "Displayed" is logged; the Launcher is stopped afterwards.
A detail worth adding: the splash screen is drawn by the system before the app process exists, and content providers initialise before Application.onCreate.
What is the difference between a cold, warm and hot start?
It depends on what is still in memory: cold means no process, warm means the process is alive but the activity must be recreated, hot means both exist and the activity just comes back to the foreground.
- Cold: fork from Zygote,
Application.onCreate,Activity.onCreate, first frame. Happens after install, reboot, or when the system killed the process. - Warm: the process and Application survive, so only
Activity.onCreateonwards runs, often with a saved-state Bundle. - Hot:
onRestart,onStart,onResumeand a redraw. NoonCreate.
The Play Console flags cold starts above 5 seconds, warm above 2 and hot above 1.5. Each type is a subset of the previous one, so most of the work that makes a cold start fast helps the other two as well.
What is Zygote and why are app processes forked from it instead of started fresh?
Zygote is a process started at boot that has already initialised the ART runtime and preloaded the framework classes and resources; every app process is a fork() of it, so creating a process costs a copy instead of a full runtime start.
- Fork is copy-on-write, so the preloaded pages are physically shared by every app until written to. That saves both time and memory across the whole device.
ActivityManagerServicesends the request over a local socket; Zygote forks, and the child sets its uid, gids, SELinux label and seccomp filter before running any app code.- The child's entry point is
ActivityThread.main, which is where the main thread and Looper come from.
On 64-bit devices there are two Zygotes, one per ABI, and some builds keep a small pool of pre-forked processes so the fork is off the critical path.
What does ActivityThread.main do, and what actually is the "main thread"?
The main thread is simply the thread Zygote forked, turned into a message loop: ActivityThread.main prepares the main Looper, creates one ActivityThread, attaches to ActivityManagerService, and then loops forever.
attachmakes the Binder callattachApplicationand hands over anApplicationThread, the Binder object the system will use to call back into the process.- Every system callback (
bindApplication, activity transactions, service commands) arrives on a Binder thread and is posted to the main Looper through theHhandler. - Nothing app-specific has been loaded when
mainruns; the process does not yet know which app it is untilbindApplicationarrives.
This is why "the main thread" and "the UI thread" are the same thing: there is one Looper, and both the framework callbacks and the View traversals are messages on it.
In what order do attachBaseContext, ContentProvider.onCreate and Application.onCreate run, and why does it matter?
attachBaseContext first, then every ContentProvider's constructor and onCreate, then Application.onCreate, all on the main thread inside handleBindApplication.
- It matters because libraries use providers as an automatic initialisation hook. Each one you ship runs on every cold start before your own code gets a chance.
- It also means a provider cannot rely on anything set up in
Application.onCreate; it can only rely on the Context it is given. - App Startup replaces many providers with a single one and lets initialisers declare dependencies and run lazily.
The practical move is to read the merged manifest, remove providers you do not want with tools:node="remove", and initialise those libraries when they are first needed.
Who draws the splash screen if the app process does not exist yet?
The system does. WindowManager adds a starting window for the ActivityRecord as soon as the launch is accepted, and it is drawn from your theme's attributes by system_server and the SystemUI shell, not by your code.
- Since Android 12 that window is the splash screen: icon plus background from
windowSplashScreenBackgroundandwindowSplashScreenAnimatedIcon. - It stays until your window reports its first draw, then it is removed with the exit animation.
installSplashScreen()lets you hold it longer with a condition and customise the exit. - Before Android 12 the same slot was a plain window painted with
windowBackground, which is why the old advice was to set a themed background.
Because it is on screen before your process exists, the user sees a response within a frame or two even when the cold start takes half a second.
Which system service decides whether a new process is needed, and how?
ActivityTaskManagerService decides, by checking whether a process with the target's process name and uid is already attached; if not, it asks ActivityManagerService to start one.
- The process name comes from the manifest (
android:process, defaulting to the package name), so two activities can share or split processes. ActivityManagerServiceowns process lifecycle: it computes uid, gids, ABI and flags inProcessList.startProcessLockedand talks to Zygote throughZygoteProcess.- The
ActivityRecordthen waits; when the new process callsattachApplication, the pending launch is dispatched right afterbindApplication.
On recent versions the process start is kicked off while the previous activity is still pausing, so the two overlap instead of running back to back.
How does the system tell an app process to launch an activity?
With a ClientTransaction: one Binder call to the process's ApplicationThread that carries a LaunchActivityItem and a target lifecycle state, usually ResumeActivityItem.
- The transaction is posted to the main Looper and executed by
TransactionExecutor. - The launch item creates the activity, calls
attachandonCreate. The executor then walks the lifecycle from created to resumed, which is what producesonStartandonResume. - The same mechanism carries pause, stop, destroy and configuration changes, which keeps lifecycle events ordered.
Because the target is a state rather than a callback, the framework can skip or add intermediate steps; that is why nobody ever sends an explicit "start" message.
What happens in Activity.attach(), and where does the Window come from?
attach wires the freshly constructed activity into the framework, and it is where the PhoneWindow is created; the activity does not have a window before that.
- It receives the activity Context, the
ActivityThread, the token that identifies the activity tosystem_server, theApplicationand the intent. - It creates a
PhoneWindow, sets the activity as the window's callback so key and touch events reach it, and gives the activity aWindowManagerbound to the window's token. - Only after
attachdoesonCreaterun, sosetContentViewandsetContenthave a window to put content into.
The activity is constructed by reflection through AppComponentFactory, which is why it needs a public no-argument constructor.
When is ViewRootImpl created, and what does it do on the first traversal?
It is created at the end of handleResumeActivity, after onResume, when the framework calls WindowManager.addView with the DecorView; on the first traversal it gets the window's surface, attaches the tree, then measures, lays out and draws.
addViewcreates theViewRootImpl, callssetView, which schedules a traversal, and registers the window with WindowManager over Binder.- On VSYNC,
performTraversalscallsrelayoutto obtain aSurface, dispatchesonAttachedToWindow(this is when the Compose composition is created), then runs measure, layout and draw. - Draw records a display list and hands it to the RenderThread;
ViewRootImpllater reports the first frame to WindowManager, which removes the starting window.
The activity is resumed before it is visible: onResume runs while the DecorView is still detached, which surprises people who expect a drawn screen at that point.
What does setContent do under the hood in Jetpack Compose?
It installs a ComposeView as the activity's content and stores your lambda; the actual composition is created when that view attaches to the window, during the first traversal.
ComponentActivity.setContentcreates theComposeView, callssetContentView, and tags the DecorView with the activity as lifecycle, ViewModel store and saved-state owner.- On attach, the
ComposeViewfinds or creates the window'sRecomposer, creates anAndroidComposeViewas its child, and runs the initial composition synchronously, producing theLayoutNodetree. - Measure, layout and draw then flow from the View traversal into
AndroidComposeView, which runs the Compose layout and draw phases.
Practical consequence: composable bodies run on the main thread before the first frame, so heavy work in them delays startup just like heavy work in onCreate.
Name the three Compose phases and say when the first one runs during launch.
Composition, layout and drawing. Composition runs when the ComposeView attaches to the window, which is inside the first View traversal, before measure.
- Composition runs the composables and emits the
LayoutNodetree. Later it reruns only invalidated scopes, scheduled by theRecomposeron the next Choreographer frame. - Layout measures and places each node, inside
AndroidComposeView.onMeasureandonLayout. - Drawing paints each node into the Canvas, inside
dispatchDraw, which records into the display list.
Compose can skip phases: a state read only in a draw lambda invalidates just drawing, not composition or layout.
What roles do Choreographer, VSYNC, the RenderThread and SurfaceFlinger play in producing the first frame?
VSYNC is the clock, Choreographer runs the main-thread frame work on that clock, the RenderThread turns the recorded display list into GPU output, and SurfaceFlinger composites every window's buffer into the image the panel shows.
scheduleTraversalsposts a callback to the Choreographer, which wakes on the next VSYNC from SurfaceFlinger and runs input, animation, then traversal callbacks.- The traversal measures, lays out and records a display list;
syncAndDrawFramehands it to the RenderThread, freeing the main thread. - The RenderThread dequeues a buffer from the window's BufferQueue, renders with Skia on the GPU, and queues it to SurfaceFlinger.
- SurfaceFlinger composites on its own VSYNC, using Hardware Composer overlays when possible, and the result is scanned out on the following one.
That pipeline is why there is a two to three frame latency between "the app drew" and "the pixels changed", even when every stage is fast.
How is the "Displayed" time measured, and what does reportFullyDrawn change?
"Displayed" is measured in system_server from the moment startActivity is received until the activity's window reports its first drawn frame; reportFullyDrawn adds a second, app-defined milestone called "Fully drawn".
- It is logged by
ActivityTaskManagerand is the same numberam start -Wprints asTotalTime. - For most apps the first frame is a loading state, so "Displayed" understates the real wait.
Activity.reportFullyDrawn(), orReportDrawnWhenin Compose, marks the moment useful content is visible. Macrobenchmark reports both astimeToInitialDisplayMsandtimeToFullDisplayMs.
Without the call, Macrobenchmark has no full-display milestone to measure, so timeToFullDisplayMs simply stays empty.
What happens to the Launcher's lifecycle while your app launches?
The Launcher gets onPause before your activity is created and onStop only after your first frame is on screen and the launch transition has finished.
ActivityTaskManagerServicepauses the current resumed activity first and waits foractivityPaused, with a 500 ms timeout, before resuming yours.- Stopping happens later, when the new activity has reported idle and its window has drawn; activities that are no longer visible are then stopped.
- The same rule applies between two of your own activities: a slow
onPausein the previous one delays the next launch, andonStopof the previous one is not a signal that the next one is starting.
This ordering is why the Launcher is still fully visible under the splash screen during the entire cold start.
What are the most effective ways to reduce cold start time, and how would you measure the effect?
Cut the work in the app-owned half: content providers, Application.onCreate, Activity.onCreate and the first composition; then ship a Baseline Profile so what remains runs compiled. Measure with am start -W for a quick check and Macrobenchmark for real numbers.
- Remove or defer library auto-init providers; use App Startup for the ones that must stay.
- Keep
Application.onCreatefree of I/O and eager graph construction; initialise lazily or on a background dispatcher. - Render a cheap first screen and load behind a state flag; avoid
runBlockingand heavy composable bodies. - Generate a Baseline Profile with
BaselineProfileRule; expect around 20 to 30 percent off cold start. - Use the SplashScreen API rather than a separate splash activity.
For diagnosis, a Perfetto trace of the main thread from bindApplication to the first Choreographer#doFrame shows exactly which provider, class load or composable took the time.